feat: enhance rate limiting with new public request budgets and client IP validation

This commit is contained in:
shuaiplus
2026-03-05 02:26:05 +08:00
parent c39654ab3c
commit 49af3e7099
6 changed files with 345 additions and 36 deletions
+12
View File
@@ -38,6 +38,18 @@
// Public (unauthenticated) request budget per IP per minute. // Public (unauthenticated) request budget per IP per minute.
// 公开(未认证)接口每 IP 每分钟请求配额。 // 公开(未认证)接口每 IP 每分钟请求配额。
publicRequestsPerMinute: 60, publicRequestsPerMinute: 60,
// Public read-only request budget per IP per minute.
// 公开只读接口每 IP 每分钟请求配额。
publicReadRequestsPerMinute: 120,
// Sensitive public/auth request budget per IP per minute.
// 敏感公开/认证接口每 IP 每分钟请求配额。
sensitivePublicRequestsPerMinute: 30,
// Register endpoint budget per IP per minute.
// 注册接口每 IP 每分钟请求配额。
registerRequestsPerMinute: 5,
// Refresh-token grant budget per IP per minute.
// refresh_token 授权每 IP 每分钟请求配额。
refreshTokenRequestsPerMinute: 30,
// Fixed window size for API rate limiting in seconds. // Fixed window size for API rate limiting in seconds.
// API 限流固定窗口大小(秒)。 // API 限流固定窗口大小(秒)。
apiWindowSeconds: 60, apiWindowSeconds: 60,
+5 -1
View File
@@ -527,7 +527,11 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
const email = String(body.email || body.username || '').trim().toLowerCase(); const email = String(body.email || body.username || '').trim().toLowerCase();
const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim(); const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim();
const recoveryCode = normalizeRecoveryCodeInput(String(body.recoveryCode || body.twoFactorToken || body.recovery_code || '')); const recoveryCode = normalizeRecoveryCodeInput(String(body.recoveryCode || body.twoFactorToken || body.recovery_code || ''));
const recoverLimitKey = `${getClientIdentifier(request)}:recover-2fa:${email || 'unknown'}`; const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
const recoverLimitKey = `${clientIdentifier}:recover-2fa:${email || 'unknown'}`;
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey); const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
if (!recoverAttemptCheck.allowed) { if (!recoverAttemptCheck.allowed) {
+23 -1
View File
@@ -107,6 +107,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const grantType = body.grant_type; const grantType = body.grant_type;
const clientIdentifier = getClientIdentifier(request); const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return identityErrorResponse('Client IP is required', 'invalid_request', 403);
}
if (grantType === 'password') { if (grantType === 'password') {
// Login with password // Login with password
@@ -297,7 +300,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
).trim() || null; ).trim() || null;
const password = String(body.password || '').trim() || null; const password = String(body.password || '').trim() || null;
const result = await issueSendAccessToken(env, sendId, passwordHashB64, password); const result = await issueSendAccessToken(
env,
sendId,
passwordHashB64,
password,
rateLimit,
`${clientIdentifier}:send-password`
);
if ('error' in result) { if ('error' in result) {
return result.error; return result.error;
} }
@@ -310,6 +320,18 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
unofficialServer: true, unofficialServer: true,
}); });
} else if (grantType === 'refresh_token') { } else if (grantType === 'refresh_token') {
const refreshLimit = await rateLimit.consumeBudget(
`${clientIdentifier}:identity-refresh`,
LIMITS.rateLimit.refreshTokenRequestsPerMinute
);
if (!refreshLimit.allowed) {
return identityErrorResponse(
`Rate limit exceeded. Try again in ${refreshLimit.retryAfterSeconds} seconds.`,
'TooManyRequests',
429
);
}
// Refresh token // Refresh token
const refreshToken = body.refresh_token; const refreshToken = body.refresh_token;
if (!refreshToken) { if (!refreshToken) {
+124 -15
View File
@@ -1,5 +1,6 @@
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types'; import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { parsePagination, encodeContinuationToken } from '../utils/pagination'; import { parsePagination, encodeContinuationToken } from '../utils/pagination';
@@ -13,6 +14,7 @@ import {
const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer available'; const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer available';
const SEND_PASSWORD_ITERATIONS = 100_000; const SEND_PASSWORD_ITERATIONS = 100_000;
const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } { function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
if (!source || typeof source !== 'object') return { present: false, value: undefined }; if (!source || typeof source !== 'object') return { present: false, value: undefined };
@@ -383,12 +385,44 @@ async function getCreatorIdentifier(storage: StorageService, send: Send): Promis
return owner?.email ?? null; return owner?.email ?? null;
} }
async function validatePublicSendAccess(send: Send, body: unknown): Promise<Response | null> { type PublicSendAccessValidationResult =
if (hasEmailAuth(send)) { | { ok: true }
return errorResponse(SEND_INACCESSIBLE_MSG, 404); | { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
function sendPasswordLimitKey(clientIdentifier: string): string {
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`;
} }
if (!send.passwordHash) return null; function sendPasswordLockMessage(retryAfterSeconds: number): string {
return `Too many failed send password attempts. Try again in ${Math.ceil(retryAfterSeconds / 60)} minutes.`;
}
function sendPasswordLockedErrorResponse(retryAfterSeconds: number): Response {
return errorResponse(sendPasswordLockMessage(retryAfterSeconds), 429);
}
function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Response {
const message = sendPasswordLockMessage(retryAfterSeconds);
return jsonResponse(
{
error: 'invalid_grant',
error_description: message,
send_access_error_type: 'too_many_password_attempts',
ErrorModel: {
Message: message,
Object: 'error',
},
},
429
);
}
async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> {
if (hasEmailAuth(send)) {
return { ok: false, response: errorResponse(SEND_INACCESSIBLE_MSG, 404), reason: 'email_auth_unsupported' };
}
if (!send.passwordHash) return { ok: true };
const passwordRaw = getAliasedProp(body, ['password', 'Password']); const passwordRaw = getAliasedProp(body, ['password', 'Password']);
const passwordHashB64Raw = getAliasedProp(body, [ const passwordHashB64Raw = getAliasedProp(body, [
@@ -401,7 +435,7 @@ async function validatePublicSendAccess(send: Send, body: unknown): Promise<Resp
let validPassword = false; let validPassword = false;
if (send.passwordSalt && send.passwordIterations) { if (send.passwordSalt && send.passwordIterations) {
if (typeof passwordRaw.value !== 'string') { if (typeof passwordRaw.value !== 'string') {
return errorResponse('Password not provided', 401); return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' };
} }
validPassword = await verifySendPassword(send, passwordRaw.value); validPassword = await verifySendPassword(send, passwordRaw.value);
} else { } else {
@@ -411,12 +445,14 @@ async function validatePublicSendAccess(send: Send, body: unknown): Promise<Resp
: typeof passwordRaw.value === 'string' : typeof passwordRaw.value === 'string'
? passwordRaw.value ? passwordRaw.value
: ''; : '';
if (!candidate) return errorResponse('Password not provided', 401); if (!candidate) return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' };
validPassword = verifySendPasswordHashB64(send, candidate); validPassword = verifySendPasswordHashB64(send, candidate);
} }
if (!validPassword) return errorResponse('Invalid password', 400); if (!validPassword) {
return { ok: false, response: errorResponse('Invalid password', 400), reason: 'invalid_password' };
}
return null; return { ok: true };
} }
// GET /api/sends // GET /api/sends
@@ -1016,9 +1052,34 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
body = {}; body = {};
} }
const validationErr = await validatePublicSendAccess(send, body); let sendPasswordLimitIpKey: string | null = null;
if (validationErr) { let sendPasswordRateLimit: RateLimitService | null = null;
return validationErr; if (send.passwordHash) {
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
sendPasswordRateLimit = new RateLimitService(env.DB);
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) {
return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60);
}
}
const validation = await validatePublicSendAccess(send, body);
if (!validation.ok) {
if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) {
const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey);
if (failed.locked) {
return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60);
}
}
return validation.response;
}
if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) {
await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
} }
if (send.type === SendType.Text) { if (send.type === SendType.Text) {
@@ -1065,9 +1126,34 @@ export async function handleAccessSendFile(
body = {}; body = {};
} }
const validationErr = await validatePublicSendAccess(send, body); let sendPasswordLimitIpKey: string | null = null;
if (validationErr) { let sendPasswordRateLimit: RateLimitService | null = null;
return validationErr; if (send.passwordHash) {
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) {
return errorResponse('Client IP is required', 403);
}
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
sendPasswordRateLimit = new RateLimitService(env.DB);
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) {
return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60);
}
}
const validation = await validatePublicSendAccess(send, body);
if (!validation.ok) {
if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) {
const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey);
if (failed.locked) {
return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60);
}
}
return validation.response;
}
if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) {
await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
} }
const updated = await storage.incrementSendAccessCount(send.id); const updated = await storage.incrementSendAccessCount(send.id);
@@ -1221,7 +1307,9 @@ export async function issueSendAccessToken(
env: Env, env: Env,
sendIdOrAccessId: string, sendIdOrAccessId: string,
passwordHashB64?: string | null, passwordHashB64?: string | null,
password?: string | null password?: string | null,
rateLimit?: RateLimitService,
sendPasswordLimitIpKey?: string
): Promise<{ token: string } | { error: Response }> { ): Promise<{ token: string } | { error: Response }> {
const jwt = getSafeJwtSecret(env); const jwt = getSafeJwtSecret(env);
if (!jwt.ok) { if (!jwt.ok) {
@@ -1267,6 +1355,15 @@ export async function issueSendAccessToken(
} }
if (send.passwordHash) { if (send.passwordHash) {
if (rateLimit && sendPasswordLimitIpKey) {
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) {
return {
error: sendPasswordLockedOAuthResponse(sendPasswordCheck.retryAfterSeconds || 60),
};
}
}
let ok = false; let ok = false;
if (passwordHashB64) { if (passwordHashB64) {
ok = verifySendPasswordHashB64(send, passwordHashB64); ok = verifySendPasswordHashB64(send, passwordHashB64);
@@ -1275,6 +1372,14 @@ export async function issueSendAccessToken(
} }
if (!ok) { if (!ok) {
if (rateLimit && sendPasswordLimitIpKey) {
const failed = await rateLimit.recordFailedLogin(sendPasswordLimitIpKey);
if (failed.locked) {
return {
error: sendPasswordLockedOAuthResponse(failed.retryAfterSeconds || 60),
};
}
}
return { return {
error: jsonResponse( error: jsonResponse(
{ {
@@ -1290,6 +1395,10 @@ export async function issueSendAccessToken(
), ),
}; };
} }
if (rateLimit && sendPasswordLimitIpKey) {
await rateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
}
} }
const token = await createSendAccessToken(send.id, jwt.secret); const token = await createSendAccessToken(send.id, jwt.secret);
+39 -13
View File
@@ -215,9 +215,23 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
const method = request.method; const method = request.method;
const clientId = getClientIdentifier(request); const clientId = getClientIdentifier(request);
async function enforcePublicRateLimit(): Promise<Response | null> { async function enforcePublicRateLimit(
category: string = 'public',
maxRequests: number = LIMITS.rateLimit.publicRequestsPerMinute
): Promise<Response | null> {
if (!clientId) {
return new Response(JSON.stringify({
error: 'Forbidden',
error_description: 'Client IP is required',
}), {
status: 403,
headers: {
'Content-Type': 'application/json',
},
});
}
const rateLimit = new RateLimitService(env.DB); const rateLimit = new RateLimitService(env.DB);
const check = await rateLimit.consumeBudget(`${clientId}:public`, LIMITS.rateLimit.publicRequestsPerMinute); const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
if (check.allowed) return null; if (check.allowed) return null;
return new Response(JSON.stringify({ return new Response(JSON.stringify({
error: 'Too many requests', error: 'Too many requests',
@@ -254,11 +268,15 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Setup status // Setup status
if (path === '/setup/status' && method === 'GET') { if (path === '/setup/status' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return handleSetupStatus(request, env); return handleSetupStatus(request, env);
} }
// Web runtime config for static client bootstrap // Web runtime config for static client bootstrap
if (path === '/api/web/config' && method === 'GET') { if (path === '/api/web/config' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
const jwtUnsafeReason = jwtSecretUnsafeReason(env); const jwtUnsafeReason = jwtSecretUnsafeReason(env);
return jsonResponse({ return jsonResponse({
defaultKdfIterations: LIMITS.auth.defaultKdfIterations, defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
@@ -338,30 +356,27 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handleDownloadSendFile(request, env, sendId, fileId); return handleDownloadSendFile(request, env, sendId, fileId);
} }
// Notifications hub (stub - no auth required, return 200 for connection) // Identity endpoints (no auth required)
if (path.startsWith('/notifications/')) { if (path === '/identity/connect/token' && method === 'POST') {
const blocked = await enforcePublicRateLimit(); return handleToken(request, env);
if (blocked) return blocked;
return new Response(null, { status: 200 });
} }
// Known device check (no auth required) // Known device check (no auth required).
if (path === '/api/devices/knowndevice' && method === 'GET') { if (path === '/api/devices/knowndevice' && method === 'GET') {
const blocked = await enforcePublicRateLimit(); const blocked = await enforcePublicRateLimit();
if (blocked) return jsonResponse(false); if (blocked) return jsonResponse(false);
return handleKnownDevice(request, env); return handleKnownDevice(request, env);
} }
// Identity endpoints (no auth required)
if (path === '/identity/connect/token' && method === 'POST') {
return handleToken(request, env);
}
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') { if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleRevocation(request, env); return handleRevocation(request, env);
} }
if (path === '/identity/accounts/prelogin' && method === 'POST') { if (path === '/identity/accounts/prelogin' && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handlePrelogin(request, env); return handlePrelogin(request, env);
} }
@@ -374,6 +389,8 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// They also tolerate different casing, but their response models use PascalCase. // They also tolerate different casing, but their response models use PascalCase.
const isConfigRequest = (path === '/config' || path === '/api/config') && method === 'GET'; const isConfigRequest = (path === '/config' || path === '/api/config') && method === 'GET';
if (isConfigRequest) { if (isConfigRequest) {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
const origin = url.origin; const origin = url.origin;
return jsonResponse({ return jsonResponse({
// ── Version Strategy (Plan E) ────────────────────────────────────── // ── Version Strategy (Plan E) ──────────────────────────────────────
@@ -414,6 +431,8 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// Version endpoint (some clients probe this to validate the server) // Version endpoint (some clients probe this to validate the server)
if (path === '/api/version' && method === 'GET') { if (path === '/api/version' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); // Always same value as /config.version return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); // Always same value as /config.version
} }
@@ -421,6 +440,8 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
// - first user can self-register and becomes admin // - first user can self-register and becomes admin
// - later registrations require inviteCode in request body // - later registrations require inviteCode in request body
if (path === '/api/accounts/register' && method === 'POST') { if (path === '/api/accounts/register' && method === 'POST') {
const blocked = await enforcePublicRateLimit('register', LIMITS.rateLimit.registerRequestsPerMinute);
if (blocked) return blocked;
if (!isSameOriginWriteRequest(request)) { if (!isSameOriginWriteRequest(request)) {
return errorResponse('Forbidden origin', 403); return errorResponse('Forbidden origin', 403);
} }
@@ -525,6 +546,11 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handleSync(request, env, userId); return handleSync(request, env, userId);
} }
// Notifications hub (stub): now requires authentication.
if (path.startsWith('/notifications/')) {
return new Response(null, { status: 200 });
}
// Cipher endpoints // Cipher endpoints
if (path === '/api/ciphers' || path === '/api/ciphers/create') { if (path === '/api/ciphers' || path === '/api/ciphers/create') {
if (method === 'GET') return handleGetCiphers(request, env, userId); if (method === 'GET') return handleGetCiphers(request, env, userId);
+141 -5
View File
@@ -186,12 +186,148 @@ export class RateLimitService {
} }
} }
export function getClientIdentifier(request: Request): string { function parseIpv4Octets(input: string): number[] | null {
const parts = input.split('.');
if (parts.length !== 4) return null;
const octets: number[] = [];
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return null;
const value = Number(part);
if (!Number.isInteger(value) || value < 0 || value > 255) return null;
octets.push(value);
}
return octets;
}
function parseIpv6Hextets(input: string): number[] | null {
let value = input.trim().toLowerCase();
if (!value) return null;
if (value.startsWith('[') && value.endsWith(']')) {
value = value.slice(1, -1);
}
const zoneIndex = value.indexOf('%');
if (zoneIndex >= 0) {
value = value.slice(0, zoneIndex);
}
if (!value.includes(':')) return null;
// Handle IPv4-mapped tail (e.g. ::ffff:192.0.2.1).
if (value.includes('.')) {
const lastColon = value.lastIndexOf(':');
if (lastColon < 0) return null;
const ipv4Tail = value.slice(lastColon + 1);
const octets = parseIpv4Octets(ipv4Tail);
if (!octets) return null;
const high = ((octets[0] << 8) | octets[1]).toString(16);
const low = ((octets[2] << 8) | octets[3]).toString(16);
value = `${value.slice(0, lastColon)}:${high}:${low}`;
}
const doubleColon = value.indexOf('::');
if (doubleColon !== value.lastIndexOf('::')) return null;
const parsePart = (part: string): number | null => {
if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
const n = parseInt(part, 16);
return Number.isNaN(n) ? null : n;
};
const parseParts = (parts: string[]): number[] | null => {
const out: number[] = [];
for (const p of parts) {
if (!p) return null;
const n = parsePart(p);
if (n === null) return null;
out.push(n);
}
return out;
};
if (doubleColon >= 0) {
const [headRaw, tailRaw] = value.split('::');
const head = headRaw ? headRaw.split(':') : [];
const tail = tailRaw ? tailRaw.split(':') : [];
const headNums = parseParts(head);
const tailNums = parseParts(tail);
if (!headNums || !tailNums) return null;
const missing = 8 - (headNums.length + tailNums.length);
if (missing < 1) return null;
return [...headNums, ...new Array<number>(missing).fill(0), ...tailNums];
}
const all = parseParts(value.split(':'));
if (!all || all.length !== 8) return null;
return all;
}
function normalizeClientIpForRateLimit(rawIp: string): string | null {
const input = rawIp.trim();
if (!input) return null;
const ipv4 = parseIpv4Octets(input);
if (ipv4) {
return `ip4:${ipv4.join('.')}`;
}
const ipv6 = parseIpv6Hextets(input);
if (!ipv6) return null;
// Handle IPv4-mapped / IPv4-compatible IPv6 as IPv4 identity.
// Examples: ::ffff:192.0.2.1, ::192.0.2.1
if (
ipv6[0] === 0 &&
ipv6[1] === 0 &&
ipv6[2] === 0 &&
ipv6[3] === 0 &&
ipv6[4] === 0 &&
(ipv6[5] === 0xffff || ipv6[5] === 0)
) {
const octets = [ipv6[6] >> 8, ipv6[6] & 0xff, ipv6[7] >> 8, ipv6[7] & 0xff];
return `ip4:${octets.join('.')}`;
}
// Collapse to /64 to reduce brute-force bypass via IPv6 address rotation.
const prefix64 = ipv6
.slice(0, 4)
.map(part => part.toString(16).padStart(4, '0'))
.join(':');
return `ip6:${prefix64}`;
}
export function getClientIdentifier(request: Request): string | null {
const cfIp = request.headers.get('CF-Connecting-IP'); const cfIp = request.headers.get('CF-Connecting-IP');
if (cfIp) return cfIp; if (cfIp) {
return normalizeClientIpForRateLimit(cfIp);
}
// Local development fallback:
// wrangler dev may not provide CF-Connecting-IP. Allow localhost requests
// to resolve an identifier from X-Forwarded-For or loopback.
try {
const hostname = new URL(request.url).hostname.toLowerCase();
const isLocalHost =
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '[::1]';
if (!isLocalHost) {
return null;
}
const forwardedFor = request.headers.get('X-Forwarded-For'); const forwardedFor = request.headers.get('X-Forwarded-For');
if (forwardedFor) return forwardedFor.split(',')[0].trim(); if (forwardedFor) {
const first = forwardedFor.split(',')[0].trim();
return 'unknown'; const normalized = normalizeClientIpForRateLimit(first);
if (normalized) return normalized;
}
return 'ip4:127.0.0.1';
} catch {
return null;
}
} }