From cc4a830be81a2f84fb6327eff9210fa9de1207f7 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 6 Jul 2026 19:06:24 +0800 Subject: [PATCH] Harden backup and download token flows --- src/handlers/attachments.ts | 11 +++---- src/handlers/backup.ts | 15 +++++++-- src/handlers/sends-public.ts | 10 +++--- src/router-admin-backup.ts | 2 +- webapp/src/App.tsx | 5 ++- webapp/src/components/AppMainRoutes.tsx | 2 +- webapp/src/components/BackupCenterPage.tsx | 38 ++++++++++++---------- webapp/src/hooks/useBackupActions.ts | 4 +-- webapp/src/lib/api/backup.ts | 10 +++--- webapp/src/lib/demo.ts | 2 +- 10 files changed, 58 insertions(+), 41 deletions(-) diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index 81c4e6e..9a432f3 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -439,17 +439,16 @@ export async function handlePublicDownloadAttachment( } const path = getAttachmentObjectKey(cipherId, attachmentId); - const object = await getBlobObject(env, path); - - if (!object) { - return errorResponse('Attachment file not found', 404); - } - const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp); if (!firstUse) { return errorResponse('Invalid or expired token', 401); } + const object = await getBlobObject(env, path); + if (!object) { + return errorResponse('Attachment file not found', 404); + } + return new Response(object.body, { headers: { 'Content-Type': sanitizeDownloadContentType(object.contentType), diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index bdeba31..407d444 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -1062,12 +1062,21 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise { if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); + let body: { destinationId?: string; path?: string; masterPasswordHash?: string }; + try { + body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>(); + } catch { + return errorResponse('Remote backup integrity payload is invalid', 400); + } + + const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env); + if (verificationError) return verificationError; + const storage = new StorageService(env.DB); try { const settings = await loadBackupSettings(storage, env, 'UTC'); - const url = new URL(request.url); - const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || ''); - const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null); + const path = ensureRemoteRestoreCandidate(String(body.path || '')); + const destination = requireBackupDestination(settings, body.destinationId || null); const remoteFile = await downloadRemoteBackupFile(destination, path); const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path); return jsonResponse({ diff --git a/src/handlers/sends-public.ts b/src/handlers/sends-public.ts index 1129616..bdeadef 100644 --- a/src/handlers/sends-public.ts +++ b/src/handlers/sends-public.ts @@ -300,17 +300,17 @@ export async function handleDownloadSendFile( return errorResponse(SEND_INACCESSIBLE_MSG, 404); } + const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp); + if (!firstUse) { + return errorResponse('Invalid or expired token', 401); + } + const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId)); if (!object) { return errorResponse('Send file not found', 404); } const fileName = typeof data.fileName === 'string' ? data.fileName : fileId; - const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp); - if (!firstUse) { - return errorResponse('Invalid or expired token', 401); - } - return new Response(object.body, { headers: { 'Content-Type': sanitizeDownloadContentType(object.contentType), diff --git a/src/router-admin-backup.ts b/src/router-admin-backup.ts index e995254..d076fe4 100644 --- a/src/router-admin-backup.ts +++ b/src/router-admin-backup.ts @@ -54,7 +54,7 @@ export async function handleAdminBackupRoute( return handleDownloadAdminRemoteBackup(request, env, actorUser); } - if (path === '/api/admin/backup/remote/integrity' && method === 'GET') { + if (path === '/api/admin/backup/remote/integrity' && method === 'POST') { return handleInspectAdminRemoteBackup(request, env, actorUser); } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index e1bbff0..bbef688 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -2120,7 +2120,10 @@ export default function App() { const hash = await deriveCurrentMasterPasswordHash(masterPassword); return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress); }, - onInspectRemoteBackup: backupActions.inspectRemoteBackup, + onInspectRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => { + const hash = await deriveCurrentMasterPasswordHash(masterPassword); + return backupActions.inspectRemoteBackup(hash, destinationId, path); + }, onDeleteRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => { const hash = await deriveCurrentMasterPasswordHash(masterPassword); return backupActions.deleteRemoteBackup(hash, destinationId, path); diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index a2ce516..3b2d462 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -168,7 +168,7 @@ export interface AppMainRoutesProps { onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise; onListRemoteBackups: (destinationId: string, path: string) => Promise; onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise; - onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>; + onInspectRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>; onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise; diff --git a/webapp/src/components/BackupCenterPage.tsx b/webapp/src/components/BackupCenterPage.tsx index 50d6d94..771d074 100644 --- a/webapp/src/components/BackupCenterPage.tsx +++ b/webapp/src/components/BackupCenterPage.tsx @@ -42,7 +42,7 @@ interface BackupCenterPageProps { onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise; onListRemoteBackups: (destinationId: string, path: string) => Promise; onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise; - onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>; + onInspectRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>; onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise; @@ -492,8 +492,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { return verifyBackupFileIntegrity(bytes, file.name || ''); } - async function inspectRemoteBackupFile(destinationId: string, path: string): Promise { - const payload = await props.onInspectRemoteBackup(destinationId, path); + async function inspectRemoteBackupFile(masterPassword: string, destinationId: string, path: string): Promise { + const payload = await props.onInspectRemoteBackup(masterPassword, destinationId, path); return { source: 'remote', path, @@ -800,19 +800,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { if (!savedSelectedDestination) return; setLocalError(''); resetPendingIntegrityWarning(); - try { - const integrity = await inspectRemoteBackupFile(savedSelectedDestination.id, path); - if (!integrity.result.matches) { - setPendingRestoreIntegrity(integrity); - setConfirmIntegrityWarningOpen(true); - return; - } - await runRemoteRestore(path, false, false, integrity.result); - } catch (error) { - const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed'); - setLocalError(message); - props.onNotify('error', message); - } + await runRemoteRestore(path, false); } async function runRemoteRestore( @@ -846,7 +834,23 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { setRestoringRemotePath(path); setLocalError(''); try { - const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path); + const integrity = knownIntegrity + ? { result: knownIntegrity } + : await inspectRemoteBackupFile(masterPassword, savedSelectedDestination.id, path); + if (!allowChecksumMismatch && !integrity.result.matches) { + setPendingRestoreIntegrity( + 'source' in integrity + ? integrity + : { + source: 'remote', + path, + fileName: path.split('/').pop() || path, + result: integrity.result, + } + ); + setConfirmIntegrityWarningOpen(true); + return true; + } startRestoreProgress('backup-restore', path.split('/').pop() || path, { source: 'remote', delayMs: replaceExisting ? 480 : 1400, diff --git a/webapp/src/hooks/useBackupActions.ts b/webapp/src/hooks/useBackupActions.ts index e00f8ae..b1772df 100644 --- a/webapp/src/hooks/useBackupActions.ts +++ b/webapp/src/hooks/useBackupActions.ts @@ -82,8 +82,8 @@ export default function useBackupActions(options: UseBackupActionsOptions) { downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); }, - async inspectRemoteBackup(destinationId: string, path: string) { - return inspectRemoteBackupIntegrity(authedFetch, destinationId, path); + async inspectRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) { + return inspectRemoteBackupIntegrity(authedFetch, masterPasswordHash, destinationId, path); }, async deleteRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) { diff --git a/webapp/src/lib/api/backup.ts b/webapp/src/lib/api/backup.ts index c91bc5b..d8f6ea2 100644 --- a/webapp/src/lib/api/backup.ts +++ b/webapp/src/lib/api/backup.ts @@ -420,13 +420,15 @@ export async function deleteRemoteBackup( export async function inspectRemoteBackupIntegrity( authedFetch: AuthedFetch, + masterPasswordHash: string, destinationId: string, path: string ): Promise { - const params = new URLSearchParams(); - params.set('destinationId', destinationId); - params.set('path', path); - const resp = await authedFetch(`/api/admin/backup/remote/integrity?${params.toString()}`, { method: 'GET' }); + const resp = await authedFetch('/api/admin/backup/remote/integrity', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ destinationId, path, masterPasswordHash }), + }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed'))); const body = await parseJson(resp); if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response')); diff --git a/webapp/src/lib/demo.ts b/webapp/src/lib/demo.ts index e2cf829..a891823 100644 --- a/webapp/src/lib/demo.ts +++ b/webapp/src/lib/demo.ts @@ -1201,7 +1201,7 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => { notify('success', t('txt_demo_download_prepared')); }, - onInspectRemoteBackup: async (_destinationId: string, path: string) => ({ + onInspectRemoteBackup: async (_masterPassword: string, _destinationId: string, path: string) => ({ object: 'backup-remote-integrity', destinationId: _destinationId, path,