mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +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.
|
// 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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+123
-14
@@ -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 =
|
||||||
|
| { 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)) {
|
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 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
@@ -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);
|
||||||
|
|||||||
+143
-7
@@ -186,12 +186,148 @@ export class RateLimitService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getClientIdentifier(request: Request): string {
|
function parseIpv4Octets(input: string): number[] | null {
|
||||||
const cfIp = request.headers.get('CF-Connecting-IP');
|
const parts = input.split('.');
|
||||||
if (cfIp) return cfIp;
|
if (parts.length !== 4) return null;
|
||||||
|
|
||||||
const forwardedFor = request.headers.get('X-Forwarded-For');
|
const octets: number[] = [];
|
||||||
if (forwardedFor) return forwardedFor.split(',')[0].trim();
|
for (const part of parts) {
|
||||||
|
if (!/^\d{1,3}$/.test(part)) return null;
|
||||||
return 'unknown';
|
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 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) {
|
||||||
|
const first = forwardedFor.split(',')[0].trim();
|
||||||
|
const normalized = normalizeClientIpForRateLimit(first);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'ip4:127.0.0.1';
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user