fix(cipher): clear omitted notes on full update (#363)

Treat omitted nullable cipher fields as cleared during full updates so stale encrypted notes are not restored by merge fallback.

Fixes #362
This commit is contained in:
KiritoXDone
2026-08-30 01:57:41 +08:00
committed by GitHub
parent e63f9663e8
commit a72592e76e
2 changed files with 37 additions and 10 deletions
+32
View File
@@ -0,0 +1,32 @@
interface AliasedValue<T> {
present: boolean;
value: T | null | undefined;
}
function readOwnAliasedValue<T>(source: unknown, aliases: readonly string[]): AliasedValue<T> {
if (!source || typeof source !== 'object') {
return { present: false, value: undefined };
}
const record = source as Record<string, unknown>;
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<T>(
source: unknown,
aliases: readonly string[]
): T | null {
const incoming = readOwnAliasedValue<T>(source, aliases);
return incoming.present ? incoming.value ?? null : null;
}
+5 -10
View File
@@ -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<string>(cipherData, ['notes', 'Notes']);
cipher.fields = readNullableFullUpdateField<Cipher['fields']>(cipherData, ['fields', 'Fields']);
normalizeCipherForStorage(cipher);
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
if (compatibilityError) return errorResponse(compatibilityError, 400);