feat: enhance deployment process and update dependencies

- Updated the deployment script to build the web application before deploying.
- Upgraded Wrangler dependency from 4.61.1 to 4.69.0.

feat: add import item limit and request body size limit

- Introduced a new limit for the maximum total items allowed in a single import (5000).
- Set a hard body size limit for JSON API endpoints (25 MB).

feat: validate KDF parameters during registration and password change

- Added validation for KDF parameters to ensure compliance with Bitwarden's minimum requirements.
- Enhanced error handling for invalid KDF parameters during user registration and password change.

feat: clean up R2 files on user deletion

- Implemented cleanup of R2 files associated with user attachments and sends before deleting user metadata.

feat: verify folder ownership when creating or updating ciphers

- Added checks to ensure that users cannot reference folders owned by other users when creating or updating ciphers.

fix: handle corrupted cipher data gracefully

- Improved error handling when retrieving ciphers from the database to avoid crashes due to corrupted data.

feat: increment send access count atomically

- Added a method to atomically increment the access count for sends and return whether the update was successful.

fix: enforce request body size limits

- Implemented checks to reject oversized request bodies for non-file upload paths.

fix: update error handling for database initialization

- Enhanced error logging for database initialization failures while providing a generic message to clients.

feat: enhance security with Content Security Policy

- Added a Content Security Policy to the web application to improve security against XSS attacks.

fix: remove plaintext TOTP secret from localStorage

- Updated the TOTP enabling process to remove the plaintext secret from localStorage after it is stored on the server.

fix: ensure only PBKDF2 hash is sent for public send access

- Modified the public send access payload to ensure only the PBKDF2 hash is sent, never the plaintext password.
This commit is contained in:
shuaiplus
2026-03-01 21:01:52 +08:00
committed by Shuai
parent e9ace523e6
commit c0683016c3
18 changed files with 349 additions and 186 deletions
+8
View File
@@ -103,6 +103,14 @@
// Max IDs per SQL batch when moving ciphers in bulk.
// 批量移动密码项时每批 SQL 的最大 ID 数量。
bulkMoveChunkSize: 200,
// Max total items (folders + ciphers) allowed in a single import.
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
importItemLimit: 5000,
},
request: {
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
// JSON 接口请求 body 大小上限(字节),文件上传接口除外。
maxBodyBytes: 25 * 1024 * 1024,
},
compatibility: {
// Single source of truth for /config.version and /api/version.
+44
View File
@@ -18,6 +18,32 @@ function looksLikeEncString(value: string): boolean {
return parts.length >= 2;
}
/**
* Validate KDF parameters according to Bitwarden minimum requirements.
* Returns an error message if invalid, or null if OK.
*/
function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null {
const type = kdfType ?? 0;
if (type === 0) {
// PBKDF2-SHA256: minimum 100 000 iterations
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) {
return 'PBKDF2 iterations must be at least 100000';
}
} else if (type === 1) {
// Argon2id: iterations >= 2, memory >= 16 MiB, parallelism >= 1
if (typeof kdfIterations === 'number' && kdfIterations < 2) {
return 'Argon2id iterations must be at least 2';
}
if (typeof kdfMemory === 'number' && kdfMemory < 16) {
return 'Argon2id memory must be at least 16 MiB';
}
if (typeof kdfParallelism === 'number' && kdfParallelism < 1) {
return 'Argon2id parallelism must be at least 1';
}
}
return null;
}
function normalizeTotpSecret(input: string): string {
return input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
}
@@ -111,6 +137,9 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
if (!email || !masterPasswordHash || !key) {
return errorResponse('Email, masterPasswordHash, and key are required', 400);
}
if (!email.includes('@') || email.length < 3) {
return errorResponse('Invalid email address', 400);
}
if (!privateKey || !publicKey) {
return errorResponse('Private key and public key are required', 400);
}
@@ -121,6 +150,9 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
}
const kdfErr = validateKdfParams(body.kdf, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400);
const now = new Date().toISOString();
const auth = new AuthService(env);
const serverHash = await auth.hashPasswordServer(masterPasswordHash, email);
@@ -338,6 +370,9 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
return errorResponse('new encryptedPrivateKey is not a valid encrypted string', 400);
}
const kdfErr = validateKdfParams(body.kdf ?? user.kdfType, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400);
user.masterPasswordHash = await auth.hashPasswordServer(body.newMasterPasswordHash, user.email);
if (nextKey) user.key = nextKey;
if (nextPrivateKey) user.privateKey = nextPrivateKey;
@@ -350,6 +385,15 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
await storage.createAuditLog({
id: generateUUID(),
actorUserId: user.id,
action: 'user.password.change',
targetType: 'user',
targetId: user.id,
metadata: JSON.stringify({ email: user.email }),
createdAt: user.updatedAt,
});
return new Response(null, { status: 200 });
}
+22
View File
@@ -255,6 +255,28 @@ export async function handleAdminDeleteUser(
return errorResponse('User not found', 404);
}
// Clean up R2 files before DB cascade deletes the metadata rows.
// 1. Attachment files (keyed by cipherId/attachmentId)
const attachmentMap = await storage.getAttachmentsByUserId(target.id);
for (const [cipherId, attachments] of attachmentMap) {
for (const att of attachments) {
await env.ATTACHMENTS.delete(`${cipherId}/${att.id}`);
}
}
// 2. Send files (keyed by sends/sendId/fileId)
const sends = await storage.getAllSends(target.id);
for (const send of sends) {
if (send.type === 1) { // SendType.File
try {
const parsed = JSON.parse(send.data) as Record<string, unknown>;
const fileId = typeof parsed.id === 'string' ? parsed.id : null;
if (fileId) {
await env.ATTACHMENTS.delete(`sends/${send.id}/${fileId}`);
}
} catch { /* non-file send or bad data, skip */ }
}
}
await storage.deleteRefreshTokensByUserId(target.id);
await storage.deleteUserById(target.id);
await writeAuditLog(storage, actorUser.id, 'admin.user.delete', 'user', target.id, {
+27
View File
@@ -144,6 +144,12 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
return jsonResponse(cipherToResponse(cipher, attachments));
}
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
if (!folderId) return true;
const folder = await storage.getFolder(folderId);
return !!(folder && folder.userId === userId);
}
// POST /api/ciphers
export async function handleCreateCipher(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
@@ -178,6 +184,12 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
// Prevent referencing a folder owned by another user.
if (cipher.folderId) {
const folderOk = await verifyFolderOwnership(storage, cipher.folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
@@ -232,6 +244,12 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
cipher.fields = null;
}
// Prevent referencing a folder owned by another user.
if (cipher.folderId) {
const folderOk = await verifyFolderOwnership(storage, cipher.folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
await storage.saveCipher(cipher);
await storage.updateRevisionDate(userId);
@@ -331,6 +349,10 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
}
if (body.folderId !== undefined) {
if (body.folderId) {
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
cipher.folderId = body.folderId;
}
if (body.favorite !== undefined) {
@@ -359,6 +381,11 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
return errorResponse('ids array is required', 400);
}
if (body.folderId) {
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
if (!folderOk) return errorResponse('Folder not found', 404);
}
await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId);
return new Response(null, { status: 204 });
+4 -2
View File
@@ -391,8 +391,10 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
// Return default KDF settings even if user doesn't exist (to prevent user enumeration)
const kdfType = user?.kdfType ?? 0;
const kdfIterations = user?.kdfIterations ?? LIMITS.auth.defaultKdfIterations;
const kdfMemory = user?.kdfMemory;
const kdfParallelism = user?.kdfParallelism;
// Use ?? null so non-existent users return null (not undefined/omitted) for these fields,
// matching the response shape of real PBKDF2 users and reducing enumeration signal.
const kdfMemory = user?.kdfMemory ?? null;
const kdfParallelism = user?.kdfParallelism ?? null;
return jsonResponse({
kdf: kdfType,
+4
View File
@@ -102,6 +102,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const ciphers = importData.ciphers || [];
const folderRelationships = importData.folderRelationships || [];
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
return errorResponse(`Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`, 400);
}
const now = new Date().toISOString();
const batchChunkSize = LIMITS.performance.bulkMoveChunkSize;
+16 -8
View File
@@ -1022,9 +1022,11 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
}
if (send.type === SendType.Text) {
const updated = await storage.incrementSendAccessCount(send.id);
if (!updated) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
await storage.updateRevisionDate(send.userId);
}
@@ -1068,9 +1070,11 @@ export async function handleAccessSendFile(
return validationErr;
}
const updated = await storage.incrementSendAccessCount(send.id);
if (!updated) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
await storage.updateRevisionDate(send.userId);
const token = await createSendFileDownloadToken(send.id, fileId, secret);
@@ -1106,9 +1110,11 @@ export async function handleAccessSendV2(request: Request, env: Env): Promise<Re
}
if (send.type === SendType.Text) {
const updated = await storage.incrementSendAccessCount(send.id);
if (!updated) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
await storage.updateRevisionDate(send.userId);
}
@@ -1145,9 +1151,11 @@ export async function handleAccessSendFileV2(request: Request, env: Env, fileId:
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
const updated = await storage.incrementSendAccessCount(send.id);
if (!updated) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
send.accessCount += 1;
send.updatedAt = new Date().toISOString();
await storage.saveSend(send);
await storage.updateRevisionDate(send.userId);
const downloadToken = await createSendFileDownloadToken(send.id, fileId, secret);
+4 -2
View File
@@ -34,12 +34,14 @@ export default {
void ctx;
await ensureDatabaseInitialized(env);
if (dbInitError) {
// Log full error server-side, return generic message to client.
console.error('DB init error (not forwarded to client):', dbInitError);
const resp = jsonResponse(
{
error: 'Database not initialized',
error_description: dbInitError,
error_description: 'Database initialization failed. Check server logs for details.',
ErrorModel: {
Message: dbInitError,
Message: 'Service temporarily unavailable',
Object: 'error',
},
},
+14
View File
@@ -240,6 +240,18 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Route matching
try {
// Reject oversized bodies before any path-specific parsing.
// File upload paths enforce their own limits and are exempt here.
const isFileUploadPath =
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path);
if (!isFileUploadPath) {
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
if (contentLength > LIMITS.request.maxBodyBytes) {
return errorResponse('Request body too large', 413);
}
}
// Setup status
if (path === '/setup/status' && method === 'GET') {
return handleSetupStatus(request, env);
@@ -328,6 +340,8 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Notifications hub (stub - no auth required, return 200 for connection)
if (path.startsWith('/notifications/')) {
const blocked = await enforcePublicRateLimit();
if (blocked) return blocked;
return new Response(null, { status: 200 });
}
+39 -5
View File
@@ -425,7 +425,13 @@ export class StorageService {
async getCipher(id: string): Promise<Cipher | null> {
const row = await this.db.prepare('SELECT data FROM ciphers WHERE id = ?').bind(id).first<{ data: string }>();
return row?.data ? (JSON.parse(row.data) as Cipher) : null;
if (!row?.data) return null;
try {
return JSON.parse(row.data) as Cipher;
} catch {
console.error('Corrupted cipher data, id:', id);
return null;
}
}
async saveCipher(cipher: Cipher): Promise<void> {
@@ -460,7 +466,9 @@ export class StorageService {
async getAllCiphers(userId: string): Promise<Cipher[]> {
const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>();
return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
return (res.results || []).flatMap(r => {
try { return [JSON.parse(r.data) as Cipher]; } catch { return []; }
});
}
async getCiphersPage(userId: string, includeDeleted: boolean, limit: number, offset: number): Promise<Cipher[]> {
@@ -475,7 +483,9 @@ export class StorageService {
)
.bind(userId, limit, offset)
.all<{ data: string }>();
return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
return (res.results || []).flatMap(r => {
try { return [JSON.parse(r.data) as Cipher]; } catch { return []; }
});
}
async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> {
@@ -484,7 +494,9 @@ export class StorageService {
const placeholders = ids.map(() => '?').join(',');
const stmt = this.db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`);
const res = await stmt.bind(userId, ...ids).all<{ data: string }>();
return (res.results || []).map(r => JSON.parse(r.data) as Cipher);
return (res.results || []).flatMap(r => {
try { return [JSON.parse(r.data) as Cipher]; } catch { return []; }
});
}
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<void> {
@@ -555,7 +567,12 @@ export class StorageService {
.all<{ data: string }>();
for (const row of (res.results || [])) {
const cipher = JSON.parse(row.data) as Cipher;
let cipher: Cipher;
try {
cipher = JSON.parse(row.data) as Cipher;
} catch {
continue;
}
cipher.folderId = null;
cipher.updatedAt = now;
await this.saveCipher(cipher);
@@ -857,6 +874,23 @@ export class StorageService {
).run();
}
/**
* Atomically increment access_count and update updated_at.
* Returns true if the row was updated (send still available),
* false if max_access_count has already been reached.
*/
async incrementSendAccessCount(sendId: string): Promise<boolean> {
const now = new Date().toISOString();
const result = await this.db
.prepare(
'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' +
'WHERE id = ? AND (max_access_count IS NULL OR access_count < max_access_count)'
)
.bind(now, sendId)
.run();
return (result.meta.changes ?? 0) > 0;
}
async deleteSend(id: string, userId: string): Promise<void> {
await this.db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run();
}
-1
View File
@@ -5,7 +5,6 @@ const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarde
function isTrustedClientOrigin(origin: string): boolean {
// Official browser extension / desktop-webview common origins.
if (origin === 'null') return true;
if (origin.startsWith('chrome-extension://')) return true;
if (origin.startsWith('moz-extension://')) return true;
if (origin.startsWith('safari-web-extension://')) return true;