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
+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 {
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();
+4
View File
@@ -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,
+28
View File
@@ -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<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 {
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);
}