fix rate limit reset bypasses

This commit is contained in:
shuaiplus
2026-07-06 14:24:01 +08:00
parent 00e0ec0892
commit ae168bea31
3 changed files with 24 additions and 11 deletions
+16 -6
View File
@@ -122,6 +122,16 @@ function readBodyValue(body: Record<string, string>, names: string[]): string |
return undefined;
}
async function sha256Hex(value: string): Promise<string> {
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<string> {
const subjectHash = await sha256Hex(`${grantType}:${String(subject || '').trim() || 'unknown'}`);
return `${clientIdentifier}:login:${grantType}:${subjectHash}`;
}
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
const fromEnv = yubicoCredentialsFromEnv(env);
if (fromEnv) return fromEnv;
@@ -343,13 +353,13 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
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<Response>
: 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<Response>
);
}
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<Response>
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<Response>
);
}
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<Response>
passwordHashB64,
password,
rateLimit,
`${clientIdentifier}:send-password`
clientIdentifier
);
if ('error' in result) {
return result.error;
+6 -3
View File
@@ -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);
+2 -2
View File
@@ -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 {