Skip to content

Commit a72592e

Browse files
authored
fix(cipher): clear omitted notes on full update (shuaiplus#363)
Treat omitted nullable cipher fields as cleared during full updates so stale encrypted notes are not restored by merge fallback. Fixes shuaiplus#362
1 parent e63f966 commit a72592e

2 files changed

Lines changed: 37 additions & 10 deletions

File tree

src/handlers/cipher-full-update.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
interface AliasedValue<T> {
2+
present: boolean;
3+
value: T | null | undefined;
4+
}
5+
6+
function readOwnAliasedValue<T>(source: unknown, aliases: readonly string[]): AliasedValue<T> {
7+
if (!source || typeof source !== 'object') {
8+
return { present: false, value: undefined };
9+
}
10+
11+
const record = source as Record<string, unknown>;
12+
for (const alias of aliases) {
13+
if (Object.prototype.hasOwnProperty.call(record, alias)) {
14+
return { present: true, value: record[alias] as T | null | undefined };
15+
}
16+
}
17+
18+
return { present: false, value: undefined };
19+
}
20+
21+
/**
22+
* Full cipher updates use replacement semantics for nullable fields.
23+
* Bitwarden clients may omit a property after its value is cleared, so an
24+
* absent property must become null instead of falling back to stored data.
25+
*/
26+
export function readNullableFullUpdateField<T>(
27+
source: unknown,
28+
aliases: readonly string[]
29+
): T | null {
30+
const incoming = readOwnAliasedValue<T>(source, aliases);
31+
return incoming.present ? incoming.value ?? null : null;
32+
}

src/handlers/ciphers.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from '.
2727
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
2828
import { readActingDeviceIdentifier } from '../utils/device';
2929
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
30+
import { readNullableFullUpdateField } from './cipher-full-update';
3031

3132
// CONTRACT:
3233
// Cipher JSON is the highest-risk Bitwarden compatibility surface. Preserve
@@ -1100,16 +1101,10 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
11001101
cipher.passwordHistory = incomingPasswordHistory.value ?? null;
11011102
}
11021103

1103-
// Custom fields deletion compatibility:
1104-
// - Accept both camelCase "fields" and PascalCase "Fields".
1105-
// - For full update (PUT/POST on this endpoint), missing fields means cleared fields.
1106-
// This prevents stale custom fields from being resurrected by merge fallback.
1107-
const incomingFields = getAliasedProp(cipherData, ['fields', 'Fields']);
1108-
if (incomingFields.present) {
1109-
cipher.fields = incomingFields.value ?? null;
1110-
} else if (request.method === 'PUT' || request.method === 'POST') {
1111-
cipher.fields = null;
1112-
}
1104+
// Nullable fields use replacement semantics on this full-update endpoint.
1105+
// Some clients omit cleared values, so merge fallback must not resurrect them.
1106+
cipher.notes = readNullableFullUpdateField<string>(cipherData, ['notes', 'Notes']);
1107+
cipher.fields = readNullableFullUpdateField<Cipher['fields']>(cipherData, ['fields', 'Fields']);
11131108
normalizeCipherForStorage(cipher);
11141109
const compatibilityError = validateCipherEncryptedFieldsForCompatibility(cipher);
11151110
if (compatibilityError) return errorResponse(compatibilityError, 400);

0 commit comments

Comments
 (0)