diff --git a/src/handlers/cipher-full-update.ts b/src/handlers/cipher-full-update.ts new file mode 100644 index 0000000..08c29c3 --- /dev/null +++ b/src/handlers/cipher-full-update.ts @@ -0,0 +1,32 @@ +interface AliasedValue { + present: boolean; + value: T | null | undefined; +} + +function readOwnAliasedValue(source: unknown, aliases: readonly string[]): AliasedValue { + if (!source || typeof source !== 'object') { + return { present: false, value: undefined }; + } + + const record = source as Record; + for (const alias of aliases) { + if (Object.prototype.hasOwnProperty.call(record, alias)) { + return { present: true, value: record[alias] as T | null | undefined }; + } + } + + return { present: false, value: undefined }; +} + +/** + * Full cipher updates use replacement semantics for nullable fields. + * Bitwarden clients may omit a property after its value is cleared, so an + * absent property must become null instead of falling back to stored data. + */ +export function readNullableFullUpdateField( + source: unknown, + aliases: readonly string[] +): T | null { + const incoming = readOwnAliasedValue(source, aliases); + return incoming.present ? incoming.value ?? null : null; +} diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 4a01420..2298433 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -27,6 +27,7 @@ import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from '. import { parsePagination, encodeContinuationToken } from '../utils/pagination'; import { readActingDeviceIdentifier } from '../utils/device'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; +import { readNullableFullUpdateField } from './cipher-full-update'; // CONTRACT: // Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve @@ -1100,16 +1101,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str cipher.passwordHistory = incomingPasswordHistory.value ?? null; } - // Custom fields deletion compatibility: - // - Accept both camelCase "fields" and PascalCase "Fields". - // - For full update (PUT/POST on this endpoint), missing fields means cleared fields. - // This prevents stale custom fields from being resurrected by merge fallback. - const incomingFields = getAliasedProp(cipherData, ['fields', 'Fields']); - if (incomingFields.present) { - cipher.fields = incomingFields.value ?? null; - } else if (request.method === 'PUT' || request.method === 'POST') { - cipher.fields = null; - } + // Nullable fields use replacement semantics on this full-update endpoint. + // Some clients omit cleared values, so merge fallback must not resurrect them. + cipher.notes = readNullableFullUpdateField(cipherData, ['notes', 'Notes']); + cipher.fields = readNullableFullUpdateField(cipherData, ['fields', 'Fields']); normalizeCipherForStorage(cipher); const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher); if (compatibilityError) return errorResponse(compatibilityError, 400);