From 4f5d992f10e070e37306a3e8ba22438ed879cc10 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 31 May 2026 19:53:42 +0800 Subject: [PATCH] fix: enhance cipher handling with repairable URI support and sync improvements --- .tmp-bitwarden-clients | 1 + src/handlers/ciphers.ts | 69 +++++++++++++++++++++++--------- src/handlers/sync.ts | 18 ++++++--- webapp/src/App.tsx | 21 +++++++--- webapp/src/lib/api/auth.ts | 3 ++ webapp/src/lib/api/vault-sync.ts | 10 ++++- 6 files changed, 92 insertions(+), 30 deletions(-) create mode 160000 .tmp-bitwarden-clients diff --git a/.tmp-bitwarden-clients b/.tmp-bitwarden-clients new file mode 160000 index 0000000..d07a6ac --- /dev/null +++ b/.tmp-bitwarden-clients @@ -0,0 +1 @@ +Subproject commit d07a6acf62adca40298fff8517df0e4c2e9d4b8f diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index abf7e3b..a885cf5 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -24,6 +24,18 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events' // unknown/future client fields by default, then override only server-owned // fields. Any change to cipher response shape must be checked against /api/sync, // attachments, import/export, and current official clients. +export interface CipherResponseOptions { + preserveRepairableUris?: boolean; +} + +export function shouldPreserveRepairableCipherUris(request: Request): boolean { + return request.headers.get('X-NodeWarden-Web') === '1'; +} + +function cipherResponseOptionsForRequest(request: Request): CipherResponseOptions { + return { preserveRepairableUris: shouldPreserveRepairableCipherUris(request) }; +} + function normalizeOptionalId(value: unknown): string | null { if (value == null) return null; const normalized = String(value).trim(); @@ -169,7 +181,11 @@ export function normalizeCipherLoginForStorage(login: any): any { }; } -export function normalizeCipherLoginForCompatibility(login: any, requiresUriChecksum: boolean = false): any { +export function normalizeCipherLoginForCompatibility( + login: any, + requiresUriChecksum: boolean = false, + preserveRepairableUris: boolean = false +): any { const normalized = normalizeCipherLoginForStorage(login); if (!normalized || typeof normalized !== 'object') return normalized ?? null; const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']); @@ -177,6 +193,7 @@ export function normalizeCipherLoginForCompatibility(login: any, requiresUriChec next.uris = normalizeCipherLoginUrisForCompatibility(next.uris, { hasLegacyLoginUri: isValidEncString(next.uri), requiresUriChecksum, + preserveRepairableUris, }); next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials); return next; @@ -184,7 +201,7 @@ export function normalizeCipherLoginForCompatibility(login: any, requiresUriChec function normalizeCipherLoginUrisForCompatibility( uris: any, - options: { hasLegacyLoginUri?: boolean; requiresUriChecksum?: boolean } = {} + options: { hasLegacyLoginUri?: boolean; requiresUriChecksum?: boolean; preserveRepairableUris?: boolean } = {} ): any[] | null { if (!Array.isArray(uris) || uris.length === 0) return null; const out: any[] = []; @@ -204,11 +221,18 @@ function normalizeCipherLoginUrisForCompatibility( } if (hasUri && !hasChecksum) { - // Bitwarden browser clients using the SDK can fail the whole vault load - // when an item-key encrypted URI has no encrypted checksum. The server - // cannot derive the checksum, so expose the item without the bad URI. + if (options.preserveRepairableUris) { + // Preserve the encrypted URI so NodeWarden Web can decrypt it and repair + // the missing checksum. Dropping it here makes the URI appear lost and + // can turn a display-only compatibility issue into data loss on save. + out.push({ ...next, uriChecksum: null }); + continue; + } + // Bitwarden browser clients using the SDK drop item-key encrypted URIs + // whose checksum is missing/invalid. User-key encrypted legacy/import + // entries bypass this validation and can safely keep the URI. if (options.requiresUriChecksum || options.hasLegacyLoginUri) continue; - out.push({ ...next, uri: null, uriChecksum: null }); + out.push({ ...next, uriChecksum: null }); continue; } @@ -555,12 +579,17 @@ export function isCipherResponseSyncCompatible(cipher: CipherResponse): boolean // survive a round-trip without code changes. export function cipherToResponse( cipher: Cipher, - attachments: Attachment[] = [] + attachments: Attachment[] = [], + options: CipherResponseOptions = {} ): CipherResponse { // Strip internal-only fields that must not appear in the API response const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher; const responseCipherKey = optionalEncString(cipher.key); - const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, !!responseCipherKey); + const normalizedLogin = normalizeCipherLoginForCompatibility( + (passthrough as any).login ?? null, + !!responseCipherKey, + !!options.preserveRepairableUris + ); const normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']); const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [ 'title', @@ -654,10 +683,11 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin ); // Build responses only for the current page to keep pagination cheap. + const responseOptions = cipherResponseOptionsForRequest(request); const cipherResponses: CipherResponse[] = []; for (const cipher of filteredCiphers) { const attachments = attachmentsByCipher.get(cipher.id) || []; - cipherResponses.push(cipherToResponse(cipher, attachments)); + cipherResponses.push(cipherToResponse(cipher, attachments, responseOptions)); } return jsonResponse({ @@ -677,8 +707,9 @@ export async function handleGetCipher(request: Request, env: Env, userId: string } const attachments = await storage.getAttachmentsByCipher(cipher.id); + const responseOptions = cipherResponseOptionsForRequest(request); return jsonResponse( - cipherToResponse(cipher, attachments) + cipherToResponse(cipher, attachments, responseOptions) ); } @@ -756,9 +787,10 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + const responseOptions = cipherResponseOptionsForRequest(request); return jsonResponse( - cipherToResponse(cipher, []), + cipherToResponse(cipher, [], responseOptions), 200 ); } @@ -864,9 +896,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); const attachments = await storage.getAttachmentsByCipher(cipher.id); + const responseOptions = cipherResponseOptionsForRequest(request); return jsonResponse( - cipherToResponse(cipher, attachments) + cipherToResponse(cipher, attachments, responseOptions) ); } @@ -893,7 +926,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str }); return jsonResponse( - cipherToResponse(cipher, []) + cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request)) ); } @@ -968,7 +1001,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st notifyVaultSyncForRequest(request, env, userId, revisionDate); return jsonResponse( - cipherToResponse(cipher, []) + cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request)) ); } @@ -1007,7 +1040,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user notifyVaultSyncForRequest(request, env, userId, revisionDate); return jsonResponse( - cipherToResponse(cipher, []) + cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request)) ); } @@ -1051,7 +1084,7 @@ async function buildCipherListResponse( return jsonResponse({ data: ciphers.map((cipher) => - cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []) + cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], cipherResponseOptionsForRequest(request)) ), object: 'list', continuationToken: null, @@ -1084,7 +1117,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st const attachments = await storage.getAttachmentsByCipher(cipher.id); return jsonResponse( - cipherToResponse(cipher, attachments) + cipherToResponse(cipher, attachments, cipherResponseOptionsForRequest(request)) ); } @@ -1106,7 +1139,7 @@ export async function handleUnarchiveCipher(request: Request, env: Env, userId: const attachments = await storage.getAttachmentsByCipher(cipher.id); return jsonResponse( - cipherToResponse(cipher, attachments) + cipherToResponse(cipher, attachments, cipherResponseOptionsForRequest(request)) ); } diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index b5b98c8..b3763bf 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -1,7 +1,7 @@ import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types'; import { StorageService } from '../services/storage'; import { errorResponse } from '../utils/response'; -import { cipherToResponse, isCipherResponseSyncCompatible } from './ciphers'; +import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepairableCipherUris } from './ciphers'; import { sendToResponse } from './sends'; import { LIMITS } from '../config/limits'; import { @@ -16,10 +16,17 @@ import { buildDomainsResponse } from '../services/domain-rules'; // Filtering invalid cipher responses here protects clients from stored rows that // would otherwise make official apps fail after an HTTP 200 sync. // Keep this aligned with src/handlers/ciphers.ts when adding new vault fields. -function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request { +function buildSyncCacheRequest( + request: Request, + userId: string, + revisionDate: string, + excludeDomains: boolean, + excludeSends: boolean, + preserveRepairableUris: boolean +): Request { const url = new URL(request.url); const cacheUrl = new URL( - `/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}`, + `/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}/${excludeSends ? '1' : '0'}/${preserveRepairableUris ? '1' : '0'}`, url.origin ); return new Request(cacheUrl.toString(), { method: 'GET' }); @@ -43,6 +50,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam); const excludeSendsParam = url.searchParams.get('excludeSends'); const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam); + const preserveRepairableUris = shouldPreserveRepairableCipherUris(request); const user = await storage.getUserById(userId); if (!user) { @@ -50,7 +58,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr } const revisionDate = await storage.getRevisionDate(userId); - const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends); + const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains, excludeSends, preserveRepairableUris); const cachedResponse = await readSyncCache(cacheRequest); if (cachedResponse) { return cachedResponse; @@ -93,7 +101,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr const cipherResponses: CipherResponse[] = []; for (const cipher of ciphers) { - const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []); + const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris }); if (isCipherResponseSyncCompatible(response)) { cipherResponses.push(response); } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index bb55e96..f5d9b09 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -25,8 +25,8 @@ import { import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin'; import { getDomainRules, saveDomainRules } from '@/lib/api/domains'; import { getSends } from '@/lib/api/send'; -import { repairCipherUriChecksums } from '@/lib/api/vault'; -import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; +import { repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault'; +import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { parseSignalRTextFrames, @@ -1086,9 +1086,18 @@ export default function App() { const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`; if (uriChecksumRepairAttemptRef.current !== repairKey) { uriChecksumRepairAttemptRef.current = repairKey; - void repairCipherUriChecksums(authedFetch, session, result.ciphers) - .then((uriChecksumCount) => { - if (uriChecksumCount > 0) void refetchVaultCoreData(); + void repairCipherKeyMismatches(authedFetch, session, result.ciphers) + .then(async (keyMismatchCount) => { + if (keyMismatchCount > 0) { + await invalidateVaultCoreSyncSnapshot(vaultCacheKey); + void refetchVaultCoreData(); + return; + } + const uriChecksumCount = await repairCipherUriChecksums(authedFetch, session, result.ciphers); + if (uriChecksumCount > 0) { + await invalidateVaultCoreSyncSnapshot(vaultCacheKey); + void refetchVaultCoreData(); + } }) .catch(() => { // Best-effort compatibility repair must not interrupt normal vault loading. @@ -1106,7 +1115,7 @@ export default function App() { return () => { active = false; }; - }, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers]); + }, [session?.symEncKey, session?.symMacKey, vaultCacheKey, encryptedFolders, encryptedCiphers]); useEffect(() => { if (IS_DEMO_MODE) return; diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index fe43be5..382a386 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -453,6 +453,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess if (!session?.accessToken) throw new Error('Unauthorized'); const headers = new Headers(init.headers || {}); headers.set('Authorization', `Bearer ${session.accessToken}`); + headers.set('X-NodeWarden-Web', '1'); let resp = await retryableRequest(headers); if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp; @@ -461,6 +462,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess if (latest?.accessToken && latest.accessToken !== session.accessToken) { const latestHeaders = new Headers(init.headers || {}); latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`); + latestHeaders.set('X-NodeWarden-Web', '1'); resp = await retryableRequest(latestHeaders); if (resp.status !== 401) return resp; } @@ -486,6 +488,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess const retryHeaders = new Headers(init.headers || {}); retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`); + retryHeaders.set('X-NodeWarden-Web', '1'); resp = await retryableRequest(retryHeaders); return resp; }; diff --git a/webapp/src/lib/api/vault-sync.ts b/webapp/src/lib/api/vault-sync.ts index ada3bae..8e0fbb7 100644 --- a/webapp/src/lib/api/vault-sync.ts +++ b/webapp/src/lib/api/vault-sync.ts @@ -1,6 +1,6 @@ import type { Cipher, Folder, Send } from '../types'; import { getVaultRevisionDate } from './auth'; -import { loadCachedVaultCoreSnapshot, saveCachedVaultCoreSnapshot, type VaultCoreSnapshot } from '../vault-cache'; +import { clearCachedVaultCoreSnapshot, loadCachedVaultCoreSnapshot, saveCachedVaultCoreSnapshot, type VaultCoreSnapshot } from '../vault-cache'; import { parseJson, type AuthedFetch } from './shared'; interface VaultSyncResponse { @@ -43,6 +43,14 @@ export async function getCachedVaultCoreSnapshot(cacheKey: string): Promise { + const normalizedKey = String(cacheKey || '').trim(); + if (!normalizedKey) return; + pendingVaultCoreRequests.delete(normalizedKey); + memoryVaultCoreCache.delete(normalizedKey); + await clearCachedVaultCoreSnapshot(normalizedKey); +} + export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise { const normalizedKey = String(cacheKey || '').trim(); if (!normalizedKey) return { ciphers: [], folders: [], sends: [] };