Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 50 additions & 0 deletions src/config/rateLimits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { RateLimitConfig } from '../types/rateLimit.js';

export const DEFAULT_IP_CONFIG: RateLimitConfig = {
windowMs: 60_000,
max: 100,
enabled: true,
};

export const DEFAULT_APIKEY_CONFIG: RateLimitConfig = {
windowMs: 60_000,
max: 500,
enabled: true,
};

export const DEFAULT_ADMIN_CONFIG: RateLimitConfig = {
windowMs: 60_000,
max: 2000,
enabled: true,
};

export function getRateLimitConfig(env: Record<string, string | undefined>): {
ip: RateLimitConfig;
apiKey: RateLimitConfig;
admin: RateLimitConfig;
trustProxy: boolean;
} {
const enabled = env.RATE_LIMIT_ENABLED !== 'false';

const ip: RateLimitConfig = {
windowMs: parseInt(env.RATE_LIMIT_IP_WINDOW_MS ?? '', 10) || DEFAULT_IP_CONFIG.windowMs,
max: parseInt(env.RATE_LIMIT_IP_MAX ?? '', 10) || DEFAULT_IP_CONFIG.max,
enabled,
};

const apiKey: RateLimitConfig = {
windowMs: parseInt(env.RATE_LIMIT_APIKEY_WINDOW_MS ?? '', 10) || DEFAULT_APIKEY_CONFIG.windowMs,
max: parseInt(env.RATE_LIMIT_APIKEY_MAX ?? '', 10) || DEFAULT_APIKEY_CONFIG.max,
enabled,
};

const admin: RateLimitConfig = {
windowMs: parseInt(env.RATE_LIMIT_ADMIN_WINDOW_MS ?? '', 10) || DEFAULT_ADMIN_CONFIG.windowMs,
max: parseInt(env.RATE_LIMIT_ADMIN_MAX ?? '', 10) || DEFAULT_ADMIN_CONFIG.max,
enabled,
};

const trustProxy = env.RATE_LIMIT_TRUST_PROXY !== 'false';

return { ip, apiKey, admin, trustProxy };
}
187 changes: 187 additions & 0 deletions src/middleware/rateLimiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import type { Request, Response, NextFunction } from 'express';
import type { RateLimitConfig, RateLimitStatus } from '../types/rateLimit.js';
import { getRateLimitConfig } from '../config/rateLimits.js';

interface ClientState {
identifier: string;
identifierType: 'ip' | 'apiKey';
config: RateLimitConfig;
resetAt: number;
count: number;
}

const EXEMPT_PATHS = new Set(['/', '/health', '/api/rate-limits']);

function buildErrorBody(
identifier: string,
identifierType: string,
limit: number,
windowMs: number,
retryAfterSeconds: number
) {
return {
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: `Too many requests. Retry after ${retryAfterSeconds} seconds.`,
retryAfter: retryAfterSeconds,
limit,
window: windowMs === 60_000 ? 'minute' : 'unknown',
identifier: identifierType === 'ip' ? identifier : maskApiKey(identifier),
},
};
}

function maskApiKey(key: string): string {
if (key.length <= 8) return `${key.slice(0, 2)}...${key.slice(-2)}`;
return `${key.slice(0, 4)}...${key.slice(-4)}`;
}

function getCurrentReset(windowMs: number): number {
return Date.now() + windowMs;
}

function getRemainingRequests(count: number, max: number): number {
return Math.max(0, max - count);
}

function secondsUntil(resetAt: number): number {
return Math.max(0, Math.ceil((resetAt - Date.now()) / 1000));
}

export function extractClientIdentifier(req: Request): {
identifier: string;
identifierType: 'ip' | 'apiKey';
} {
const apiKey = req.headers['x-api-key'];
if (typeof apiKey === 'string' && apiKey.length > 0) {
return { identifier: apiKey, identifierType: 'apiKey' };
}
const ip = (req as Request & { ip?: string }).ip ?? req.socket.remoteAddress ?? 'unknown';
return { identifier: ip, identifierType: 'ip' };
}

export interface RateLimiter {
(req: Request, res: Response, next: NextFunction): void;
getStatus(identifier: string, identifierType: 'ip' | 'apiKey'): RateLimitStatus;
extractClientIdentifier(req: Request): { identifier: string; identifierType: 'ip' | 'apiKey' };
}

export function createRateLimiter(
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>
): RateLimiter {
const { ip: ipConfig, apiKey: apiKeyConfig, admin: adminConfig } = getRateLimitConfig(env);

const adminKeys = new Set<string>();
const adminKeyEnv = env.ADMIN_API_KEY ?? '';
if (adminKeyEnv) {
for (const k of adminKeyEnv.split(',').map((s) => s.trim())) {
if (k) adminKeys.add(k);
}
}

const ipCounters = new Map<string, { count: number; resetAt: number }>();
const apiKeyCounters = new Map<string, { count: number; resetAt: number }>();

function getOrInitCounter(
map: Map<string, { count: number; resetAt: number }>,
key: string,
windowMs: number
) {
const now = Date.now();
let entry = map.get(key);
if (!entry || now >= entry.resetAt) {
entry = { count: 0, resetAt: getCurrentReset(windowMs) };
map.set(key, entry);
}
return entry;
}

function clientState(identifier: string, identifierType: 'ip' | 'apiKey'): ClientState {
const isAdmin = identifierType === 'apiKey' && adminKeys.has(identifier);
const config = isAdmin ? adminConfig : identifierType === 'apiKey' ? apiKeyConfig : ipConfig;
const counters = identifierType === 'apiKey' ? apiKeyCounters : ipCounters;
const entry = getOrInitCounter(counters, identifier, config.windowMs);
return {
identifier,
identifierType,
config,
resetAt: entry.resetAt,
count: entry.count,
};
}

function rateLimitHandler(req: Request, res: Response, next: NextFunction): void {
if (!ipConfig.enabled && !apiKeyConfig.enabled) {
return next();
}

const path = req.path;
if (EXEMPT_PATHS.has(path)) {
return next();
}

const { identifier, identifierType } = extractClientIdentifier(req);
const state = clientState(identifier, identifierType);

if (!state.config.enabled) {
return next();
}

if (state.count >= state.config.max) {
const retryAfter = secondsUntil(state.resetAt);
res.setHeader('Retry-After', String(retryAfter));
res.setHeader('X-RateLimit-Limit', String(state.config.max));
res.setHeader('X-RateLimit-Remaining', '0');
res.setHeader('X-RateLimit-Reset', String(Math.ceil(state.resetAt / 1000)));
res.status(429).json(buildErrorBody(identifier, identifierType, state.config.max, state.config.windowMs, retryAfter));
return;
}

const entry = getOrInitCounter(
identifierType === 'apiKey' ? apiKeyCounters : ipCounters,
identifier,
state.config.windowMs
);
entry.count += 1;
entry.resetAt = state.resetAt;

res.setHeader('X-RateLimit-Limit', String(state.config.max));
res.setHeader('X-RateLimit-Remaining', String(getRemainingRequests(entry.count, state.config.max)));
res.setHeader('X-RateLimit-Reset', String(Math.ceil(state.resetAt / 1000)));

next();
}

function getStatus(identifier: string, identifierType: 'ip' | 'apiKey'): RateLimitStatus {
const isAdmin = identifierType === 'apiKey' && adminKeys.has(identifier);
const config = isAdmin ? adminConfig : identifierType === 'apiKey' ? apiKeyConfig : ipConfig;
const entry = getOrInitCounter(
identifierType === 'apiKey' ? apiKeyCounters : ipCounters,
identifier,
config.windowMs
);
return {
identifier: identifierType === 'ip' ? identifier : maskApiKey(identifier),
identifierType,
limit: config.max,
remaining: getRemainingRequests(entry.count, config.max),
resetsAt: new Date(entry.resetAt).toISOString(),
window: config.windowMs === 60_000 ? 'minute' : 'unknown',
};
}

rateLimitHandler.getStatus = getStatus;
rateLimitHandler.extractClientIdentifier = extractClientIdentifier;

return rateLimitHandler;
}

export function isAdminKey(
key: string,
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>
): boolean {
const adminKeyEnv = env.ADMIN_API_KEY ?? '';
if (!adminKeyEnv) return false;
const adminKeys = new Set(adminKeyEnv.split(',').map((s) => s.trim()).filter(Boolean));
return adminKeys.has(key);
}
20 changes: 20 additions & 0 deletions src/routes/rateLimits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import type { RateLimiter } from '../middleware/rateLimiter.js';

export function createRateLimitsRouter(limiter: RateLimiter) {
const rateLimitsRouter = Router();

rateLimitsRouter.get('/', (req: Request, res: Response) => {
const { identifier, identifierType } = limiter.extractClientIdentifier(req);
const status = limiter.getStatus(identifier, identifierType);

res.setHeader('X-RateLimit-Limit', String(status.limit));
res.setHeader('X-RateLimit-Remaining', String(status.remaining));
res.setHeader('X-RateLimit-Reset', String(Math.ceil(new Date(status.resetsAt).getTime() / 1000)));

res.json(status);
});

return rateLimitsRouter;
}
34 changes: 34 additions & 0 deletions src/types/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export interface RateLimitConfig {
windowMs: number;
max: number;
enabled: boolean;
}

export interface RateLimitStatus {
identifier: string;
identifierType: 'ip' | 'apiKey';
limit: number;
remaining: number;
resetsAt: string;
window: string;
}

export interface RateLimitErrorBody {
error: {
code: string;
message: string;
retryAfter: number;
limit: number;
window: string;
identifier: string;
};
}

export interface AdminKeySet {
adminKeys: Set<string>;
}

export interface RateLimitCounters {
ip: Map<string, { count: number; resetAt: number }>;
apiKey: Map<string, { count: number; resetAt: number }>;
}
Loading