fix: preserve vault fields and align cipher compatibility

Preserve linked and unknown encrypted fields in Web edits, reject stale and concurrent saves, and support official bulk delete and restore methods while retaining legacy routes.

Add 20 offline compatibility regressions and update undici to 8.10.2. Verified 51 tests, TypeScript checks, locale validation, Web build and Worker deployment dry run. The unpatched braces advisory remains visible.
This commit is contained in:
shuaiplus committed 2026-10-05 14:25:06 +08:00
1 parent 5f71cb388c
commit 293f5ce196
26 files changed
+456 -25

No files matched your search

+3 -3
View File
@@ -4716,9 +4716,9 @@
}
},
"node_modules/undici": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
"version": "8.10.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz",
"integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==",
"dev": true,
"license": "MIT",
"engines": {
+2 -1
View File
@@ -16,6 +16,7 @@
"i18n": "node scripts/i18n-validate.cjs",
"i18n:validate": "node scripts/i18n-validate.cjs",
"test:config-compatibility": "tsx --test scripts/config-compatibility.test.ts",
"test:vault-compatibility": "tsx --tsconfig webapp/tsconfig.json --import ./scripts/offline-workers-loader.mjs --test scripts/vault-compatibility.test.ts",
"test:web-crypto": "tsx --test scripts/web-crypto-availability.test.ts",
"test:notifications-security": "tsx --test scripts/notifications-security.test.ts",
"test:webauthn-mobile": "node --test scripts/webauthn-mobile-connector.test.mjs",
@@ -50,7 +51,7 @@
},
"overrides": {
"nanoid": "3.3.18",
"undici": "8.9.0",
"undici": "8.10.2",
"@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1",
"ws": "8.21.0",
+15
View File
@@ -0,0 +1,15 @@
import { registerHooks } from 'node:module';
// Vite injects this build constant into modules shared with Web draft helpers.
globalThis.__NODEWARDEN_DEMO__ = false;
// Tests exercise routes and storage without running Cloudflare Durable Objects.
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === 'cloudflare:workers') {
const source = 'export class DurableObject {} export function waitUntil(p) { p.catch(() => {}); }';
return { url: 'data:text/javascript,' + encodeURIComponent(source), shortCircuit: true };
}
return nextResolve(specifier, context);
},
});
+320
View File
@@ -0,0 +1,320 @@
import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import { StorageService } from '../src/services/storage';
import { handleAuthenticatedRoute } from '../src/router-authenticated';
import { cipherToResponse } from '../src/handlers/ciphers';
import { bytesToBase64, encryptBw, decryptStr } from '../webapp/src/lib/crypto';
import { decryptSingleCipher } from '../webapp/src/lib/decrypt-cipher';
import { updateCipher, buildCipherImportPayload, repairCipherKeyMismatches } from '../webapp/src/lib/api/vault';
import { importCipherToDraft } from '../webapp/src/lib/app-support';
import { draftFromCipher } from '../webapp/src/components/vault/vault-page-helpers';
import { t as translate } from '../webapp/src/lib/i18n';
const owner = '00000000-0000-4000-8000-000000000001';
const otherOwner = '00000000-0000-4000-8000-000000000002';
const id = '00000000-0000-4000-8000-000000000003';
const otherId = '00000000-0000-4000-8000-000000000004';
const attachmentId = '00000000-0000-4000-8000-000000000005';
const revision = '2026-10-04T00:00:00.000Z';
const enc = new Uint8Array(32).fill(1);
const mac = new Uint8Array(32).fill(2);
const session: any = { symEncKey: bytesToBase64(enc), symMacKey: bytesToBase64(mac) };
const encrypt = (value: string) => encryptBw(new TextEncoder().encode(value), enc, mac);
async function fixture(ctx: TestContext) {
// Execute production SQL in a fresh in-memory SQLite database. Only the D1
// result wrapper and unrelated audit/notification integrations are substituted.
const sqlite = new DatabaseSync(':memory:');
ctx.after(async () => { await new Promise((resolve) => setImmediate(resolve)); sqlite.close(); });
sqlite.exec(`
CREATE TABLE ciphers(id TEXT PRIMARY KEY, user_id TEXT, type INTEGER,
folder_id TEXT, name TEXT, notes TEXT, favorite INTEGER, data TEXT,
reprompt INTEGER, key TEXT, created_at TEXT, updated_at TEXT,
archived_at TEXT, deleted_at TEXT);
CREATE TABLE attachments(id TEXT PRIMARY KEY, cipher_id TEXT,
file_name TEXT, size INTEGER, size_name TEXT, key TEXT);
CREATE TABLE devices(user_id TEXT, push_token TEXT);
`);
const db: any = {
prepare(sql: string) {
let values: any[] = [];
const statement = {
bind(...bound: any[]) { values = bound; return statement; },
async run() { const result = sqlite.prepare(sql).run(...values); return { success: true, meta: { changes: Number(result.changes) } }; },
async all() { return { success: true, results: sqlite.prepare(sql).all(...values) }; },
async first() { return sqlite.prepare(sql).get(...values) ?? null; },
};
return statement;
},
};
ctx.mock.method(StorageService.prototype, 'updateRevisionDate', async () => revision);
ctx.mock.method(StorageService.prototype, 'createAuditLog', async () => {});
ctx.mock.method(StorageService.prototype, 'getConfigValue', async () => null);
ctx.mock.method(StorageService.prototype, 'pruneAuditLogs', async () => {});
const deletedBlobs: string[] = [];
const notifications: any[] = [];
const env: any = {
DB: db,
ATTACHMENTS: { delete: async (key: string) => { deletedBlobs.push(key); } },
NOTIFICATIONS_HUB: {
idFromName: (name: string) => name,
get: () => ({ fetch: async (_url: string, init: RequestInit) => { notifications.push(JSON.parse(init.body as string)); return new Response(null, { status: 204 }); } }),
},
};
const storage = new StorageService(db);
const base: any = {
id, userId: owner, type: 1, name: await encrypt('Initial title'),
notes: await encrypt('Notes'), favorite: false, folderId: null, key: null,
login: { username: await encrypt('Username'), password: await encrypt('Password') },
createdAt: revision, updatedAt: revision, deletedAt: null,
};
await storage.saveCipher(base);
await storage.saveCipher({ ...base, id: otherId, userId: otherOwner });
const route = async (path: string, method: string, body?: unknown, headers: Record<string, string> = {}) => {
const request = new Request(`https://vault.example.test${path}`, {
method, headers: { 'Content-Type': 'application/json', ...headers },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const response = await handleAuthenticatedRoute(request, env, owner, { id: owner } as any, path, method);
assert.ok(response, `${method} ${path} must be handled`);
return response;
};
const attachment = { id: attachmentId, cipherId: id, fileName: await encrypt('file.txt'), key: null, size: 7, sizeName: '7 B' };
return { storage, base, env, sqlite, route, deletedBlobs, notifications, attachment };
}
for (const method of ['PUT', 'POST']) {
test(`${method} bulk soft delete and restore retain ownership and return restored items`, async (ctx) => {
const f = await fixture(ctx);
await f.storage.saveAttachment(f.attachment);
const ids = [id, otherId, id, '00000000-0000-4000-8000-000000000099'];
assert.equal((await f.route('/api/ciphers/delete', method, { ids })).status, 204);
assert.ok((await f.storage.getCipherForUser(id, owner))?.deletedAt);
assert.equal((await f.storage.getCipherForUser(otherId, otherOwner))?.deletedAt, null);
const response = await f.route('/api/ciphers/restore', method, { ids });
assert.equal(response.status, 200);
const body: any = await response.json();
assert.equal(body.object, 'list');
assert.equal(body.continuationToken, null);
assert.equal(body.data.length, 1);
assert.equal(body.data[0].id, id);
assert.equal(body.data[0].deletedDate, null);
assert.equal(body.data[0].attachments[0].id, attachmentId);
assert.deepEqual(f.deletedBlobs, []);
const empty: any = await (await f.route('/api/ciphers/restore', method, { ids: [] })).json();
assert.deepEqual(empty.data, []);
});
}
for (const [path, method] of [['/api/ciphers', 'DELETE'], ['/api/ciphers/delete-permanent', 'POST']]) {
test(`${method} ${path} permanently deletes only owned items and their attachments`, async (ctx) => {
const f = await fixture(ctx);
await f.storage.saveAttachment(f.attachment);
await f.storage.saveAttachment({ ...f.attachment, id: otherId, cipherId: otherId });
assert.equal((await f.route(path, method, { ids: [id, otherId, id] })).status, 204);
assert.equal(await f.storage.getCipherForUser(id, owner), null);
assert.ok(await f.storage.getCipherForUser(otherId, otherOwner));
assert.equal(await f.storage.getAttachment(attachmentId), null);
assert.ok(await f.storage.getAttachment(otherId));
assert.deepEqual(f.deletedBlobs, [`${id}/${attachmentId}`]);
});
}
test('single POST delete alias is permanent; existing root DELETE still moves an active item to trash', async (ctx) => {
const f = await fixture(ctx);
assert.equal((await f.route(`/api/ciphers/${id}`, 'DELETE')).status, 200);
assert.ok((await f.storage.getCipherForUser(id, owner))?.deletedAt);
assert.equal((await f.route(`/api/ciphers/${id}/delete`, 'POST')).status, 204);
assert.equal(await f.storage.getCipherForUser(id, owner), null);
assert.equal((await f.route(`/api/ciphers/${otherId}/delete`, 'POST')).status, 404);
});
test('invalid bulk requests are rejected without changing vault items', async (ctx) => {
const f = await fixture(ctx);
for (const [path, method] of [['/api/ciphers', 'DELETE'], ['/api/ciphers/delete', 'PUT'], ['/api/ciphers/restore', 'PUT']]) {
assert.equal((await f.route(path, method, {})).status, 400);
assert.equal((await f.route(path, method, { ids: 'invalid' })).status, 400);
}
assert.deepEqual(await f.storage.getCipherForUser(id, owner), await f.storage.getCipher(id));
assert.equal((await f.storage.getCipherForUser(id, owner))?.deletedAt, null);
});
test('stale updates less than a second apart cannot bypass checks with attachment metadata', async (ctx) => {
const f = await fixture(ctx);
await f.storage.saveAttachment(f.attachment);
await f.storage.saveCipher({ ...f.base, updatedAt: '2026-10-04T00:00:00.001Z' });
const response = await f.route(`/api/ciphers/${id}`, 'PUT', {
name: await encrypt('Stale title'), lastKnownRevisionDate: revision,
attachments2: { [attachmentId]: { fileName: await encrypt('Stale file') } },
});
assert.equal(response.status, 400);
assert.equal((await f.storage.getCipherForUser(id, owner))?.name, f.base.name);
assert.equal((await f.storage.getAttachment(attachmentId))?.fileName, f.attachment.fileName);
assert.equal(f.notifications.length, 0);
});
test('a write conflict detected after reading leaves attachment metadata untouched', async (ctx) => {
const f = await fixture(ctx);
await f.storage.saveAttachment(f.attachment);
const update = StorageService.prototype.updateCipherIfUnchanged;
ctx.mock.method(StorageService.prototype, 'updateCipherIfUnchanged', async function (this: StorageService, cipher: any, expected: string) {
await f.storage.saveCipher({ ...f.base, updatedAt: '2026-10-04T00:00:01.000Z' });
return update.call(this, cipher, expected);
});
assert.equal((await f.route(`/api/ciphers/${id}`, 'PUT', {
name: await encrypt('Stale title'), lastKnownRevisionDate: revision,
attachments2: { [attachmentId]: { fileName: await encrypt('Stale file') } },
})).status, 400);
assert.equal((await f.storage.getAttachment(attachmentId))?.fileName, f.attachment.fileName);
assert.equal(f.notifications.length, 0);
});
test('concurrent saves from the same revision accept only one, even in the same millisecond', async (ctx) => {
const f = await fixture(ctx);
ctx.mock.method(Date, 'now', () => Date.parse(revision));
const response = await Promise.all(['First title', 'Second title'].map(async (title) =>
f.route(`/api/ciphers/${id}`, 'PUT', { name: await encrypt(title), lastKnownRevisionDate: revision })
));
assert.deepEqual(response.map((r) => r.status).sort(), [200, 400]);
assert.equal((await f.storage.getCipherForUser(id, owner))?.updatedAt, '2026-10-04T00:00:00.001Z');
});
test('a competing deletion cannot be resurrected by a delayed update', async (ctx) => {
const f = await fixture(ctx);
const get = StorageService.prototype.getCipherForUser;
ctx.mock.method(StorageService.prototype, 'getCipherForUser', async function (this: StorageService, ...args: [string, string]) {
const result = await get.apply(this, args);
await f.storage.deleteCipher(id, owner);
return result;
});
assert.equal((await f.route(`/api/ciphers/${id}`, 'PUT', { name: await encrypt('Delayed title') })).status, 400);
assert.equal(await f.storage.getCipher(id), null);
});
test('normal legacy saves without a revision still work and nullable fields can be cleared', async (ctx) => {
const f = await fixture(ctx);
assert.equal((await f.route(`/api/ciphers/${id}`, 'PUT', { name: await encrypt('Legacy title'), notes: null, fields: [] })).status, 200);
const saved = await f.storage.getCipherForUser(id, owner);
assert.equal(saved?.notes, null);
assert.deepEqual(saved?.fields, []);
});
test('storage conditional updates cannot change another owner or recreate missing items', async (ctx) => {
const f = await fixture(ctx);
assert.equal(await f.storage.updateCipherIfUnchanged({ ...f.base, userId: otherOwner }, revision), false);
await f.storage.deleteCipher(id, owner);
assert.equal(await f.storage.updateCipherIfUnchanged(f.base, revision), false);
assert.equal(await f.storage.getCipher(id), null);
});
test('Web editing preserves linked IDs and field metadata through reorder, removal, encryption, storage and response', async (ctx) => {
const f = await fixture(ctx);
const extra = await encrypt('Future encrypted field');
const cipher = {
...f.base,
fields: [
{ type: 3, name: await encrypt('Linked username'), value: null, linkedId: 100, futureValue: extra },
{ type: 0, name: await encrypt('Remove me'), value: await encrypt('Remove value') },
{ type: 1, name: await encrypt('Keep me'), value: await encrypt('Secret'), futureValue: extra },
],
};
await f.storage.saveCipher(cipher);
const decrypted = await decryptSingleCipher(cipherToResponse(cipher) as any, enc, mac);
const draft = draftFromCipher(decrypted);
draft.customFields = [draft.customFields[2], draft.customFields[0]];
draft.name = 'Edited title';
const updated = await updateCipher((path, init) => f.route(path, init!.method!, JSON.parse(init!.body as string)), session, decrypted, draft);
assert.equal(updated.fields?.length, 2);
assert.equal('lastKnownRevisionDate' in updated, false);
assert.equal(updated.fields?.[1].linkedId, 100);
for (const field of updated.fields || []) {
assert.equal((field as any).futureValue, extra);
assert.equal('decName' in field, false);
assert.equal('decValue' in field, false);
}
const roundtrip = await decryptSingleCipher(updated, enc, mac);
assert.equal(roundtrip.decName, 'Edited title');
assert.equal(roundtrip.fields?.[0].decValue, 'Secret');
assert.equal((await f.storage.getCipherForUser(id, owner))?.fields?.[1].linkedId, 100);
});
for (const [type, property] of [[3, 'card'], [4, 'identity'], [2, 'secureNote']] as const) {
test(`Web editing ${property} keeps unknown encrypted values without sending decoded properties`, async (ctx) => {
const f = await fixture(ctx);
const extra = await encrypt('Future secret');
const source = { ...f.base, type, login: null, [property]: { futureEncrypted: extra, decFutureEncrypted: 'Decoded secret', type: 0 } };
await f.storage.saveCipher(source);
const decrypted = await decryptSingleCipher(cipherToResponse(source) as any, enc, mac);
const draft = draftFromCipher(decrypted);
draft.name = 'Changed';
let payload: any;
const updated = await updateCipher((path, init) => { payload = JSON.parse(init!.body as string); return f.route(path, init!.method!, payload); }, session, decrypted, draft);
assert.equal(payload[property].futureEncrypted, extra);
assert.equal('decFutureEncrypted' in payload[property], false);
assert.equal((updated as any)[property].futureEncrypted, extra);
});
}
test('Web sends the revision captured at edit start and exposes a localized conflict while preserving the draft', async (ctx) => {
const f = await fixture(ctx);
const initial = await decryptSingleCipher(cipherToResponse(f.base) as any, enc, mac);
const draft = draftFromCipher(initial);
draft.name = 'Unsaved edit';
const newer = { ...f.base, name: await encrypt('New remote title'), updatedAt: '2026-10-04T00:00:00.500Z' };
await f.storage.saveCipher(newer);
const synced = await decryptSingleCipher(cipherToResponse(newer) as any, enc, mac);
let submitted: any;
await assert.rejects(updateCipher((path, init) => {
submitted = JSON.parse(init!.body as string);
return f.route(path, init!.method!, submitted);
}, session, synced, draft), (error: any) => {
assert.equal(error.status, 400);
assert.equal(error.message, translate('txt_item_changed_elsewhere'));
return true;
});
assert.equal(submitted.lastKnownRevisionDate, revision);
assert.equal(draft.name, 'Unsaved edit');
assert.equal((await f.storage.getCipherForUser(id, owner))?.name, newer.name);
});
test('plaintext import keeps linked field IDs attached to the encrypted field', async () => {
const draft = importCipherToDraft({ type: 1, name: 'Imported', fields: [{ type: 3, name: 'Linked', value: null, linkedId: 100 }] }, '');
assert.equal(draft.customFields[0].linkedId, 100);
const payload: any = await buildCipherImportPayload(session, draft);
assert.equal(payload.fields[0].linkedId, 100);
assert.equal(await decryptStr(payload.fields[0].name, enc, mac), 'Linked');
assert.equal('lastKnownRevisionDate' in payload, false);
});
test('a draft cannot be saved into a different item after the selection changes', async (ctx) => {
const f = await fixture(ctx);
const cipher = await decryptSingleCipher(cipherToResponse(f.base) as any, enc, mac);
const draft = draftFromCipher(cipher);
let requested = false;
await assert.rejects(updateCipher(async () => { requested = true; return new Response(); }, session, { ...cipher, id: otherId }, draft));
assert.equal(requested, false);
});
test('automatic item-key repair preserves linked metadata and the intentional revision-date behavior', async (ctx) => {
const f = await fixture(ctx);
const itemEnc = new Uint8Array(32).fill(3);
const itemMac = new Uint8Array(32).fill(4);
const itemKey = new Uint8Array([...itemEnc, ...itemMac]);
const extra = await encryptBw(new TextEncoder().encode('Future secret'), itemEnc, itemMac);
const mixed = { ...f.base, key: await encryptBw(itemKey, enc, mac), fields: [
{ type: 3, name: await encrypt('Linked username'), value: null, linkedId: 100, futureEncrypted: extra },
] };
await f.storage.saveCipher(mixed);
const decrypted = await decryptSingleCipher(cipherToResponse(mixed) as any, enc, mac);
const repaired = await repairCipherKeyMismatches((path, init) =>
f.route(path, init!.method!, JSON.parse(init!.body as string), init!.headers as Record<string, string>), session, [decrypted]);
assert.equal(repaired, 1);
const saved = (await f.storage.getCipherForUser(id, owner))!;
assert.equal(saved.updatedAt, revision);
assert.equal(saved.fields?.[0].linkedId, 100);
assert.equal((saved.fields?.[0] as any).futureEncrypted, extra);
assert.equal(await decryptStr(saved.name, itemEnc, itemMac), 'Initial title');
assert.equal(await decryptStr(saved.fields![0].name!, itemEnc, itemMac), 'Linked username');
assert.equal('lastKnownRevisionDate' in saved, false);
});
+17 -12
View File
@@ -174,7 +174,7 @@ function isStaleCipherUpdate(existingUpdatedAt: string, clientRevisionDate: stri
const existingTs = Date.parse(existingUpdatedAt);
const clientTs = Date.parse(clientRevisionDate);
if (Number.isNaN(existingTs) || Number.isNaN(clientTs)) return false;
return existingTs - clientTs > 1000;
return existingTs > clientTs;
}
function syncCipherComputedAliases(cipher: Cipher): Cipher {
@@ -522,6 +522,7 @@ function normalizeCipherSecureNoteForCompatibility(secureNote: any): CipherSecur
if (!secureNote || typeof secureNote !== 'object') return null;
const type = Number(secureNote?.type ?? secureNote?.Type ?? 0);
return {
...secureNote,
type: Number.isFinite(type) ? type : 0,
};
}
@@ -655,10 +656,6 @@ function readIncomingAttachmentMetadata(source: any): IncomingAttachmentMetadata
return [...merged.values()];
}
function hasIncomingAttachmentMetadata(source: any): boolean {
return readIncomingAttachmentMetadata(source).length > 0;
}
async function syncIncomingAttachmentMetadata(
storage: StorageService,
cipherId: string,
@@ -1048,7 +1045,6 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
const incomingPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const incomingRevisionDate = readCipherRevisionDate(cipherData);
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
const preserveRevisionDate =
shouldPreserveRepairableCipherUris(request)
&& (body.preserveRevisionDate === true || cipherData.preserveRevisionDate === true);
@@ -1057,7 +1053,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
return errorResponse('Cipher key encryption is not supported by this server. Resync the client and try again.', 400);
}
if (!hasAttachmentMigrationMetadata && isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
if (isStaleCipherUpdate(existingCipher.updatedAt, incomingRevisionDate)) {
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
}
@@ -1065,7 +1061,13 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// Opaque passthrough: merge existing stored data with ALL incoming client fields.
// Unknown/future fields from the client are preserved; server-controlled fields are protected.
const { preserveRevisionDate: _preserveRevisionDate, PreserveRevisionDate: _pascalPreserveRevisionDate, ...cipherDataWithoutFlags } = cipherData;
const {
preserveRevisionDate: _preserveRevisionDate,
PreserveRevisionDate: _pascalPreserveRevisionDate,
lastKnownRevisionDate: _lastKnownRevisionDate,
LastKnownRevisionDate: _pascalLastKnownRevisionDate,
...cipherDataWithoutFlags
} = cipherData;
const cipher: Cipher = {
...existingCipher, // start with all existing stored data (including unknowns)
...cipherDataWithoutFlags, // overlay all client data (including new/unknown fields)
@@ -1076,7 +1078,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
favorite: cipherData.favorite ?? existingCipher.favorite,
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
createdAt: existingCipher.createdAt,
updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date().toISOString(),
updatedAt: preserveRevisionDate ? existingCipher.updatedAt : new Date(Math.max(Date.now(), Date.parse(existingCipher.updatedAt) + 1)).toISOString(),
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
deletedAt: existingCipher.deletedAt,
};
@@ -1115,8 +1117,11 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
if (!folderOk) return errorResponse('Folder not found', 404);
}
if (!(await storage.updateCipherIfUnchanged(cipher, existingCipher.updatedAt))) {
return errorResponse('The client copy of this cipher is out of date. Resync the client and try again.', 400);
}
// Rejected updates must not modify attachment metadata either.
await syncIncomingAttachmentMetadata(storage, cipher.id, cipherData);
await storage.saveCipher(cipher);
const revisionDate = await storage.updateRevisionDate(userId);
notifyVaultSyncForRequest(request, env, userId, revisionDate);
notifyCipherUpdateForRequest(request, env, cipher, revisionDate);
@@ -1451,7 +1456,7 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId
return new Response(null, { status: 204 });
}
// POST /api/ciphers/restore - Bulk restore
// PUT /api/ciphers/restore (POST retained for older NodeWarden clients)
export async function handleBulkRestoreCiphers(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
@@ -1472,7 +1477,7 @@ export async function handleBulkRestoreCiphers(request: Request, env: Env, userI
notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
}
return new Response(null, { status: 204 });
return buildCipherListResponse(request, storage, userId, body.ids);
}
// POST /api/ciphers/delete-permanent - Bulk permanent delete
+4 -3
View File
@@ -290,6 +290,7 @@ export async function handleAuthenticatedRoute(
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
if (method === 'GET') return handleGetCiphers(request, env, userId);
if (method === 'POST') return handleCreateCipher(request, env, userId);
if (path === '/api/ciphers' && method === 'DELETE') return handleBulkPermanentDeleteCiphers(request, env, userId);
return null;
}
@@ -297,7 +298,7 @@ export async function handleAuthenticatedRoute(
return handleCiphersImport(request, env, userId);
}
if (path === '/api/ciphers/delete' && method === 'POST') {
if (path === '/api/ciphers/delete' && (method === 'PUT' || method === 'POST')) {
return handleBulkDeleteCiphers(request, env, userId);
}
@@ -305,7 +306,7 @@ export async function handleAuthenticatedRoute(
return handleBulkPermanentDeleteCiphers(request, env, userId);
}
if (path === '/api/ciphers/restore' && method === 'POST') {
if (path === '/api/ciphers/restore' && (method === 'PUT' || method === 'POST')) {
return handleBulkRestoreCiphers(request, env, userId);
}
@@ -333,7 +334,7 @@ export async function handleAuthenticatedRoute(
}
if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId);
if (subPath === '/delete' && method === 'DELETE') return handlePermanentDeleteCipher(request, env, userId, cipherId);
if (subPath === '/delete' && (method === 'DELETE' || method === 'POST')) return handlePermanentDeleteCipher(request, env, userId, cipherId);
if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId);
if (subPath === '/archive' && (method === 'PUT' || method === 'POST')) return handleArchiveCipher(request, env, userId, cipherId);
if (subPath === '/unarchive' && (method === 'PUT' || method === 'POST')) return handleUnarchiveCipher(request, env, userId, cipherId);
+23
View File
@@ -49,6 +49,8 @@ const CIPHER_SCALAR_DATA_KEYS = new Set([
'updatedAt',
'updated_at',
'revisionDate',
'lastKnownRevisionDate',
'LastKnownRevisionDate',
'archivedAt',
'archived_at',
'archivedDate',
@@ -144,6 +146,27 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
).run();
}
// A single conditional UPDATE prevents a competing save or deletion between
// the handler's read and write from being overwritten (or resurrected).
export async function updateCipherIfUnchanged(
db: D1Database,
safeBind: SafeBind,
cipher: Cipher,
expectedUpdatedAt: string
): Promise<boolean> {
const folderId = normalizeOptionalId(cipher.folderId);
const result = await safeBind(
db.prepare(
'UPDATE ciphers SET type=?, folder_id=?, name=?, notes=?, favorite=?, data=?, reprompt=?, key=?, updated_at=?, archived_at=?, deleted_at=? ' +
'WHERE id=? AND user_id=? AND updated_at=?'
),
Number(cipher.type) || 1, folderId, cipher.name, cipher.notes, cipher.favorite ? 1 : 0,
buildCipherData(cipher, folderId), cipher.reprompt ?? 0, cipher.key, cipher.updatedAt,
cipher.archivedAt ?? null, cipher.deletedAt, cipher.id, cipher.userId, expectedUpdatedAt
).run();
return result.meta.changes === 1;
}
function sanitizeIds(ids: string[]): string[] {
return Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
}
+5
View File
@@ -59,6 +59,7 @@ import {
getCiphersByIds as listStoredCiphersByIds,
getCiphersPage as listStoredCiphersPage,
saveCipher as saveStoredCipher,
updateCipherIfUnchanged as updateStoredCipherIfUnchanged,
deleteCipher as deleteStoredCipher,
} from './storage-cipher-repo';
import {
@@ -496,6 +497,10 @@ export class StorageService {
await saveStoredCipher(this.db, this.safeBind.bind(this), cipher);
}
async updateCipherIfUnchanged(cipher: Cipher, expectedUpdatedAt: string): Promise<boolean> {
return updateStoredCipherIfUnchanged(this.db, this.safeBind.bind(this), cipher, expectedUpdatedAt);
}
async deleteCipher(id: string, userId: string): Promise<void> {
await deleteStoredCipher(this.db, id, userId);
}
+9 -2
View File
@@ -833,13 +833,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
setLocalError(t('txt_item_name_is_required'));
return;
}
// Sync may change the current selection while a draft is open. Save only
// the item that this draft belongs to, and retain it if the item disappeared.
const editedCipher = isCreating ? null : cipherById.get(nextDraft.id || selectedCipherId);
if (!isCreating && !editedCipher) {
setLocalError(t('txt_item_changed_elsewhere'));
return;
}
setBusy(true);
try {
if (isCreating) {
await props.onCreate(nextDraft, attachmentQueue);
} else if (selectedCipher) {
} else if (editedCipher) {
const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]);
await props.onUpdate(selectedCipher, nextDraft, {
await props.onUpdate(editedCipher, nextDraft, {
addFiles: attachmentQueue,
removeAttachmentIds,
});
@@ -1,4 +1,5 @@
import { useMemo } from 'preact/hooks';
import { cipherFieldMetadata } from '@/lib/cipher-fields';
import {
BookUser,
CreditCard,
@@ -558,6 +559,7 @@ export function createEmptyDraft(type: number): VaultDraft {
export function draftFromCipher(cipher: Cipher): VaultDraft {
const draft = createEmptyDraft(Number(cipher.type || 1));
draft.id = cipher.id;
draft.revisionDate = cipher.revisionDate;
draft.favorite = !!cipher.favorite;
draft.name = cipher.decName || '';
draft.folderId = cipher.folderId || '';
@@ -655,6 +657,7 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
draft.passportExpirationDate = cipher.passport.decExpirationDate || '';
}
draft.customFields = (cipher.fields || []).map((field) => ({
...cipherFieldMetadata(field),
type: parseFieldType(field.type),
label: field.decName || '',
value: field.decValue || '',
+7
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'preact/hooks';
import { cipherFieldMetadata } from '@/lib/cipher-fields';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
import {
@@ -221,7 +222,9 @@ function optimisticCipherFromDraft(draft: VaultDraft, current?: Cipher | null):
}
next.fields = draft.customFields.map((field) => ({
...cipherFieldMetadata(field.extra || {}).extra,
type: field.type,
...(field.type === 3 ? { linkedId: field.linkedId ?? null } : {}),
name: field.label,
value: field.value,
decName: field.label,
@@ -608,6 +611,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
onNotify('success', t('txt_item_updated'));
} catch (error) {
patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false });
if (error instanceof Error && error.message === t('txt_item_changed_elsewhere')) {
// Fetch the current item for reopening; the editor keeps the draft.
await refetchCiphers().catch(() => {});
}
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
throw error;
} finally {
+18 -4
View File
@@ -19,6 +19,8 @@ import {
} from './shared';
import { readResponseBytesWithProgress } from '../download';
import { loadVaultCoreSyncSnapshot } from './vault-sync';
import { cipherFieldMetadata } from '../cipher-fields';
import { t } from '../i18n';
type CipherLoginData = NonNullable<Cipher['login']>;
const NODEWARDEN_WEB_REPAIR_HEADER = 'X-NodeWarden-Web';
@@ -573,6 +575,8 @@ function plainCipherValue(decrypted: unknown, raw: unknown = ''): string {
function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
const type = Number(cipher.type || 1) || 1;
const draft: VaultDraft = {
id: cipher.id,
revisionDate: cipher.revisionDate,
type,
name: plainCipherValue(cipher.decName, cipher.name).trim() || 'Untitled',
notes: plainCipherValue(cipher.decNotes, cipher.notes),
@@ -650,6 +654,7 @@ function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
draft.customFields = (cipher.fields || [])
.map((field) => ({
...cipherFieldMetadata(field),
type: parseFieldType(field.type ?? 0),
label: plainCipherValue(field.decName, field.name).trim(),
value: plainCipherValue(field.decValue, field.value),
@@ -797,15 +802,17 @@ async function encryptCustomFields(
fields: VaultDraftField[],
enc: Uint8Array,
mac: Uint8Array
): Promise<Array<{ type: number; name: string | null; value: string | null }>> {
const out: Array<{ type: number; name: string | null; value: string | null }> = [];
): Promise<Array<Record<string, unknown>>> {
const out: Array<Record<string, unknown>> = [];
for (const field of fields || []) {
const label = String(field.label || '').trim();
if (!label) continue;
out.push({
...cipherFieldMetadata(field.extra || {}).extra,
type: parseFieldType(field.type),
name: await encryptTextValue(label, enc, mac),
value: await encryptTextValue(String(field.value || ''), enc, mac),
...(parseFieldType(field.type) === 3 ? { linkedId: field.linkedId ?? null } : {}),
});
}
return out;
@@ -1300,6 +1307,8 @@ async function buildCipherPayload(
if (cipher?.id) {
payload.id = cipher.id;
payload.key = keys.key;
// Use the version captured when editing began, even if sync refreshed cipher.
payload.lastKnownRevisionDate = draft.revisionDate ?? cipher.revisionDate;
}
if (type === 1) {
@@ -1327,6 +1336,7 @@ async function buildCipherPayload(
payload.passwordHistory = await buildUpdatedPasswordHistory(cipher, draft, keys.enc, keys.mac);
} else if (type === 3) {
payload.card = {
...stripDecodedObjectFields(cipher?.card),
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
number: await encryptTextValue(draft.cardNumber, keys.enc, keys.mac),
brand: await encryptTextValue(draft.cardBrand, keys.enc, keys.mac),
@@ -1336,6 +1346,7 @@ async function buildCipherPayload(
};
} else if (type === 4) {
payload.identity = {
...stripDecodedObjectFields(cipher?.identity),
title: await encryptTextValue(draft.identTitle, keys.enc, keys.mac),
firstName: await encryptTextValue(draft.identFirstName, keys.enc, keys.mac),
middleName: await encryptTextValue(draft.identMiddleName, keys.enc, keys.mac),
@@ -1426,7 +1437,7 @@ async function buildCipherPayload(
keys.mac
);
} else if (type === 2) {
payload.secureNote = { type: 0 };
payload.secureNote = { ...stripDecodedObjectFields(cipher?.secureNote), type: 0 };
}
return payload;
@@ -1462,6 +1473,9 @@ export async function updateCipher(
extraPayload?: Record<string, unknown>,
options?: { webRepair?: boolean }
): Promise<Cipher> {
if (draft.id && draft.id !== cipher.id) {
throw createApiError(t('txt_item_changed_elsewhere'), 400);
}
const payload = await buildCipherPayload(session, draft, cipher);
if (extraPayload) {
Object.assign(payload, extraPayload);
@@ -1475,7 +1489,7 @@ export async function updateCipher(
},
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error('Update item failed');
if (!resp.ok) throw createApiError(await parseErrorMessage(resp, t('txt_update_item_failed')), resp.status);
return (await parseJson<Cipher>(resp))!;
}
+3
View File
@@ -1,4 +1,5 @@
import { hkdf } from '@/lib/crypto';
import { cipherFieldMetadata } from './cipher-fields';
import { t } from '@/lib/i18n';
import type { VaultDraft } from '@/lib/types';
import type { ImportResultSummary } from '@/components/ImportPage';
@@ -206,6 +207,8 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
const fieldType = parsedType === 1 || parsedType === 2 || parsedType === 3 ? (parsedType as 1 | 2 | 3) : 0;
return {
type: fieldType,
// Plaintext import cannot safely pass through unknown encrypted values.
...cipherFieldMetadata({ linkedId: field.linkedId }),
label,
value: asText(field.value),
};
+13
View File
@@ -0,0 +1,13 @@
import type { VaultDraftField } from './types';
// Keep metadata on each draft field so removing or reordering fields cannot
// accidentally attach another field's link or unknown encrypted properties.
export function cipherFieldMetadata(field: object): Pick<VaultDraftField, 'linkedId' | 'extra'> {
const raw = field as Record<string, unknown>;
return {
...(typeof raw.linkedId === 'number' || raw.linkedId === null ? { linkedId: raw.linkedId } : {}),
extra: Object.fromEntries(Object.entries(raw).filter(([key]) =>
!['type', 'name', 'value', 'linkedId'].includes(key) && !/^dec[A-Z]/.test(key)
)),
};
}
+1
View File
@@ -163,6 +163,7 @@ export function translateServerError(message: string | null | undefined, fallbac
const key = {
'Account is disabled': 'txt_server_error_account_disabled',
'The client copy of this cipher is out of date. Resync the client and try again.': 'txt_item_changed_elsewhere',
'Another backup or restore run is already in progress': 'txt_backup_error_another_backup_or_restore_running',
'Another backup run is already in progress': 'txt_backup_error_another_backup_running',
'Backup archive upload failed': 'txt_backup_error_archive_upload_failed',
+1
View File
@@ -1087,6 +1087,7 @@ const de: Record<string, string> = {
"txt_remove_device_failed": "Fehler beim Entfernen des Geräts",
"txt_remove_all_devices_failed": "Fehler beim Entfernen aller Geräte",
"txt_update_item_failed": "Fehler beim Aktualisieren des Eintrags",
"txt_item_changed_elsewhere": "Dieser Eintrag wurde in einem anderen Client geändert. Deine Änderungen stehen noch im Formular. Kopiere sie, öffne den Eintrag erneut und speichere ihn.",
"txt_update_send_failed": "Fehler beim Aktualisieren der Sendung",
"txt_update_user_status_failed": "Fehler beim Aktualisieren des Benutzerstatus",
"txt_use_recovery_code": "Wiederherstellungscode verwenden",
+1
View File
@@ -1140,6 +1140,7 @@ const en: Record<string, string> = {
"txt_remove_device_failed": "Failed to remove device",
"txt_remove_all_devices_failed": "Failed to remove all devices",
"txt_update_item_failed": "Update item failed",
"txt_item_changed_elsewhere": "This item was changed in another client. Your edits are still in the form. Copy the changes you need, reopen the item, and save again.",
"txt_update_send_failed": "Update send failed",
"txt_update_user_status_failed": "Failed to update user status",
"txt_use_recovery_code": "Use Recovery Code",
+1
View File
@@ -1087,6 +1087,7 @@ const es: Record<string, string> = {
"txt_remove_device_failed": "Error al quitar dispositivo",
"txt_remove_all_devices_failed": "Error al quitar todos los dispositivos",
"txt_update_item_failed": "Error al actualizar elemento",
"txt_item_changed_elsewhere": "Este elemento se modificó en otro cliente. Tus cambios siguen en el formulario. Copia los cambios necesarios, vuelve a abrir el elemento y guárdalo.",
"txt_update_send_failed": "Error al actualizar envío",
"txt_update_user_status_failed": "Error al actualizar estado de usuario",
"txt_use_recovery_code": "Usar código de recuperación",
+1
View File
@@ -1087,6 +1087,7 @@ const fi: Record<string, string> = {
"txt_remove_device_failed": "Laitteen poistaminen epäonnistui",
"txt_remove_all_devices_failed": "Kaikkien laitteiden poistaminen epäonnistui",
"txt_update_item_failed": "Nimikkeen päivitys epäonnistui",
"txt_item_changed_elsewhere": "Kohdetta muutettiin toisessa sovelluksessa. Muokkauksesi ovat yhä lomakkeessa. Kopioi tarvittavat muutokset, avaa kohde uudelleen ja tallenna.",
"txt_update_send_failed": "Lähetyksen päivitys epäonnistui",
"txt_update_user_status_failed": "Käyttäjän tilan päivitys epäonnistui",
"txt_use_recovery_code": "Käytä palautuskoodia",
+1
View File
@@ -1087,6 +1087,7 @@ const fr: Record<string, string> = {
"txt_remove_device_failed": "Le retrait de l'appareil a échoué",
"txt_remove_all_devices_failed": "Le retrait de tous les appareils a échoué",
"txt_update_item_failed": "La mise à jour de l'élément a échoué",
"txt_item_changed_elsewhere": "Cet élément a été modifié dans un autre client. Vos modifications restent dans le formulaire. Copiez les changements nécessaires, rouvrez cet élément et enregistrez-le.",
"txt_update_send_failed": "La mise à jour de l'envoi a échoué",
"txt_update_user_status_failed": "La mise à jour du statut de l'utilisateur a échoué",
"txt_use_recovery_code": "Utiliser le code de récupération",
+1
View File
@@ -1087,6 +1087,7 @@ const it: Record<string, string> = {
"txt_remove_device_failed": "Impossibile rimuovere il dispositivo",
"txt_remove_all_devices_failed": "Impossibile rimuovere tutti i dispositivi",
"txt_update_item_failed": "Aggiornamento elemento fallito",
"txt_item_changed_elsewhere": "Questo elemento è stato modificato in un altro client. Le tue modifiche sono ancora nel modulo. Copia quelle necessarie, riapri questo elemento e salva.",
"txt_update_send_failed": "Aggiornamento invio fallito",
"txt_update_user_status_failed": "Aggiornamento stato utente fallito",
"txt_use_recovery_code": "Usa Codice di Recupero",
+1
View File
@@ -1087,6 +1087,7 @@ const ru: Record<string, string> = {
"txt_remove_device_failed": "Не удалось удалить устройство.",
"txt_remove_all_devices_failed": "Не удалось удалить все устройства.",
"txt_update_item_failed": "Обновить элемент не удалось",
"txt_item_changed_elsewhere": "Элемент изменён в другом клиенте. Ваши правки остались в форме. Скопируйте нужные изменения, откройте элемент заново и сохраните его.",
"txt_update_send_failed": "Send обновления не удалась",
"txt_update_user_status_failed": "Не удалось обновить статус пользователя",
"txt_use_recovery_code": "Использовать код восстановления",
+1
View File
@@ -1087,6 +1087,7 @@ const sv: Record<string, string> = {
"txt_remove_device_failed": "Misslyckades med att ta bort enhet",
"txt_remove_all_devices_failed": "Misslyckades med att ta bort alla enheter",
"txt_update_item_failed": "Misslyckades med att uppdatera objekt",
"txt_item_changed_elsewhere": "Objektet ändrades i en annan klient. Dina ändringar finns kvar i formuläret. Kopiera de ändringar du behöver, öppna objektet igen och spara.",
"txt_update_send_failed": "Misslyckades med att uppdatera sändning",
"txt_update_user_status_failed": "Misslyckades med att uppdatera användarstatus",
"txt_use_recovery_code": "Använd återställningskod",
+1
View File
@@ -1090,6 +1090,7 @@ const zhCN: Record<string, string> = {
"txt_remove_device_failed": "移除设备失败",
"txt_remove_all_devices_failed": "移除所有设备失败",
"txt_update_item_failed": "更新项目失败",
"txt_item_changed_elsewhere": "条目已在其他客户端修改。你的编辑内容仍保留在表单中,请复制需要的修改,重新打开条目后再保存。",
"txt_update_send_failed": "更新 Send 失败",
"txt_update_user_status_failed": "更新用户状态失败",
"txt_use_recovery_code": "使用恢复代码",
+1
View File
@@ -1090,6 +1090,7 @@ const zhTW: Record<string, string> = {
"txt_remove_device_failed": "移除設備失敗",
"txt_remove_all_devices_failed": "移除所有設備失敗",
"txt_update_item_failed": "更新項目失敗",
"txt_item_changed_elsewhere": "項目已在其他用戶端修改。你的編輯內容仍保留在表單中,請複製需要的修改,重新開啟項目後再儲存。",
"txt_update_send_failed": "更新 Send 失敗",
"txt_update_user_status_failed": "更新用戶狀態失敗",
"txt_use_recovery_code": "使用恢復代碼",
+3
View File
@@ -320,10 +320,13 @@ export interface VaultDraftField {
type: CustomFieldType;
label: string;
value: string;
linkedId?: number | null;
extra?: Record<string, unknown>;
}
export interface VaultDraft {
id?: string;
revisionDate?: string;
type: number;
favorite: boolean;
name: string;