mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-11-04 02:40:20 +00:00
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:
1 parent
5f71cb388c
commit
293f5ce196
26 files changed
+456
-25
No files matched your search
+17
-12
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user