diff --git a/src/config/limits.ts b/src/config/limits.ts index 2e40bcc..52a008b 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -62,6 +62,9 @@ // Refresh-token grant budget per IP per minute. // refresh_token 授权每 IP 每分钟请求配额。 refreshTokenRequestsPerMinute: 30, + // Passwordless/auth-request creation budget per IP/email/device per minute. + // 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。 + authRequestRequestsPerMinute: 5, // Fixed window size for API rate limiting in seconds. // API 限流固定窗口大小(秒)。 apiWindowSeconds: 60, diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index cbb4f64..45a50d0 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -42,6 +42,9 @@ function looksLikeEncString(value: string): boolean { */ function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null { const type = kdfType ?? 0; + if (type !== 0 && type !== 1) { + return 'KDF type must be PBKDF2-SHA256 or Argon2id'; + } if (type === 0) { // PBKDF2-SHA256: minimum 100 000 iterations if (typeof kdfIterations === 'number' && kdfIterations < 100_000) { @@ -448,7 +451,7 @@ export async function handleGetPasswordHint(request: Request, env: Env): Promise } const rateLimit = new RateLimitService(env.DB); - const minuteBudget = await rateLimit.consumeBudgetWithWindow( + const minuteBudget = await rateLimit.consumeStrictBudgetWithWindow( `${clientIdentifier}:password-hint`, LIMITS.rateLimit.passwordHintRequestsPerMinute, 60 @@ -470,7 +473,7 @@ export async function handleGetPasswordHint(request: Request, env: Env): Promise ); } - const hourlyBudget = await rateLimit.consumeBudgetWithWindow( + const hourlyBudget = await rateLimit.consumeStrictBudgetWithWindow( `${clientIdentifier}:password-hint-hour`, LIMITS.rateLimit.passwordHintRequestsPerHour, 60 * 60 @@ -734,6 +737,11 @@ export async function handleChangePassword(request: Request, env: Env, userId: s const nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']); const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism); if (kdfErr) return errorResponse(kdfErr, 400); + const shouldUpdateHint = typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null; + const nextMasterPasswordHint = shouldUpdateHint ? normalizeMasterPasswordHint(body.masterPasswordHint) : undefined; + if (nextMasterPasswordHint && nextMasterPasswordHint.length > 120) { + return errorResponse('masterPasswordHint must be 120 characters or fewer', 400); + } user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email); if (nextKey) user.key = nextKey; @@ -743,8 +751,8 @@ export async function handleChangePassword(request: Request, env: Env, userId: s if (typeof nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations; if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory; if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism; - if (typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null) { - user.masterPasswordHint = body.masterPasswordHint; + if (shouldUpdateHint) { + user.masterPasswordHint = nextMasterPasswordHint ?? null; } user.securityStamp = generateUUID(); user.updatedAt = new Date().toISOString(); diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index ddeac08..81c4e6e 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -124,6 +124,10 @@ async function processAttachmentUpload( } const path = getAttachmentObjectKey(cipherId, attachment.id); + if (await getBlobObject(env, path)) { + return errorResponse('Attachment file has already been uploaded', 409); + } + try { await putBlobObject(env, path, upload.body, { size: upload.size, diff --git a/src/handlers/auth-requests.ts b/src/handlers/auth-requests.ts index 953f60b..121001b 100644 --- a/src/handlers/auth-requests.ts +++ b/src/handlers/auth-requests.ts @@ -5,6 +5,8 @@ import { readAuthRequestDeviceInfo, readActingDeviceIdentifier } from '../utils/ import { errorResponse, jsonResponse } from '../utils/response'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { notifyAuthRequestResponse, notifyUserAuthRequest } from '../durable/notifications-hub'; +import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; +import { LIMITS } from '../config/limits'; const AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK = 0; const AUTH_REQUEST_TYPE_UNLOCK = 1; @@ -131,6 +133,30 @@ async function readJsonBody(request: Request): Promise | nul } } +async function enforceAuthRequestCreateRateLimit( + request: Request, + env: Env, + email: string, + deviceIdentifier: string +): Promise { + const clientIdentifier = getClientIdentifier(request); + if (!clientIdentifier) return errorResponse('Client IP is required', 403); + + const rateLimit = new RateLimitService(env.DB); + const limit = LIMITS.rateLimit.authRequestRequestsPerMinute; + const encodedEmail = encodeURIComponent(email || 'missing'); + const encodedDevice = encodeURIComponent(deviceIdentifier || 'missing'); + const budgets = await Promise.all([ + rateLimit.consumeStrictBudget(`auth-request:ip:${clientIdentifier}`, limit), + rateLimit.consumeStrictBudget(`auth-request:email:${encodedEmail}`, limit), + rateLimit.consumeStrictBudget(`auth-request:device:${encodedDevice}`, limit), + ]); + const blocked = budgets.find((budget) => !budget.allowed); + if (!blocked) return null; + + return errorResponse('Too many authentication requests. Try again later.', 429); +} + function readBodyValue(body: Record, names: string[]): unknown { for (const name of names) { if (body[name] !== undefined) return body[name]; @@ -164,6 +190,8 @@ export async function handleCreateAuthRequest(request: Request, env: Env): Promi if (!email || !publicKey || !accessCode || !deviceInfo.deviceIdentifier) { return errorResponse('Email, public key, device identifier, and access code are required.', 400); } + const rateLimitResponse = await enforceAuthRequestCreateRateLimit(request, env, email, deviceInfo.deviceIdentifier); + if (rateLimitResponse) return rateLimitResponse; if (!isSupportedAuthRequestType(type) || type === AUTH_REQUEST_TYPE_ADMIN_APPROVAL) { return errorResponse('Invalid auth request type.', 400); } diff --git a/src/router-admin.ts b/src/router-admin.ts index c1774b3..6d36266 100644 --- a/src/router-admin.ts +++ b/src/router-admin.ts @@ -13,6 +13,23 @@ import { handleAdminClearAuditLogs, } from './handlers/admin'; import { handleAdminBackupRoute } from './router-admin-backup'; +import { errorResponse } from './utils/response'; + +function isKnownAdminPath(path: string): boolean { + return ( + path === '/api/admin/users' || + path === '/api/admin/logs' || + path === '/api/admin/logs/settings' || + path === '/api/admin/invites' || + path.startsWith('/api/admin/backup') || + /^\/api\/admin\/invites\/[^/]+$/i.test(path) || + /^\/api\/admin\/users\/[a-f0-9-]+(?:\/status)?$/i.test(path) + ); +} + +function isActiveAdmin(user: User): boolean { + return user.role === 'admin' && user.status === 'active'; +} export async function handleAdminRoute( request: Request, @@ -21,6 +38,13 @@ export async function handleAdminRoute( path: string, method: string ): Promise { + if (!isKnownAdminPath(path)) { + return null; + } + if (!isActiveAdmin(actorUser)) { + return errorResponse('Forbidden', 403); + } + if (path === '/api/admin/users' && method === 'GET') { return handleAdminListUsers(request, env, actorUser); } diff --git a/src/router-public.ts b/src/router-public.ts index 9644245..91de2d5 100644 --- a/src/router-public.ts +++ b/src/router-public.ts @@ -465,6 +465,8 @@ export async function handlePublicRoute( } if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') { + const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute); + if (blocked) return blocked; return handleRecoverTwoFactor(request, env); } diff --git a/src/router.ts b/src/router.ts index 46de01f..4819bdd 100644 --- a/src/router.ts +++ b/src/router.ts @@ -36,6 +36,70 @@ function isImportBypassRequest(request: Request, path: string, method: string): return false; } +const BODY_LIMIT_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +function isLargeUploadPath(path: string): boolean { + return ( + /^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) || + /^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) || + path === '/api/admin/backup/import' + ); +} + +async function enforceRequestBodyLimit( + request: Request, + path: string, + method: string +): Promise { + if (!BODY_LIMIT_METHODS.has(method) || isLargeUploadPath(path) || !request.body) { + return request; + } + + const contentLengthRaw = request.headers.get('Content-Length'); + if (contentLengthRaw) { + const contentLength = Number(contentLengthRaw); + if (Number.isFinite(contentLength) && contentLength > LIMITS.request.maxBodyBytes) { + return errorResponse('Request body too large', 413); + } + if (Number.isFinite(contentLength) && contentLength >= 0) { + return request; + } + } + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + total += value.byteLength; + if (total > LIMITS.request.maxBodyBytes) { + try { + await reader.cancel(); + } catch { + // Ignore cancellation races after the oversized body is rejected. + } + return errorResponse('Request body too large', 413); + } + chunks.push(value); + } + + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + + return new Request(request.url, { + method: request.method, + headers: request.headers, + body, + redirect: request.redirect, + }); +} + export async function handleRequest(request: Request, env: Env): Promise { const url = new URL(request.url); const path = url.pathname; @@ -60,7 +124,10 @@ export async function handleRequest(request: Request, env: Env): Promise LIMITS.request.maxBodyBytes) { - return errorResponse('Request body too large', 413); - } + const bodyLimitResult = await enforceRequestBodyLimit(request, path, method); + if (bodyLimitResult instanceof Response) { + return bodyLimitResult; } + request = bodyLimitResult; const secretIssue = jwtSecretUnsafeReason(env); if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) { diff --git a/src/services/ratelimit.ts b/src/services/ratelimit.ts index a659a86..42e9161 100644 --- a/src/services/ratelimit.ts +++ b/src/services/ratelimit.ts @@ -3,6 +3,7 @@ import { LIMITS } from '../config/limits'; // Rate limiting service. // - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence). // - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes). +// - Strict budgets: D1-backed fixed windows for low-volume anonymous sensitive endpoints. const CONFIG = { LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts, @@ -12,11 +13,14 @@ const CONFIG = { export class RateLimitService { private static loginIpTableReady = false; + private static strictBudgetTableReady = false; private static lastLoginIpCleanupAt = 0; + private static lastStrictBudgetCleanupAt = 0; private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability; private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs; private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs; + private static readonly STRICT_BUDGET_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs; constructor(private db: D1Database) {} @@ -58,6 +62,35 @@ export class RateLimitService { RateLimitService.loginIpTableReady = true; } + private async ensureStrictBudgetTable(): Promise { + if (RateLimitService.strictBudgetTableReady) return; + + await this.db + .prepare( + 'CREATE TABLE IF NOT EXISTS rate_limit_buckets (' + + 'bucket_key TEXT PRIMARY KEY, ' + + 'count INTEGER NOT NULL, ' + + 'expires_at INTEGER NOT NULL, ' + + 'updated_at INTEGER NOT NULL' + + ')' + ) + .run(); + + await this.db + .prepare('CREATE INDEX IF NOT EXISTS idx_rate_limit_buckets_expires ON rate_limit_buckets(expires_at)') + .run(); + RateLimitService.strictBudgetTableReady = true; + } + + private async maybeCleanupStrictBudgets(nowMs: number): Promise { + if (!this.shouldRunCleanup(RateLimitService.lastStrictBudgetCleanupAt, RateLimitService.STRICT_BUDGET_CLEANUP_INTERVAL_MS)) { + return; + } + + await this.db.prepare('DELETE FROM rate_limit_buckets WHERE expires_at < ?').bind(nowMs).run(); + RateLimitService.lastStrictBudgetCleanupAt = nowMs; + } + async checkLoginAttempt(ip: string): Promise<{ allowed: boolean; remainingAttempts: number; @@ -174,6 +207,59 @@ export class RateLimitService { return { allowed: true, remaining: Math.max(0, maxRequests - count) }; } + async consumeStrictBudget( + identifier: string, + maxRequests: number + ): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { + return this.consumeStrictBudgetWithWindow(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS); + } + + async consumeStrictBudgetWithWindow( + identifier: string, + maxRequests: number, + windowSeconds: number + ): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { + await this.ensureStrictBudgetTable(); + + const key = String(identifier || '').trim() || 'unknown'; + const max = Math.max(1, Math.floor(maxRequests)); + const windowSize = Math.max(1, Math.floor(windowSeconds)); + const nowMs = Date.now(); + const nowSec = Math.floor(nowMs / 1000); + const windowStart = nowSec - (nowSec % windowSize); + const windowEndMs = (windowStart + windowSize) * 1000; + const retryAfterSeconds = Math.max(1, Math.ceil((windowEndMs - nowMs) / 1000)); + const bucketKey = `${key}:${windowStart}`; + + await this.maybeCleanupStrictBudgets(nowMs); + await this.db + .prepare( + 'INSERT OR IGNORE INTO rate_limit_buckets(bucket_key, count, expires_at, updated_at) VALUES(?, 0, ?, ?)' + ) + .bind(bucketKey, windowEndMs, nowMs) + .run(); + + const update = await this.db + .prepare( + 'UPDATE rate_limit_buckets SET count = count + 1, expires_at = ?, updated_at = ? ' + + 'WHERE bucket_key = ? AND count < ?' + ) + .bind(windowEndMs, nowMs, bucketKey, max) + .run(); + + const allowed = Number(update.meta?.changes ?? 0) > 0; + const row = await this.db + .prepare('SELECT count FROM rate_limit_buckets WHERE bucket_key = ?') + .bind(bucketKey) + .first<{ count: number }>(); + const count = Math.max(0, Number(row?.count || 0)); + + if (!allowed) { + return { allowed: false, remaining: 0, retryAfterSeconds }; + } + return { allowed: true, remaining: Math.max(0, max - count) }; + } + // General-purpose fixed-window budget. // Callers supply an identifier (must be unique per rate-limit category) and the // per-window maximum. This single method replaces all previous specialised diff --git a/src/services/storage-account-passkey-repo.ts b/src/services/storage-account-passkey-repo.ts index f0cedc5..751d816 100644 --- a/src/services/storage-account-passkey-repo.ts +++ b/src/services/storage-account-passkey-repo.ts @@ -268,7 +268,7 @@ export async function updateAccountPasskeyEncryption( const result = await db .prepare( 'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' + - 'WHERE user_id = ? AND credential_id = ?' + "WHERE user_id = ? AND credential_id = ? AND purpose = 'login'" ) .bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId) .run(); diff --git a/webapp/public/webauthn-fallback-connector.html b/webapp/public/webauthn-fallback-connector.html index d50af30..40e45e1 100644 --- a/webapp/public/webauthn-fallback-connector.html +++ b/webapp/public/webauthn-fallback-connector.html @@ -227,6 +227,50 @@ return out; } + function trustedParentOrigin() { + var parent = decodeRepeated(params.get("parent")); + if (!parent) return ""; + try { + var parentUrl = new URL(parent); + if ( + parentUrl.protocol === "chrome-extension:" || + parentUrl.protocol === "moz-extension:" || + parentUrl.protocol === "safari-web-extension:" + ) { + return parentUrl.protocol + "//" + parentUrl.host; + } + if (parentUrl.origin === window.location.origin) { + return parentUrl.origin; + } + } catch (_error) { + return ""; + } + return ""; + } + + function safeShallowCopy(source) { + var copy = {}; + if (!source || typeof source !== "object") return copy; + Object.keys(source).forEach(function (key) { + if (key === "__proto__" || key === "prototype" || key === "constructor") return; + copy[key] = source[key]; + }); + return copy; + } + + function postResult(message) { + var parentOrigin = trustedParentOrigin(); + if (parentOrigin) { + if (window.opener && !window.opener.closed) { + window.opener.postMessage(message, parentOrigin); + } + if (window.parent && window.parent !== window) { + window.parent.postMessage(message, parentOrigin); + } + } + window.postMessage(message, window.location.origin); + } + function showMessage(kind, message) { msgEl.textContent = String(message || ""); msgEl.className = "msg show " + kind; @@ -279,13 +323,13 @@ function normalizeOptions(options) { if (!options || typeof options !== "object") throw new Error("Cannot parse data."); - var copy = Object.assign({}, options); + var copy = safeShallowCopy(options); copy.challenge = bytesFromBase64Url(copy.challenge); if (Array.isArray(copy.allowCredentials)) { copy.allowCredentials = copy.allowCredentials.map(function (credential) { - return Object.assign({}, credential, { - id: bytesFromBase64Url(credential.id), - }); + var next = safeShallowCopy(credential); + next.id = bytesFromBase64Url(credential && credential.id); + return next; }); } return copy; @@ -327,11 +371,11 @@ if (!(credential instanceof PublicKeyCredential)) { throw new Error("No security key was selected."); } - window.postMessage({ + postResult({ command: "webAuthnResult", data: credentialToDataString(credential), remember: rememberEl.checked, - }, "*"); + }); sentSuccess = true; showMessage("success", text.success); } catch (error) { diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index aa7125d..cfd9b41 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -94,6 +94,14 @@ export function loadSession(): SessionState | null { const raw = localStorage.getItem(SESSION_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as Partial & Partial; + if (parsed.email && (parsed.accessToken || parsed.refreshToken)) { + const authMode = parsed.authMode === 'web-cookie' ? 'web-cookie' : 'token'; + saveSession({ email: parsed.email, authMode }); + return { + email: parsed.email, + authMode, + }; + } if (parsed.authMode === 'web-cookie' && parsed.email) { return { email: parsed.email, @@ -106,13 +114,7 @@ export function loadSession(): SessionState | null { authMode: 'token', }; } - if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null; - return { - accessToken: parsed.accessToken, - refreshToken: parsed.refreshToken, - email: parsed.email, - authMode: 'token', - }; + return null; } catch { return null; } diff --git a/webapp/src/lib/vault-cache.ts b/webapp/src/lib/vault-cache.ts index f72413b..df87682 100644 --- a/webapp/src/lib/vault-cache.ts +++ b/webapp/src/lib/vault-cache.ts @@ -19,6 +19,28 @@ const VAULT_CORE_STORE = 'vault-core'; let dbPromise: Promise | null = null; +function stripDecryptedCacheFields(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => stripDecryptedCacheFields(item)) as T; + } + if (!value || typeof value !== 'object') return value; + const source = value as Record; + const out: Record = {}; + for (const [key, item] of Object.entries(source)) { + if (/^dec[A-Z]/.test(key) || key === 'shareUrl') continue; + out[key] = stripDecryptedCacheFields(item); + } + return out as T; +} + +function sanitizeSnapshotForCache(snapshot: VaultCoreSnapshot): VaultCoreSnapshot { + return { + ciphers: stripDecryptedCacheFields(Array.isArray(snapshot.ciphers) ? snapshot.ciphers : []), + folders: stripDecryptedCacheFields(Array.isArray(snapshot.folders) ? snapshot.folders : []), + sends: stripDecryptedCacheFields(Array.isArray(snapshot.sends) ? snapshot.sends : []), + }; +} + function supportsIndexedDb(): boolean { return typeof indexedDB !== 'undefined'; } @@ -72,7 +94,7 @@ export async function loadCachedVaultCoreSnapshot(cacheKey: string): Promise { const record = request.result as VaultCoreCacheRecord | undefined; - resolve(record || null); + resolve(record ? { ...record, snapshot: sanitizeSnapshotForCache(record.snapshot) } : null); }; request.onerror = () => resolve(null); })); @@ -90,7 +112,7 @@ export async function saveCachedVaultCoreSnapshot( cacheKey: normalized, revisionStamp, savedAt: Date.now(), - snapshot, + snapshot: sanitizeSnapshotForCache(snapshot), }; const request = store.put(record); request.onsuccess = () => resolve();