mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: enhance rate limiting with new public request budgets and client IP validation
This commit is contained in:
@@ -38,6 +38,18 @@
|
||||
// Public (unauthenticated) request budget per IP per minute.
|
||||
// 公开(未认证)接口每 IP 每分钟请求配额。
|
||||
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.
|
||||
// API 限流固定窗口大小(秒)。
|
||||
apiWindowSeconds: 60,
|
||||
|
||||
@@ -527,7 +527,11 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
|
||||
const email = String(body.email || body.username || '').trim().toLowerCase();
|
||||
const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim();
|
||||
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);
|
||||
if (!recoverAttemptCheck.allowed) {
|
||||
|
||||
@@ -107,6 +107,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
|
||||
const grantType = body.grant_type;
|
||||
const clientIdentifier = getClientIdentifier(request);
|
||||
if (!clientIdentifier) {
|
||||
return identityErrorResponse('Client IP is required', 'invalid_request', 403);
|
||||
}
|
||||
|
||||
if (grantType === 'password') {
|
||||
// Login with password
|
||||
@@ -297,7 +300,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
).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) {
|
||||
return result.error;
|
||||
}
|
||||
@@ -310,6 +320,18 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
unofficialServer: true,
|
||||
});
|
||||
} 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
|
||||
const refreshToken = body.refresh_token;
|
||||
if (!refreshToken) {
|
||||
|
||||
+123
-14
@@ -1,5 +1,6 @@
|
||||
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
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_PASSWORD_ITERATIONS = 100_000;
|
||||
const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
|
||||
|
||||
function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
|
||||
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;
|
||||
}
|
||||
|
||||
async function validatePublicSendAccess(send: Send, body: unknown): Promise<Response | null> {
|
||||
type PublicSendAccessValidationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
|
||||
|
||||
function sendPasswordLimitKey(clientIdentifier: string): string {
|
||||
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`;
|
||||
}
|
||||
|
||||
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 errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||
return { ok: false, response: errorResponse(SEND_INACCESSIBLE_MSG, 404), reason: 'email_auth_unsupported' };
|
||||
}
|
||||
|
||||
if (!send.passwordHash) return null;
|
||||
if (!send.passwordHash) return { ok: true };
|
||||
|
||||
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||
const passwordHashB64Raw = getAliasedProp(body, [
|
||||
@@ -401,7 +435,7 @@ async function validatePublicSendAccess(send: Send, body: unknown): Promise<Resp
|
||||
let validPassword = false;
|
||||
if (send.passwordSalt && send.passwordIterations) {
|
||||
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);
|
||||
} else {
|
||||
@@ -411,12 +445,14 @@ async function validatePublicSendAccess(send: Send, body: unknown): Promise<Resp
|
||||
: typeof passwordRaw.value === 'string'
|
||||
? 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);
|
||||
}
|
||||
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
|
||||
@@ -1016,9 +1052,34 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
|
||||
body = {};
|
||||
}
|
||||
|
||||
const validationErr = await validatePublicSendAccess(send, body);
|
||||
if (validationErr) {
|
||||
return validationErr;
|
||||
let sendPasswordLimitIpKey: string | null = null;
|
||||
let sendPasswordRateLimit: RateLimitService | null = null;
|
||||
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) {
|
||||
@@ -1065,9 +1126,34 @@ export async function handleAccessSendFile(
|
||||
body = {};
|
||||
}
|
||||
|
||||
const validationErr = await validatePublicSendAccess(send, body);
|
||||
if (validationErr) {
|
||||
return validationErr;
|
||||
let sendPasswordLimitIpKey: string | null = null;
|
||||
let sendPasswordRateLimit: RateLimitService | null = null;
|
||||
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);
|
||||
@@ -1221,7 +1307,9 @@ export async function issueSendAccessToken(
|
||||
env: Env,
|
||||
sendIdOrAccessId: string,
|
||||
passwordHashB64?: string | null,
|
||||
password?: string | null
|
||||
password?: string | null,
|
||||
rateLimit?: RateLimitService,
|
||||
sendPasswordLimitIpKey?: string
|
||||
): Promise<{ token: string } | { error: Response }> {
|
||||
const jwt = getSafeJwtSecret(env);
|
||||
if (!jwt.ok) {
|
||||
@@ -1267,6 +1355,15 @@ export async function issueSendAccessToken(
|
||||
}
|
||||
|
||||
if (send.passwordHash) {
|
||||
if (rateLimit && sendPasswordLimitIpKey) {
|
||||
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||
if (!sendPasswordCheck.allowed) {
|
||||
return {
|
||||
error: sendPasswordLockedOAuthResponse(sendPasswordCheck.retryAfterSeconds || 60),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let ok = false;
|
||||
if (passwordHashB64) {
|
||||
ok = verifySendPasswordHashB64(send, passwordHashB64);
|
||||
@@ -1275,6 +1372,14 @@ export async function issueSendAccessToken(
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
if (rateLimit && sendPasswordLimitIpKey) {
|
||||
const failed = await rateLimit.recordFailedLogin(sendPasswordLimitIpKey);
|
||||
if (failed.locked) {
|
||||
return {
|
||||
error: sendPasswordLockedOAuthResponse(failed.retryAfterSeconds || 60),
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
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);
|
||||
|
||||
+39
-13
@@ -215,9 +215,23 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
const method = request.method;
|
||||
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 check = await rateLimit.consumeBudget(`${clientId}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
||||
const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
|
||||
if (check.allowed) return null;
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
@@ -254,11 +268,15 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
|
||||
// Setup status
|
||||
if (path === '/setup/status' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleSetupStatus(request, env);
|
||||
}
|
||||
|
||||
// Web runtime config for static client bootstrap
|
||||
if (path === '/api/web/config' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
const jwtUnsafeReason = jwtSecretUnsafeReason(env);
|
||||
return jsonResponse({
|
||||
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
||||
@@ -338,30 +356,27 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
return handleDownloadSendFile(request, env, sendId, fileId);
|
||||
}
|
||||
|
||||
// Notifications hub (stub - no auth required, return 200 for connection)
|
||||
if (path.startsWith('/notifications/')) {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return blocked;
|
||||
return new Response(null, { status: 200 });
|
||||
// Identity endpoints (no auth required)
|
||||
if (path === '/identity/connect/token' && method === 'POST') {
|
||||
return handleToken(request, env);
|
||||
}
|
||||
|
||||
// Known device check (no auth required)
|
||||
// Known device check (no auth required).
|
||||
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||
const blocked = await enforcePublicRateLimit();
|
||||
if (blocked) return jsonResponse(false);
|
||||
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') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
return handleRevocation(request, env);
|
||||
}
|
||||
|
||||
if (path === '/identity/accounts/prelogin' && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
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.
|
||||
const isConfigRequest = (path === '/config' || path === '/api/config') && method === 'GET';
|
||||
if (isConfigRequest) {
|
||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
const origin = url.origin;
|
||||
return jsonResponse({
|
||||
// ── 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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -421,6 +440,8 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
// - first user can self-register and becomes admin
|
||||
// - later registrations require inviteCode in request body
|
||||
if (path === '/api/accounts/register' && method === 'POST') {
|
||||
const blocked = await enforcePublicRateLimit('register', LIMITS.rateLimit.registerRequestsPerMinute);
|
||||
if (blocked) return blocked;
|
||||
if (!isSameOriginWriteRequest(request)) {
|
||||
return errorResponse('Forbidden origin', 403);
|
||||
}
|
||||
@@ -525,6 +546,11 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
return handleSync(request, env, userId);
|
||||
}
|
||||
|
||||
// Notifications hub (stub): now requires authentication.
|
||||
if (path.startsWith('/notifications/')) {
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
// Cipher endpoints
|
||||
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
|
||||
if (method === 'GET') return handleGetCiphers(request, env, userId);
|
||||
|
||||
+140
-4
@@ -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');
|
||||
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');
|
||||
if (forwardedFor) return forwardedFor.split(',')[0].trim();
|
||||
if (forwardedFor) {
|
||||
const first = forwardedFor.split(',')[0].trim();
|
||||
const normalized = normalizeClientIpForRateLimit(first);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
return 'ip4:127.0.0.1';
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user