From 563570e3e09323b9063a9ae82e1b238eae966711 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 11 Jun 2026 15:02:55 +0800 Subject: [PATCH] feat: add compatibility validation for cipher fields during import and storage --- .gitignore | 6 +++ src/handlers/ciphers.ts | 97 +++++++++++++++++++++++++---------------- src/handlers/import.ts | 6 ++- 3 files changed, 71 insertions(+), 38 deletions(-) diff --git a/.gitignore b/.gitignore index b30e475..b83ab26 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,9 @@ NodeWarden-compat/ .codex-upstream/bitwarden-browser/ .reasonix/ + +# Compatibility analysis documents +BITWARDEN_COMPATIBILITY_ANALYSIS.md +.mcp.json +opencode.jsonc +.cursor/ diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 8ba428a..1563766 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -141,6 +141,12 @@ function optionalEncString(value: unknown): string | null { return isValidEncString(value) ? value.trim() : null; } +function optionalEncStringWithin(value: unknown, maxLength: number): string | null { + const normalized = optionalEncString(value); + if (!normalized) return null; + return normalized.length <= maxLength ? normalized : null; +} + function shouldAcceptCipherKey(value: unknown): boolean { return value == null || value === '' || isValidEncString(value); } @@ -151,13 +157,16 @@ function normalizeCipherKeyForStorage(value: unknown): string | null { function sanitizeEncryptedObject>( source: T | null | undefined, - encryptedKeys: readonly string[] + encryptedKeys: readonly string[] | Record ): T | null { if (!source || typeof source !== 'object') return source ?? null; const next: Record = { ...source }; - for (const key of encryptedKeys) { + const entries = Array.isArray(encryptedKeys) + ? encryptedKeys.map((key) => [key, 10000] as const) + : Object.entries(encryptedKeys); + for (const [key, maxLength] of entries) { if (!Object.prototype.hasOwnProperty.call(next, key)) continue; - next[key] = optionalEncString(next[key]); + next[key] = optionalEncStringWithin(next[key], maxLength); } return next as T; } @@ -188,7 +197,12 @@ export function normalizeCipherLoginForCompatibility( ): any { const normalized = normalizeCipherLoginForStorage(login); if (!normalized || typeof normalized !== 'object') return normalized ?? null; - const next = sanitizeEncryptedObject(normalized, ['username', 'password', 'totp', 'uri']); + const next = sanitizeEncryptedObject(normalized, { + username: 1000, + password: 5000, + totp: 1000, + uri: 10000, + }); if (!next) return null; next.uris = normalizeCipherLoginUrisForCompatibility(next.uris, { requiresUriChecksum, @@ -214,23 +228,19 @@ function normalizeCipherLoginUrisForCompatibility( const hasChecksum = isValidEncString(next.uriChecksum); const hasMatch = next.match != null; - if (hasUri && hasChecksum) { + if (hasUri && String(next.uri).trim().length > 10000) continue; + if (hasChecksum && String(next.uriChecksum).trim().length > 10000) { + next.uriChecksum = null; + } + + if (hasUri && isValidEncString(next.uriChecksum)) { out.push(next); continue; } if (hasUri && !hasChecksum) { - 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) continue; + // Official Bitwarden treats UriChecksum as nullable encrypted metadata. + // Keep the URI intact and let clients that can repair checksums do so. out.push({ ...next, uriChecksum: null }); continue; } @@ -243,14 +253,27 @@ function normalizeCipherLoginUrisForCompatibility( return out.length ? out : null; } -function hasMissingLoginUriChecksum(cipher: Cipher): boolean { - if (!cipher.key || !cipher.login || typeof cipher.login !== 'object') return false; - const uris = (cipher.login as any).uris; - if (!Array.isArray(uris)) return false; - return uris.some((uri: any) => { - if (!uri || typeof uri !== 'object') return false; - return isValidEncString(uri.uri) && !isValidEncString(uri.uriChecksum); - }); +export function validateCipherEncryptedFieldsForCompatibility(cipher: Cipher): string | null { + if (cipher.name != null && !optionalEncStringWithin(cipher.name, 1000)) return 'Cipher name must be an encrypted string up to 1000 characters.'; + if (cipher.notes != null && !optionalEncStringWithin(cipher.notes, 10000)) return 'Cipher notes must be an encrypted string up to 10000 characters.'; + + const login = cipher.login as any; + if (login && typeof login === 'object') { + if (login.username != null && !optionalEncStringWithin(login.username, 1000)) return 'Login username must be an encrypted string up to 1000 characters.'; + if (login.password != null && !optionalEncStringWithin(login.password, 5000)) return 'Login password must be an encrypted string up to 5000 characters.'; + if (login.totp != null && !optionalEncStringWithin(login.totp, 1000)) return 'Login TOTP must be an encrypted string up to 1000 characters.'; + if (login.uri != null && !optionalEncStringWithin(login.uri, 10000)) return 'Login URI must be an encrypted string up to 10000 characters.'; + + if (Array.isArray(login.uris)) { + for (const uri of login.uris) { + if (!uri || typeof uri !== 'object') continue; + if (uri.uri != null && !optionalEncStringWithin(uri.uri, 10000)) return 'Login URI must be an encrypted string up to 10000 characters.'; + if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.'; + } + } + } + + return null; } function normalizeFido2CredentialsForCompatibility(credentials: any): any[] | null { @@ -589,7 +612,14 @@ export function cipherToResponse( !!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: 1000, + brand: 1000, + number: 1000, + expMonth: 1000, + expYear: 1000, + code: 1000, + }); const normalizedIdentity = sanitizeEncryptedObject((passthrough as any).identity ?? null, [ 'title', 'firstName', @@ -647,6 +677,7 @@ export function cipherToResponse( passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory), sshKey: normalizedSshKey, key: responseCipherKey, + data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null, encryptedFor: (passthrough as any).encryptedFor ?? null, }; } @@ -772,6 +803,8 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str const createFields = getAliasedProp(cipherData, ['fields', 'Fields']); cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null); normalizeCipherForStorage(cipher); + const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher); + if (compatibilityError) return errorResponse(compatibilityError, 400); // Prevent referencing a folder owned by another user. if (cipher.folderId) { @@ -779,10 +812,6 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str if (!folderOk) return errorResponse('Folder not found', 404); } - if (hasMissingLoginUriChecksum(cipher)) { - return errorResponse('Login URI checksum is required for item-key encrypted ciphers. Refresh NodeWarden and save the item again.', 400); - } - await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); @@ -835,10 +864,6 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400); } - if (!shouldPreserveRepairableCipherUris(request) && incomingLogin.present && hasMissingLoginUriChecksum(existingCipher)) { - return errorResponse('This item has login URIs that must be repaired in NodeWarden Web before updating from this client. Open NodeWarden Web once, then resync.', 400); - } - const nextType = Number(cipherData.type) || existingCipher.type; // Opaque passthrough: merge existing stored data with ALL incoming client fields. @@ -887,6 +912,8 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str cipher.fields = null; } normalizeCipherForStorage(cipher); + const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher); + if (compatibilityError) return errorResponse(compatibilityError, 400); // Prevent referencing a folder owned by another user. if (cipher.folderId) { @@ -894,10 +921,6 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str if (!folderOk) return errorResponse('Folder not found', 404); } - if (hasMissingLoginUriChecksum(cipher)) { - return errorResponse('Login URI checksum is required for item-key encrypted ciphers. Refresh NodeWarden and save the item again.', 400); - } - await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData); await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); diff --git a/src/handlers/import.ts b/src/handlers/import.ts index 358d68e..c3cd88f 100644 --- a/src/handlers/import.ts +++ b/src/handlers/import.ts @@ -5,7 +5,7 @@ import { errorResponse, jsonResponse } from '../utils/response'; import { readActingDeviceIdentifier } from '../utils/device'; import { generateUUID } from '../utils/uuid'; import { LIMITS } from '../config/limits'; -import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers'; +import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility, validateCipherEncryptedFieldsForCompatibility } from './ciphers'; // Bitwarden client import request format interface CiphersImportRequest { @@ -252,6 +252,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st deletedAt: null, }; cipher.login = normalizeCipherLoginForStorage(cipher.login); + const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher); + if (compatibilityError) { + return errorResponse(`Cipher ${i + 1}: ${compatibilityError}`, 400); + } cipherRows.push(cipher); cipherMapRows.push({ index: i, sourceId, id: cipher.id });