fix(security): harden auth and request limits

This commit is contained in:
shuaiplus
2026-07-05 23:43:49 +08:00
parent 12af18e3a3
commit d9a36fefe6
12 changed files with 315 additions and 30 deletions
+3
View File
@@ -62,6 +62,9 @@
// Refresh-token grant budget per IP per minute. // Refresh-token grant budget per IP per minute.
// refresh_token 授权每 IP 每分钟请求配额。 // refresh_token 授权每 IP 每分钟请求配额。
refreshTokenRequestsPerMinute: 30, 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. // Fixed window size for API rate limiting in seconds.
// API 限流固定窗口大小(秒)。 // API 限流固定窗口大小(秒)。
apiWindowSeconds: 60, apiWindowSeconds: 60,
+12 -4
View File
@@ -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 { function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null {
const type = kdfType ?? 0; const type = kdfType ?? 0;
if (type !== 0 && type !== 1) {
return 'KDF type must be PBKDF2-SHA256 or Argon2id';
}
if (type === 0) { if (type === 0) {
// PBKDF2-SHA256: minimum 100 000 iterations // PBKDF2-SHA256: minimum 100 000 iterations
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) { 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 rateLimit = new RateLimitService(env.DB);
const minuteBudget = await rateLimit.consumeBudgetWithWindow( const minuteBudget = await rateLimit.consumeStrictBudgetWithWindow(
`${clientIdentifier}:password-hint`, `${clientIdentifier}:password-hint`,
LIMITS.rateLimit.passwordHintRequestsPerMinute, LIMITS.rateLimit.passwordHintRequestsPerMinute,
60 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`, `${clientIdentifier}:password-hint-hour`,
LIMITS.rateLimit.passwordHintRequestsPerHour, LIMITS.rateLimit.passwordHintRequestsPerHour,
60 * 60 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 nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']);
const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism); const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400); 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); user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email);
if (nextKey) user.key = nextKey; 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 nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations;
if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory; if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory;
if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism; if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism;
if (typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null) { if (shouldUpdateHint) {
user.masterPasswordHint = body.masterPasswordHint; user.masterPasswordHint = nextMasterPasswordHint ?? null;
} }
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
+4
View File
@@ -124,6 +124,10 @@ async function processAttachmentUpload(
} }
const path = getAttachmentObjectKey(cipherId, attachment.id); const path = getAttachmentObjectKey(cipherId, attachment.id);
if (await getBlobObject(env, path)) {
return errorResponse('Attachment file has already been uploaded', 409);
}
try { try {
await putBlobObject(env, path, upload.body, { await putBlobObject(env, path, upload.body, {
size: upload.size, size: upload.size,
+28
View File
@@ -5,6 +5,8 @@ import { readAuthRequestDeviceInfo, readActingDeviceIdentifier } from '../utils/
import { errorResponse, jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { notifyAuthRequestResponse, notifyUserAuthRequest } from '../durable/notifications-hub'; 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_AUTHENTICATE_AND_UNLOCK = 0;
const AUTH_REQUEST_TYPE_UNLOCK = 1; const AUTH_REQUEST_TYPE_UNLOCK = 1;
@@ -131,6 +133,30 @@ async function readJsonBody(request: Request): Promise<Record<string, any> | nul
} }
} }
async function enforceAuthRequestCreateRateLimit(
request: Request,
env: Env,
email: string,
deviceIdentifier: string
): Promise<Response | null> {
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<string, any>, names: string[]): unknown { function readBodyValue(body: Record<string, any>, names: string[]): unknown {
for (const name of names) { for (const name of names) {
if (body[name] !== undefined) return body[name]; 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) { if (!email || !publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
return errorResponse('Email, public key, device identifier, and access code are required.', 400); 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) { if (!isSupportedAuthRequestType(type) || type === AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
return errorResponse('Invalid auth request type.', 400); return errorResponse('Invalid auth request type.', 400);
} }
+24
View File
@@ -13,6 +13,23 @@ import {
handleAdminClearAuditLogs, handleAdminClearAuditLogs,
} from './handlers/admin'; } from './handlers/admin';
import { handleAdminBackupRoute } from './router-admin-backup'; 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( export async function handleAdminRoute(
request: Request, request: Request,
@@ -21,6 +38,13 @@ export async function handleAdminRoute(
path: string, path: string,
method: string method: string
): Promise<Response | null> { ): Promise<Response | null> {
if (!isKnownAdminPath(path)) {
return null;
}
if (!isActiveAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
if (path === '/api/admin/users' && method === 'GET') { if (path === '/api/admin/users' && method === 'GET') {
return handleAdminListUsers(request, env, actorUser); return handleAdminListUsers(request, env, actorUser);
} }
+2
View File
@@ -465,6 +465,8 @@ export async function handlePublicRoute(
} }
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') { 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); return handleRecoverTwoFactor(request, env);
} }
+72 -10
View File
@@ -36,6 +36,70 @@ function isImportBypassRequest(request: Request, path: string, method: string):
return false; 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<Request | Response> {
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<Response> { export async function handleRequest(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url); const url = new URL(request.url);
const path = url.pathname; const path = url.pathname;
@@ -60,7 +124,10 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
const rateLimit = new RateLimitService(env.DB); const rateLimit = new RateLimitService(env.DB);
const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests); const shouldUseStrictBudget = category === 'public-sensitive' || category === 'register';
const check = shouldUseStrictBudget
? await rateLimit.consumeStrictBudget(`${clientId}:${category}`, maxRequests)
: await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
if (check.allowed) return null; if (check.allowed) return null;
return new Response( return new Response(
@@ -84,16 +151,11 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
try { try {
const isLargeUploadPath = const bodyLimitResult = await enforceRequestBodyLimit(request, path, method);
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) || if (bodyLimitResult instanceof Response) {
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) || return bodyLimitResult;
path === '/api/admin/backup/import';
if (!isLargeUploadPath) {
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
if (contentLength > LIMITS.request.maxBodyBytes) {
return errorResponse('Request body too large', 413);
}
} }
request = bodyLimitResult;
const secretIssue = jwtSecretUnsafeReason(env); const secretIssue = jwtSecretUnsafeReason(env);
if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) { if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) {
+86
View File
@@ -3,6 +3,7 @@ import { LIMITS } from '../config/limits';
// Rate limiting service. // Rate limiting service.
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence). // - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
// - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes). // - 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 = { const CONFIG = {
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts, LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
@@ -12,11 +13,14 @@ const CONFIG = {
export class RateLimitService { export class RateLimitService {
private static loginIpTableReady = false; private static loginIpTableReady = false;
private static strictBudgetTableReady = false;
private static lastLoginIpCleanupAt = 0; private static lastLoginIpCleanupAt = 0;
private static lastStrictBudgetCleanupAt = 0;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability; 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_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs; 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) {} constructor(private db: D1Database) {}
@@ -58,6 +62,35 @@ export class RateLimitService {
RateLimitService.loginIpTableReady = true; RateLimitService.loginIpTableReady = true;
} }
private async ensureStrictBudgetTable(): Promise<void> {
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<void> {
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<{ async checkLoginAttempt(ip: string): Promise<{
allowed: boolean; allowed: boolean;
remainingAttempts: number; remainingAttempts: number;
@@ -174,6 +207,59 @@ export class RateLimitService {
return { allowed: true, remaining: Math.max(0, maxRequests - count) }; 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. // General-purpose fixed-window budget.
// Callers supply an identifier (must be unique per rate-limit category) and the // Callers supply an identifier (must be unique per rate-limit category) and the
// per-window maximum. This single method replaces all previous specialised // per-window maximum. This single method replaces all previous specialised
+1 -1
View File
@@ -268,7 +268,7 @@ export async function updateAccountPasskeyEncryption(
const result = await db const result = await db
.prepare( .prepare(
'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' + '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) .bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId)
.run(); .run();
+50 -6
View File
@@ -227,6 +227,50 @@
return out; 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) { function showMessage(kind, message) {
msgEl.textContent = String(message || ""); msgEl.textContent = String(message || "");
msgEl.className = "msg show " + kind; msgEl.className = "msg show " + kind;
@@ -279,13 +323,13 @@
function normalizeOptions(options) { function normalizeOptions(options) {
if (!options || typeof options !== "object") throw new Error("Cannot parse data."); 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); copy.challenge = bytesFromBase64Url(copy.challenge);
if (Array.isArray(copy.allowCredentials)) { if (Array.isArray(copy.allowCredentials)) {
copy.allowCredentials = copy.allowCredentials.map(function (credential) { copy.allowCredentials = copy.allowCredentials.map(function (credential) {
return Object.assign({}, credential, { var next = safeShallowCopy(credential);
id: bytesFromBase64Url(credential.id), next.id = bytesFromBase64Url(credential && credential.id);
}); return next;
}); });
} }
return copy; return copy;
@@ -327,11 +371,11 @@
if (!(credential instanceof PublicKeyCredential)) { if (!(credential instanceof PublicKeyCredential)) {
throw new Error("No security key was selected."); throw new Error("No security key was selected.");
} }
window.postMessage({ postResult({
command: "webAuthnResult", command: "webAuthnResult",
data: credentialToDataString(credential), data: credentialToDataString(credential),
remember: rememberEl.checked, remember: rememberEl.checked,
}, "*"); });
sentSuccess = true; sentSuccess = true;
showMessage("success", text.success); showMessage("success", text.success);
} catch (error) { } catch (error) {
+9 -7
View File
@@ -94,6 +94,14 @@ export function loadSession(): SessionState | null {
const raw = localStorage.getItem(SESSION_KEY); const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>; const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>;
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) { if (parsed.authMode === 'web-cookie' && parsed.email) {
return { return {
email: parsed.email, email: parsed.email,
@@ -106,13 +114,7 @@ export function loadSession(): SessionState | null {
authMode: 'token', authMode: 'token',
}; };
} }
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null; return null;
return {
accessToken: parsed.accessToken,
refreshToken: parsed.refreshToken,
email: parsed.email,
authMode: 'token',
};
} catch { } catch {
return null; return null;
} }
+24 -2
View File
@@ -19,6 +19,28 @@ const VAULT_CORE_STORE = 'vault-core';
let dbPromise: Promise<IDBDatabase | null> | null = null; let dbPromise: Promise<IDBDatabase | null> | null = null;
function stripDecryptedCacheFields<T>(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<string, unknown>;
const out: Record<string, unknown> = {};
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 { function supportsIndexedDb(): boolean {
return typeof indexedDB !== 'undefined'; return typeof indexedDB !== 'undefined';
} }
@@ -72,7 +94,7 @@ export async function loadCachedVaultCoreSnapshot(cacheKey: string): Promise<Vau
const request = store.get(normalized); const request = store.get(normalized);
request.onsuccess = () => { request.onsuccess = () => {
const record = request.result as VaultCoreCacheRecord | undefined; const record = request.result as VaultCoreCacheRecord | undefined;
resolve(record || null); resolve(record ? { ...record, snapshot: sanitizeSnapshotForCache(record.snapshot) } : null);
}; };
request.onerror = () => resolve(null); request.onerror = () => resolve(null);
})); }));
@@ -90,7 +112,7 @@ export async function saveCachedVaultCoreSnapshot(
cacheKey: normalized, cacheKey: normalized,
revisionStamp, revisionStamp,
savedAt: Date.now(), savedAt: Date.now(),
snapshot, snapshot: sanitizeSnapshotForCache(snapshot),
}; };
const request = store.put(record); const request = store.put(record);
request.onsuccess = () => resolve(); request.onsuccess = () => resolve();