diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index f66784d..5331551 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -122,6 +122,16 @@ function readBodyValue(body: Record, names: string[]): string | return undefined; } +async function sha256Hex(value: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +async function loginRateLimitKey(clientIdentifier: string, grantType: string, subject: string): Promise { + const subjectHash = await sha256Hex(`${grantType}:${String(subject || '').trim() || 'unknown'}`); + return `${clientIdentifier}:login:${grantType}:${subjectHash}`; +} + async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise { const fromEnv = yubicoCredentialsFromEnv(env); if (fromEnv) return fromEnv; @@ -343,13 +353,13 @@ export async function handleToken(request: Request, env: Env): Promise const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']); const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']); const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']); - const loginIdentifier = clientIdentifier; const deviceInfo = readAuthRequestDeviceInfo(body, request); if (!email || !passwordHash) { // Bitwarden clients expect OAuth-style error fields. return identityErrorResponse('Email and password are required', 'invalid_request', 400); } + const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, email); // Check login lockout before user lookup to reduce user-enumeration signal const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); @@ -608,7 +618,8 @@ export async function handleToken(request: Request, env: Env): Promise : baseResponse; } else if (grantType === 'webauthn') { - const loginIdentifier = clientIdentifier; + const token = String(body.token || '').trim(); + const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, token || 'missing-token'); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); if (!loginCheck.allowed) { return identityErrorResponse( @@ -618,7 +629,6 @@ export async function handleToken(request: Request, env: Env): Promise ); } - const token = String(body.token || '').trim(); let deviceResponse: unknown = body.deviceResponse; if (typeof deviceResponse === 'string') { try { @@ -736,11 +746,12 @@ export async function handleToken(request: Request, env: Env): Promise const scope = body.scope; const deviceInfo = readAuthRequestDeviceInfo(body, request); - const loginIdentifier = clientIdentifier; const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope); if (!parmValid) { return identityErrorResponse('Parameter error', 'invalid_request', 400); } + const uid = clientId.slice(5); + const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, uid); // Check login lockout before user lookup to reduce user-enumeration signal const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); @@ -752,7 +763,6 @@ export async function handleToken(request: Request, env: Env): Promise ); } - const uid = clientId.slice(5); const user = await storage.getUserById(uid); if (!user) { await rateLimit.recordFailedLogin(loginIdentifier); @@ -895,7 +905,7 @@ export async function handleToken(request: Request, env: Env): Promise passwordHashB64, password, rateLimit, - `${clientIdentifier}:send-password` + clientIdentifier ); if ('error' in result) { return result.error; diff --git a/src/handlers/sends-public.ts b/src/handlers/sends-public.ts index 0355ece..1129616 100644 --- a/src/handlers/sends-public.ts +++ b/src/handlers/sends-public.ts @@ -68,7 +68,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str if (!clientIdentifier) { return errorResponse('Client IP is required', 403); } - sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier); + sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id); sendPasswordRateLimit = new RateLimitService(env.DB); const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey); if (!sendPasswordCheck.allowed) { @@ -142,7 +142,7 @@ export async function handleAccessSendFile( if (!clientIdentifier) { return errorResponse('Client IP is required', 403); } - sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier); + sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id); sendPasswordRateLimit = new RateLimitService(env.DB); const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey); if (!sendPasswordCheck.allowed) { @@ -328,7 +328,7 @@ export async function issueSendAccessToken( passwordHashB64?: string | null, password?: string | null, rateLimit?: RateLimitService, - sendPasswordLimitIpKey?: string + clientIdentifier?: string ): Promise<{ token: string } | { error: Response }> { const jwt = getSafeJwtSecret(env); if (!jwt.ok) { @@ -373,6 +373,9 @@ export async function issueSendAccessToken( }; } + const sendPasswordLimitIpKey = + rateLimit && clientIdentifier ? sendPasswordLimitKey(clientIdentifier, send.id) : null; + if (send.passwordHash) { if (rateLimit && sendPasswordLimitIpKey) { const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey); diff --git a/src/handlers/sends-shared.ts b/src/handlers/sends-shared.ts index 82484e7..535220a 100644 --- a/src/handlers/sends-shared.ts +++ b/src/handlers/sends-shared.ts @@ -434,8 +434,8 @@ export type PublicSendAccessValidationResult = | { ok: true } | { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' }; -export function sendPasswordLimitKey(clientIdentifier: string): string { - return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`; +export function sendPasswordLimitKey(clientIdentifier: string, sendId: string): string { + return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}:${String(sendId || '').trim() || 'unknown-send'}`; } function sendPasswordLockMessage(retryAfterSeconds: number): string {