fix: enhance cipher handling with repairable URI support and sync improvements

This commit is contained in:
shuaiplus
2026-05-31 19:53:42 +08:00
parent 667afa305b
commit 4f5d992f10
6 changed files with 92 additions and 30 deletions
Submodule .tmp-bitwarden-clients added at d07a6acf62
+51 -18
View File
@@ -24,6 +24,18 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
// unknown/future client fields by default, then override only server-owned // unknown/future client fields by default, then override only server-owned
// fields. Any change to cipher response shape must be checked against /api/sync, // fields. Any change to cipher response shape must be checked against /api/sync,
// attachments, import/export, and current official clients. // 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 { function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null; if (value == null) return null;
const normalized = String(value).trim(); 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); const normalized = normalizeCipherLoginForStorage(login);
if (!normalized || typeof normalized !== 'object') return normalized ?? null; if (!normalized || typeof normalized !== 'object') return normalized ?? null;
const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']); const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']);
@@ -177,6 +193,7 @@ export function normalizeCipherLoginForCompatibility(login: any, requiresUriChec
next.uris = normalizeCipherLoginUrisForCompatibility(next.uris, { next.uris = normalizeCipherLoginUrisForCompatibility(next.uris, {
hasLegacyLoginUri: isValidEncString(next.uri), hasLegacyLoginUri: isValidEncString(next.uri),
requiresUriChecksum, requiresUriChecksum,
preserveRepairableUris,
}); });
next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials); next.fido2Credentials = normalizeFido2CredentialsForCompatibility(next.fido2Credentials);
return next; return next;
@@ -184,7 +201,7 @@ export function normalizeCipherLoginForCompatibility(login: any, requiresUriChec
function normalizeCipherLoginUrisForCompatibility( function normalizeCipherLoginUrisForCompatibility(
uris: any, uris: any,
options: { hasLegacyLoginUri?: boolean; requiresUriChecksum?: boolean } = {} options: { hasLegacyLoginUri?: boolean; requiresUriChecksum?: boolean; preserveRepairableUris?: boolean } = {}
): any[] | null { ): any[] | null {
if (!Array.isArray(uris) || uris.length === 0) return null; if (!Array.isArray(uris) || uris.length === 0) return null;
const out: any[] = []; const out: any[] = [];
@@ -204,11 +221,18 @@ function normalizeCipherLoginUrisForCompatibility(
} }
if (hasUri && !hasChecksum) { if (hasUri && !hasChecksum) {
// Bitwarden browser clients using the SDK can fail the whole vault load if (options.preserveRepairableUris) {
// when an item-key encrypted URI has no encrypted checksum. The server // Preserve the encrypted URI so NodeWarden Web can decrypt it and repair
// cannot derive the checksum, so expose the item without the bad URI. // 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; if (options.requiresUriChecksum || options.hasLegacyLoginUri) continue;
out.push({ ...next, uri: null, uriChecksum: null }); out.push({ ...next, uriChecksum: null });
continue; continue;
} }
@@ -555,12 +579,17 @@ export function isCipherResponseSyncCompatible(cipher: CipherResponse): boolean
// survive a round-trip without code changes. // survive a round-trip without code changes.
export function cipherToResponse( export function cipherToResponse(
cipher: Cipher, cipher: Cipher,
attachments: Attachment[] = [] attachments: Attachment[] = [],
options: CipherResponseOptions = {}
): CipherResponse { ): CipherResponse {
// Strip internal-only fields that must not appear in the API response // Strip internal-only fields that must not appear in the API response
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher; const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
const responseCipherKey = optionalEncString(cipher.key); 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 normalizedCard = sanitizeEncryptedObject((passthrough as any).card ?? null, ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code']);
const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [ const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [
'title', '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. // Build responses only for the current page to keep pagination cheap.
const responseOptions = cipherResponseOptionsForRequest(request);
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of filteredCiphers) { for (const cipher of filteredCiphers) {
const attachments = attachmentsByCipher.get(cipher.id) || []; const attachments = attachmentsByCipher.get(cipher.id) || [];
cipherResponses.push(cipherToResponse(cipher, attachments)); cipherResponses.push(cipherToResponse(cipher, attachments, responseOptions));
} }
return jsonResponse({ return jsonResponse({
@@ -677,8 +707,9 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
} }
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
const responseOptions = cipherResponseOptionsForRequest(request);
return jsonResponse( 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); await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
const responseOptions = cipherResponseOptionsForRequest(request);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, []), cipherToResponse(cipher, [], responseOptions),
200 200
); );
} }
@@ -864,9 +896,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
const revisionDate = await storage.updateRevisionDate(userId); const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate); notifyVaultSyncForRequest(request, env, userId, revisionDate);
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
const responseOptions = cipherResponseOptionsForRequest(request);
return jsonResponse( 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( 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); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse( 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); notifyVaultSyncForRequest(request, env, userId, revisionDate);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, []) cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request))
); );
} }
@@ -1051,7 +1084,7 @@ async function buildCipherListResponse(
return jsonResponse({ return jsonResponse({
data: ciphers.map((cipher) => data: ciphers.map((cipher) =>
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []) cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], cipherResponseOptionsForRequest(request))
), ),
object: 'list', object: 'list',
continuationToken: null, continuationToken: null,
@@ -1084,7 +1117,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
const attachments = await storage.getAttachmentsByCipher(cipher.id); const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse( 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); const attachments = await storage.getAttachmentsByCipher(cipher.id);
return jsonResponse( return jsonResponse(
cipherToResponse(cipher, attachments) cipherToResponse(cipher, attachments, cipherResponseOptionsForRequest(request))
); );
} }
+13 -5
View File
@@ -1,7 +1,7 @@
import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types'; import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response'; import { errorResponse } from '../utils/response';
import { cipherToResponse, isCipherResponseSyncCompatible } from './ciphers'; import { cipherToResponse, isCipherResponseSyncCompatible, shouldPreserveRepairableCipherUris } from './ciphers';
import { sendToResponse } from './sends'; import { sendToResponse } from './sends';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { import {
@@ -16,10 +16,17 @@ import { buildDomainsResponse } from '../services/domain-rules';
// Filtering invalid cipher responses here protects clients from stored rows that // Filtering invalid cipher responses here protects clients from stored rows that
// would otherwise make official apps fail after an HTTP 200 sync. // would otherwise make official apps fail after an HTTP 200 sync.
// Keep this aligned with src/handlers/ciphers.ts when adding new vault fields. // 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 url = new URL(request.url);
const cacheUrl = new 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 url.origin
); );
return new Request(cacheUrl.toString(), { method: 'GET' }); 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 excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
const excludeSendsParam = url.searchParams.get('excludeSends'); const excludeSendsParam = url.searchParams.get('excludeSends');
const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam); const excludeSends = excludeSendsParam !== null && /^(1|true|yes)$/i.test(excludeSendsParam);
const preserveRepairableUris = shouldPreserveRepairableCipherUris(request);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) { if (!user) {
@@ -50,7 +58,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
} }
const revisionDate = await storage.getRevisionDate(userId); 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); const cachedResponse = await readSyncCache(cacheRequest);
if (cachedResponse) { if (cachedResponse) {
return cachedResponse; return cachedResponse;
@@ -93,7 +101,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) { 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)) { if (isCipherResponseSyncCompatible(response)) {
cipherResponses.push(response); cipherResponses.push(response);
} }
+15 -6
View File
@@ -25,8 +25,8 @@ import {
import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin'; import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin';
import { getDomainRules, saveDomainRules } from '@/lib/api/domains'; import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
import { getSends } from '@/lib/api/send'; import { getSends } from '@/lib/api/send';
import { repairCipherUriChecksums } from '@/lib/api/vault'; import { repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault';
import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync';
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
import { import {
parseSignalRTextFrames, parseSignalRTextFrames,
@@ -1086,9 +1086,18 @@ export default function App() {
const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`; const repairKey = `${session.accessToken}:${encryptedCiphers.map((cipher) => `${cipher.id}:${cipher.revisionDate || ''}`).join(',')}`;
if (uriChecksumRepairAttemptRef.current !== repairKey) { if (uriChecksumRepairAttemptRef.current !== repairKey) {
uriChecksumRepairAttemptRef.current = repairKey; uriChecksumRepairAttemptRef.current = repairKey;
void repairCipherUriChecksums(authedFetch, session, result.ciphers) void repairCipherKeyMismatches(authedFetch, session, result.ciphers)
.then((uriChecksumCount) => { .then(async (keyMismatchCount) => {
if (uriChecksumCount > 0) void refetchVaultCoreData(); 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(() => { .catch(() => {
// Best-effort compatibility repair must not interrupt normal vault loading. // Best-effort compatibility repair must not interrupt normal vault loading.
@@ -1106,7 +1115,7 @@ export default function App() {
return () => { return () => {
active = false; active = false;
}; };
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers]); }, [session?.symEncKey, session?.symMacKey, vaultCacheKey, encryptedFolders, encryptedCiphers]);
useEffect(() => { useEffect(() => {
if (IS_DEMO_MODE) return; if (IS_DEMO_MODE) return;
+3
View File
@@ -453,6 +453,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
if (!session?.accessToken) throw new Error('Unauthorized'); if (!session?.accessToken) throw new Error('Unauthorized');
const headers = new Headers(init.headers || {}); const headers = new Headers(init.headers || {});
headers.set('Authorization', `Bearer ${session.accessToken}`); headers.set('Authorization', `Bearer ${session.accessToken}`);
headers.set('X-NodeWarden-Web', '1');
let resp = await retryableRequest(headers); let resp = await retryableRequest(headers);
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp; 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) { if (latest?.accessToken && latest.accessToken !== session.accessToken) {
const latestHeaders = new Headers(init.headers || {}); const latestHeaders = new Headers(init.headers || {});
latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`); latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`);
latestHeaders.set('X-NodeWarden-Web', '1');
resp = await retryableRequest(latestHeaders); resp = await retryableRequest(latestHeaders);
if (resp.status !== 401) return resp; if (resp.status !== 401) return resp;
} }
@@ -486,6 +488,7 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
const retryHeaders = new Headers(init.headers || {}); const retryHeaders = new Headers(init.headers || {});
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`); retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
retryHeaders.set('X-NodeWarden-Web', '1');
resp = await retryableRequest(retryHeaders); resp = await retryableRequest(retryHeaders);
return resp; return resp;
}; };
+9 -1
View File
@@ -1,6 +1,6 @@
import type { Cipher, Folder, Send } from '../types'; import type { Cipher, Folder, Send } from '../types';
import { getVaultRevisionDate } from './auth'; 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'; import { parseJson, type AuthedFetch } from './shared';
interface VaultSyncResponse { interface VaultSyncResponse {
@@ -43,6 +43,14 @@ export async function getCachedVaultCoreSnapshot(cacheKey: string): Promise<Vaul
return snapshot; return snapshot;
} }
export async function invalidateVaultCoreSyncSnapshot(cacheKey: string): Promise<void> {
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<VaultCoreSnapshot> { export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise<VaultCoreSnapshot> {
const normalizedKey = String(cacheKey || '').trim(); const normalizedKey = String(cacheKey || '').trim();
if (!normalizedKey) return { ciphers: [], folders: [], sends: [] }; if (!normalizedKey) return { ciphers: [], folders: [], sends: [] };