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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,17 @@ NODE_ENV=development
DATABASE_URL=postgresql://postgres:password@localhost:5432/orbitstream

# JWT
# REQUIRED in production. Must be a unique, high-entropy value of at least 32
# characters. The app refuses to start in production if JWT_SECRET is unset, still
# set to a placeholder ("dev-secret" / "change-me-in-production"), or shorter than
# 32 characters. Generate one with: openssl rand -base64 48
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN=7d
# Optional: previous secret used during zero-downtime rotation. Tokens signed with
# either JWT_SECRET or JWT_SECRET_PREVIOUS are accepted; tokens that only verify
# against the previous secret are re-issued with the current secret on /auth/refresh
# (or any login). Remove this once all old tokens have expired.
JWT_SECRET_PREVIOUS=

# Stellar
STELLAR_NETWORK=TESTNET
Expand Down
38 changes: 38 additions & 0 deletions load-tests/rate-limit.artillery.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Rate-limiting load test (optional).
#
# Run with the Artillery CLI without adding it as a project dependency:
# npx artillery@latest run load-tests/rate-limit.artillery.yml
#
# Goal: hammer a per-IP limited endpoint with ~100 concurrent virtual users and
# confirm the limiter holds — responses should be a mix of 200 (within limit) and
# 429 (Too Many Requests) rather than the server accepting everything.
#
# Note: Artillery is intentionally NOT a package.json dependency to keep the
# install lean; install it on demand via npx. See the PR notes — full load
# verification is left as a follow-up.

config:
target: 'http://localhost:3001'
phases:
- name: burst
duration: 30
arrivalRate: 100 # 100 new virtual users per second
http:
timeout: 10
# Surface the rate-limit headers in the report.
ensure:
maxErrorRate: 100

scenarios:
- name: 'checkout status reads (60/min per IP)'
flow:
- get:
url: '/v1/checkout/sessions/load-test-session-id'
# All requests share an IP so the per-IP sliding window trips quickly.
headers:
X-Forwarded-For: '198.51.100.10'
expect:
- statusCode:
- 200
- 404
- 429
61 changes: 61 additions & 0 deletions src/__tests__/rate-limit.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import express from 'express';
import request from 'supertest';
import RedisMock from 'ioredis-mock';
import { RateLimitMiddleware } from '../api/middleware/rate-limit.middleware';
import { RateLimitService } from '../api/middleware/rate-limit.service';
import type { RedisService } from '../redis/redis.service';

/**
* End-to-end style test: mount the real rate-limit middleware on an Express app
* (the same primitives Nest uses under the hood) and drive it with supertest to
* confirm a 429 is returned once the limit is exceeded, that the documented
* headers are present, and that exempt routes are never throttled.
*/
function buildApp() {
const client = new RedisMock();
const redisService = { getClient: () => client } as unknown as RedisService;
const service = new RateLimitService(redisService);
const limiter = new RateLimitMiddleware(service);
jest.spyOn((limiter as any).logger, 'warn').mockImplementation(() => undefined);

Check warning on line 19 in src/__tests__/rate-limit.integration.spec.ts

View workflow job for this annotation

GitHub Actions / lint-build-test (20)

Unexpected any. Specify a different type

const app = express();
app.set('trust proxy', 1);
app.use((req, res, next) => limiter.use(req, res, next));
app.get('/health', (_req, res) => res.json({ ok: true }));
app.post('/merchants/register', (_req, res) => res.json({ ok: true }));
app.get('/v1/checkout/sessions/:id', (_req, res) => res.json({ ok: true }));
return app;
}

describe('rate limiting (integration)', () => {
it('returns 429 with Retry-After when the per-IP limit is exceeded', async () => {
const app = buildApp();
const agent = request(app);
const ip = '203.0.113.7';

// /merchants/register limit is 3/min per IP.
for (let i = 0; i < 3; i++) {
const ok = await agent.post('/merchants/register').set('X-Forwarded-For', ip);
expect(ok.status).toBe(200);
expect(ok.headers['x-ratelimit-limit']).toBe('3');
}

const blocked = await agent.post('/merchants/register').set('X-Forwarded-For', ip);
expect(blocked.status).toBe(429);
expect(blocked.body).toMatchObject({ statusCode: 429, error: 'Too Many Requests' });
expect(blocked.headers['retry-after']).toBeDefined();
expect(Number(blocked.headers['retry-after'])).toBeGreaterThanOrEqual(1);
expect(blocked.headers['x-ratelimit-remaining']).toBe('0');
});

it('exempts /health from rate limiting', async () => {
const app = buildApp();
const agent = request(app);
// Well above the 60/min default limit — proves /health is never throttled.
for (let i = 0; i < 80; i++) {
const res = await agent.get('/health');
expect(res.status).toBe(200);
expect(res.headers['x-ratelimit-limit']).toBeUndefined();
}
}, 15000);
});
98 changes: 98 additions & 0 deletions src/api/middleware/rate-limit.config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { Request } from 'express';
import { isExempt, resolveRule, clientIp, apiKey, rateLimitIdentity } from './rate-limit.config';

describe('isExempt', () => {
it.each(['/health', '/metrics', '/health/db', '/metrics/'])('exempts %s', (path) => {
expect(isExempt(path)).toBe(true);
});

it.each(['/auth/login', '/v1/checkout/sessions', '/healthz', '/metricsfoo'])(
'does not exempt %s',
(path) => {
expect(isExempt(path)).toBe(false);
},
);
});

describe('resolveRule', () => {
it('limits /auth/login and /auth/verify to 5/min per IP', () => {
expect(resolveRule('POST', '/auth/login')).toMatchObject({ limit: 5, scope: 'ip' });
expect(resolveRule('POST', '/auth/verify')).toMatchObject({ limit: 5, scope: 'ip' });
});

it('limits /merchants/register to 3/min per IP', () => {
expect(resolveRule('POST', '/merchants/register')).toMatchObject({ limit: 3, scope: 'ip' });
});

it('limits POST /v1/checkout/sessions to 100/min per API key', () => {
expect(resolveRule('POST', '/v1/checkout/sessions')).toMatchObject({
limit: 100,
scope: 'apiKey',
});
});

it('limits GET /v1/checkout/sessions/:id to 60/min per IP', () => {
expect(resolveRule('GET', '/v1/checkout/sessions/abc')).toMatchObject({
limit: 60,
scope: 'ip',
});
});

it('falls back to 60/min per IP for everything else', () => {
expect(resolveRule('GET', '/merchants/me')).toMatchObject({ limit: 60, scope: 'ip' });
expect(resolveRule('POST', '/auth/challenge')).toMatchObject({ limit: 60, scope: 'ip' });
});
});

function req(headers: Record<string, string | string[]>, ip?: string): Request {
return { headers, ip, socket: { remoteAddress: ip } } as unknown as Request;
}

describe('clientIp', () => {
it('prefers the first X-Forwarded-For hop', () => {
expect(clientIp(req({ 'x-forwarded-for': '1.2.3.4, 5.6.7.8' }))).toBe('1.2.3.4');
});

it('falls back to req.ip', () => {
expect(clientIp(req({}, '9.9.9.9'))).toBe('9.9.9.9');
});
});

describe('apiKey', () => {
it('extracts the bearer token', () => {
expect(apiKey(req({ authorization: 'Bearer sk_test_abc' }))).toBe('sk_test_abc');
});

it('returns undefined without a bearer token', () => {
expect(apiKey(req({}))).toBeUndefined();
});
});

describe('rateLimitIdentity', () => {
it('keys api-key-scoped rules by the api key', () => {
const id = rateLimitIdentity(req({ authorization: 'Bearer sk_test_abc' }), {
name: 'checkout-create',
limit: 100,
scope: 'apiKey',
});
expect(id).toBe('key:sk_test_abc');
});

it('falls back to IP when an api-key-scoped rule has no key', () => {
const id = rateLimitIdentity(req({}, '1.1.1.1'), {
name: 'checkout-create',
limit: 100,
scope: 'apiKey',
});
expect(id).toBe('ip:1.1.1.1');
});

it('keys ip-scoped rules by IP', () => {
const id = rateLimitIdentity(req({ 'x-forwarded-for': '2.2.2.2' }), {
name: 'auth',
limit: 5,
scope: 'ip',
});
expect(id).toBe('ip:2.2.2.2');
});
});
89 changes: 89 additions & 0 deletions src/api/middleware/rate-limit.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { Request } from 'express';

export const WINDOW_MS = 60_000; // 1 minute sliding window

export type RateLimitScope = 'ip' | 'apiKey';

export interface RateLimitRule {
/** Human-readable identifier used in the Redis key namespace. */
name: string;
/** Allowed requests per window. */
limit: number;
/** Whether the limit is keyed by client IP or by API key. */
scope: RateLimitScope;
}

/** Endpoints that bypass rate limiting entirely. */
const EXEMPT_PREFIXES = ['/health', '/metrics'];

export function isExempt(path: string): boolean {
return EXEMPT_PREFIXES.some((p) => path === p || path.startsWith(p + '/'));
}

const GET_CHECKOUT_SESSION = /^\/v1\/checkout\/sessions\/[^/]+\/?$/;
const CHECKOUT_SESSIONS = /^\/v1\/checkout\/sessions\/?$/;

/**
* Resolve the rate-limit rule for a request.
*
* Order matters: more specific routes are matched before the catch-all.
*/
export function resolveRule(method: string, path: string): RateLimitRule {
const m = method.toUpperCase();

// Auth: tight per-IP limit on credential endpoints.
if (path === '/auth/login' || path === '/auth/verify') {
return { name: 'auth', limit: 5, scope: 'ip' };
}

// Merchant registration: very tight per-IP limit (anti-abuse).
if (path === '/merchants/register') {
return { name: 'merchants-register', limit: 3, scope: 'ip' };
}

// Checkout session creation: per-API-key (programmatic, higher limit).
if (m === 'POST' && CHECKOUT_SESSIONS.test(path)) {
return { name: 'checkout-create', limit: 100, scope: 'apiKey' };
}

// Public checkout-status reads: per-IP.
if (m === 'GET' && GET_CHECKOUT_SESSION.test(path)) {
return { name: 'checkout-get', limit: 60, scope: 'ip' };
}

// Default: per-IP.
return { name: 'default', limit: 60, scope: 'ip' };
}

/** Extract the client IP, honouring the first `X-Forwarded-For` hop. */
export function clientIp(req: Request): string {
const fwd = req.headers['x-forwarded-for'];
if (typeof fwd === 'string' && fwd.length > 0) {
return fwd.split(',')[0].trim();
}
if (Array.isArray(fwd) && fwd.length > 0) {
return fwd[0].split(',')[0].trim();
}
return req.ip ?? req.socket?.remoteAddress ?? 'unknown';
}

/** Extract the API key (bearer token) used to key API-scoped limits. */
export function apiKey(req: Request): string | undefined {
const auth = req.headers['authorization'];
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
return auth.slice(7);
}
return undefined;
}

/**
* Build the rate-limit identity for a request. API-key-scoped rules fall back to
* IP when no key is present so unauthenticated callers are still limited.
*/
export function rateLimitIdentity(req: Request, rule: RateLimitRule): string {
if (rule.scope === 'apiKey') {
const key = apiKey(req);
if (key) return `key:${key}`;
}
return `ip:${clientIp(req)}`;
}
Loading
Loading