mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user