diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index ee00881..bdeba31 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -1239,7 +1239,28 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env: try { const url = new URL(request.url); - const blobName = ensureBackupBlobName(url.searchParams.get('blobName') || ''); + let input: { blobName?: unknown; masterPasswordHash?: unknown } = {}; + if (request.method === 'POST') { + try { + input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>(); + } catch { + return errorResponse('Backup attachment download payload is invalid', 400); + } + } else { + input = { + blobName: url.searchParams.get('blobName') || '', + masterPasswordHash: url.searchParams.get('masterPasswordHash') || '', + }; + } + + const verificationError = await requireBackupUserVerification( + actorUser, + String(input.masterPasswordHash || ''), + env + ); + if (verificationError) return verificationError; + + const blobName = ensureBackupBlobName(String(input.blobName || '')); const object = await getBlobObject(env, blobName); if (!object) { return errorResponse('Backup attachment blob not found', 404); diff --git a/src/router-admin-backup.ts b/src/router-admin-backup.ts index d0512a4..e995254 100644 --- a/src/router-admin-backup.ts +++ b/src/router-admin-backup.ts @@ -26,7 +26,7 @@ export async function handleAdminBackupRoute( return handleAdminExportBackup(request, env, actorUser); } - if (path === '/api/admin/backup/blob' && method === 'GET') { + if (path === '/api/admin/backup/blob' && (method === 'GET' || method === 'POST')) { return handleDownloadAdminBackupAttachment(request, env, actorUser); } diff --git a/src/services/backup-config.ts b/src/services/backup-config.ts index dfdcb7a..47b14b1 100644 --- a/src/services/backup-config.ts +++ b/src/services/backup-config.ts @@ -124,11 +124,26 @@ function assertBackupEndpointHostAllowed(hostname: string, label: string): void if (!normalized) throw new Error(`${label} host is required`); if ( normalized === 'localhost' || + normalized === 'localhost.localdomain' || + normalized.endsWith('.localhost.localdomain') || normalized.endsWith('.localhost') || normalized.endsWith('.local') || + normalized.endsWith('.home.arpa') || normalized.endsWith('.internal') || normalized.endsWith('.lan') || - normalized === 'metadata.google.internal' + normalized === 'metadata.google.internal' || + normalized === 'localtest.me' || + normalized.endsWith('.localtest.me') || + normalized === 'lvh.me' || + normalized.endsWith('.lvh.me') || + normalized === 'vcap.me' || + normalized.endsWith('.vcap.me') || + normalized === 'nip.io' || + normalized.endsWith('.nip.io') || + normalized === 'sslip.io' || + normalized.endsWith('.sslip.io') || + normalized === 'xip.io' || + normalized.endsWith('.xip.io') ) { throw new Error(`${label} host is not allowed`); } diff --git a/src/services/backup-uploader.ts b/src/services/backup-uploader.ts index e7c7dcc..5e25bba 100644 --- a/src/services/backup-uploader.ts +++ b/src/services/backup-uploader.ts @@ -241,19 +241,38 @@ function webDavFullPath(config: WebDavBackupDestination, relativePath: string): return buildJoinedPath(config.remotePath, normalizeRelativePath(relativePath)); } +async function fetchBackupEndpoint( + input: string | URL, + init: RequestInit, + label: string +): Promise { + const url = normalizeBackupEndpointUrl(input.toString(), label); + const response = await fetch(url, { + ...init, + redirect: 'manual', + }); + if (response.status >= 300 && response.status < 400) { + throw new Error(`${label} must not redirect`); + } + if (response.redirected) { + throw new Error(`${label} must not redirect`); + } + return response; +} + async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, authHeader: string): Promise { const segments = trimSlashes(directoryPath).split('/').filter(Boolean); let current = ''; for (const segment of segments) { current = buildJoinedPath(current, segment); const url = buildWebDavUrl(baseUrl, current); - const response = await fetch(url, { + const response = await fetchBackupEndpoint(url, { method: 'MKCOL', headers: { Authorization: authHeader, }, - }); - if ([200, 201, 204, 301, 302, 405].includes(response.status)) continue; + }, 'WebDAV directory URL'); + if ([200, 201, 204, 405].includes(response.status)) continue; throw new Error(`WebDAV directory creation failed: ${response.status}`); } } @@ -270,13 +289,13 @@ async function ensureWebDavDirectoryCached( current = buildJoinedPath(current, segment); if (ensuredDirectories.has(current)) continue; const url = buildWebDavUrl(baseUrl, current); - const response = await fetch(url, { + const response = await fetchBackupEndpoint(url, { method: 'MKCOL', headers: { Authorization: authHeader, }, - }); - if ([200, 201, 204, 301, 302, 405].includes(response.status)) { + }, 'WebDAV directory URL'); + if ([200, 201, 204, 405].includes(response.status)) { ensuredDirectories.add(current); continue; } @@ -303,7 +322,7 @@ async function putToWebDav( } } - const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), { + const response = await fetchBackupEndpoint(buildWebDavUrl(config.baseUrl, remoteFilePath), { method: 'PUT', headers: { Authorization: authHeader, @@ -311,7 +330,7 @@ async function putToWebDav( 'Content-Length': String(bytes.byteLength), }, body: bytes, - }); + }, 'WebDAV upload URL'); if (!response.ok) { throw new Error(`WebDAV upload failed: ${response.status}`); @@ -340,7 +359,7 @@ async function listWebDavEntries(config: WebDavBackupDestination, relativePath: const currentPath = normalizeRelativePath(relativePath); const targetFullPath = webDavFullPath(config, currentPath); const authHeader = toBasicAuthHeader(config.username, config.password); - const response = await fetch(buildWebDavUrl(config.baseUrl, targetFullPath), { + const response = await fetchBackupEndpoint(buildWebDavUrl(config.baseUrl, targetFullPath), { method: 'PROPFIND', headers: { Authorization: authHeader, @@ -348,7 +367,7 @@ async function listWebDavEntries(config: WebDavBackupDestination, relativePath: 'Content-Type': 'application/xml; charset=utf-8', }, body: ``, - }); + }, 'WebDAV listing URL'); if (response.status === 404) { return { provider: 'webdav', @@ -408,12 +427,12 @@ async function downloadFromWebDav(config: WebDavBackupDestination, relativePath: } const authHeader = toBasicAuthHeader(config.username, config.password); const remotePath = webDavFullPath(config, normalized); - const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), { + const response = await fetchBackupEndpoint(buildWebDavUrl(config.baseUrl, remotePath), { method: 'GET', headers: { Authorization: authHeader, }, - }); + }, 'WebDAV download URL'); if (!response.ok) { throw new Error(`WebDAV download failed: ${response.status}`); } @@ -429,12 +448,12 @@ async function downloadFromWebDav(config: WebDavBackupDestination, relativePath: async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise { const authHeader = toBasicAuthHeader(config.username, config.password); const remotePath = webDavFullPath(config, relativePath); - const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), { + const response = await fetchBackupEndpoint(buildWebDavUrl(config.baseUrl, remotePath), { method: 'DELETE', headers: { Authorization: authHeader, }, - }); + }, 'WebDAV delete URL'); if (!response.ok && response.status !== 404) { throw new Error(`WebDAV delete failed: ${response.status}`); } @@ -447,12 +466,12 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str async function statWebDavFile(config: WebDavBackupDestination, relativePath: string): Promise { const authHeader = toBasicAuthHeader(config.username, config.password); const remotePath = webDavFullPath(config, relativePath); - const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), { + const response = await fetchBackupEndpoint(buildWebDavUrl(config.baseUrl, remotePath), { method: 'HEAD', headers: { Authorization: authHeader, }, - }); + }, 'WebDAV stat URL'); if (response.status === 404) return null; if (!response.ok) { throw new Error(`WebDAV existence check failed: ${response.status}`); @@ -519,7 +538,7 @@ async function signedS3Request( config.region || 'auto' ); - return fetch(url.toString(), { + return fetchBackupEndpoint(url, { method, headers: { Authorization: authorization, @@ -528,7 +547,7 @@ async function signedS3Request( ...(method === 'PUT' ? { 'Content-Type': headers['content-type'] } : {}), }, body, - }); + }, 'S3 endpoint URL'); } async function putToS3( diff --git a/webapp/src/lib/api/backup.ts b/webapp/src/lib/api/backup.ts index c2ba009..c91bc5b 100644 --- a/webapp/src/lib/api/backup.ts +++ b/webapp/src/lib/api/backup.ts @@ -196,11 +196,14 @@ export async function exportAdminBackup( export async function downloadAdminBackupAttachmentBlob( authedFetch: AuthedFetch, - blobName: string + blobName: string, + masterPasswordHash: string ): Promise { - const params = new URLSearchParams(); - params.set('blobName', blobName); - const resp = await authedFetch(`/api/admin/backup/blob?${params.toString()}`, { method: 'GET' }); + const resp = await authedFetch('/api/admin/backup/blob', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ blobName, masterPasswordHash }), + }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed'))); return new Uint8Array(await resp.arrayBuffer()); } @@ -246,7 +249,7 @@ export async function buildCompleteAdminBackupExport( stageDetail: 'txt_backup_export_progress_fetch_attachments_detail', }); for (const attachment of manifest.attachmentBlobs || []) { - const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName); + const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName, masterPasswordHash); zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes; }