diff --git a/.env.example b/.env.example index 8023e10..055da8f 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,28 @@ LOG_LEVEL=info # Format: postgresql://user:password@host:port/database DATABASE_URL="postgresql://postgres:postgres@localhost:5432/guildpass" +# ============================================================================ +# RATE LIMITING (Optional - sensible defaults provided) +# ============================================================================ + +# Set to false to disable rate limiting entirely (useful for local development +# or integration tests that fire many requests). Default: true +# RATE_LIMIT_ENABLED=true + +# Time window for rate limit counters, in milliseconds (default: 60000 = 1 minute) +# RATE_LIMIT_WINDOW_MS=60000 + +# Maximum requests per IP per window for standard endpoints (default: 100) +# RATE_LIMIT_DEFAULT_MAX=100 + +# Maximum requests per IP per window for expensive endpoints such as +# GET /v1/communities/:id/members (default: 20) +# RATE_LIMIT_EXPENSIVE_MAX=20 + +# Optional Redis connection string for distributed rate limiting across multiple +# instances. When omitted, an in-memory store is used (not shared across replicas). +# REDIS_URL="redis://localhost:6379" + # ============================================================================ # FUTURE USE (not yet validated) # ============================================================================ @@ -36,9 +58,6 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/guildpass" # MEMBERSHIP_NFT_ADDRESS="" # CHAIN_ID=31337 -# Reserved for future caching -# REDIS_URL="redis://localhost:6379" - # Reserved for future metrics auth # METRICS_TOKEN="" diff --git a/apps/access-api/package.json b/apps/access-api/package.json index aabc770..246b7b6 100644 --- a/apps/access-api/package.json +++ b/apps/access-api/package.json @@ -21,6 +21,7 @@ "test": "jest --passWithNoTests" }, "dependencies": { + "@fastify/rate-limit": "^9.1.0", "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^2.0.1", "@guildpass/policy-engine": "workspace:*", diff --git a/apps/access-api/src/app.ts b/apps/access-api/src/app.ts index 953f599..0b5a6d5 100644 --- a/apps/access-api/src/app.ts +++ b/apps/access-api/src/app.ts @@ -19,6 +19,7 @@ import Fastify, { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import swagger from '@fastify/swagger'; import swaggerUi from '@fastify/swagger-ui'; +import rateLimit from '@fastify/rate-limit'; import { buildPinoHttp } from './observability/logger'; import { registry, metrics } from './observability/metrics'; @@ -130,13 +131,47 @@ export async function buildApp(): Promise { await app.register(swaggerUi, { routePrefix: '/docs' }); // ----------------------------------------------------------------------- - // 4. Prometheus metrics endpoint + // 4. Rate limiting + // Enabled by default; set RATE_LIMIT_ENABLED=false to disable. + // Health endpoints opt-out via { config: { rateLimit: false } }. + // Expensive endpoints declare a tighter ceiling in their route config. + // ----------------------------------------------------------------------- + if (config.rateLimitEnabled) { + await app.register(rateLimit, { + global: true, + max: config.rateLimitDefaultMax, + timeWindow: config.rateLimitWindowMs, + keyGenerator: (req) => { + const forwarded = req.headers['x-forwarded-for']; + const ip = Array.isArray(forwarded) + ? forwarded[0] + : forwarded?.split(',')[0]?.trim(); + return ip ?? req.ip; + }, + errorResponseBuilder: (_req, context) => ({ + statusCode: 429, + error: 'Too Many Requests', + message: `Rate limit exceeded. Retry after ${Math.ceil(context.ttl / 1000)} seconds.`, + retryAfter: Math.ceil(context.ttl / 1000), + }), + addHeaders: { + 'x-ratelimit-limit': true, + 'x-ratelimit-remaining': true, + 'x-ratelimit-reset': true, + 'retry-after': true, + }, + }); + } + + // ----------------------------------------------------------------------- + // 6. Prometheus metrics endpoint // Secured by a simple pre-handler so it is never exposed publicly. // Set METRICS_TOKEN to a non-empty string to enable bearer-token auth. // ----------------------------------------------------------------------- app.get( '/metrics', { + config: { rateLimit: false }, schema: { summary: 'Prometheus metrics scrape endpoint', tags: ['Observability'], @@ -157,7 +192,7 @@ export async function buildApp(): Promise { ); // ----------------------------------------------------------------------- - // 5. Health endpoints + // 7. Health endpoints // // GET /health/live – liveness probe // Answers: "Is the process alive and the event loop responsive?" @@ -171,6 +206,7 @@ export async function buildApp(): Promise { app.get( '/health/live', { + config: { rateLimit: false }, schema: { summary: 'Liveness probe – is the process alive?', tags: ['Health'], @@ -193,6 +229,7 @@ export async function buildApp(): Promise { app.get( '/health/ready', { + config: { rateLimit: false }, schema: { summary: 'Readiness probe – can we serve traffic?', tags: ['Health'], @@ -233,7 +270,7 @@ export async function buildApp(): Promise { ); // ----------------------------------------------------------------------- - // 6. Business routes + // 8. Business routes // ----------------------------------------------------------------------- registerRoutes(app); diff --git a/apps/access-api/src/config.ts b/apps/access-api/src/config.ts index b86d6c3..abbbb18 100644 --- a/apps/access-api/src/config.ts +++ b/apps/access-api/src/config.ts @@ -49,6 +49,28 @@ const ConfigSchema = z.object({ .int() .positive() .default(60_000), + + // Rate limiting + rateLimitEnabled: z + .string() + .transform((v: string) => v !== 'false' && v !== '0') + .default('true'), + rateLimitWindowMs: z.coerce + .number() + .int() + .positive() + .default(60_000), + rateLimitDefaultMax: z.coerce + .number() + .int() + .positive() + .default(100), + rateLimitExpensiveMax: z.coerce + .number() + .int() + .positive() + .default(20), + redisUrl: z.string().optional(), }); export type Config = z.infer; @@ -64,6 +86,11 @@ function validateConfig(): Config { databaseUrl: process.env.DATABASE_URL, logLevel: process.env.LOG_LEVEL, reconciliationIntervalMs: process.env.RECONCILIATION_INTERVAL_MS, + rateLimitEnabled: process.env.RATE_LIMIT_ENABLED, + rateLimitWindowMs: process.env.RATE_LIMIT_WINDOW_MS, + rateLimitDefaultMax: process.env.RATE_LIMIT_DEFAULT_MAX, + rateLimitExpensiveMax: process.env.RATE_LIMIT_EXPENSIVE_MAX, + redisUrl: process.env.REDIS_URL, }; const result = ConfigSchema.safeParse(envVars); diff --git a/apps/access-api/test/rateLimit.test.ts b/apps/access-api/test/rateLimit.test.ts new file mode 100644 index 0000000..c586d12 --- /dev/null +++ b/apps/access-api/test/rateLimit.test.ts @@ -0,0 +1,197 @@ +import Fastify, { FastifyInstance } from 'fastify'; +import rateLimit from '@fastify/rate-limit'; + +interface RateLimitOptions { + enabled: boolean; + max: number; + expensiveMax: number; + timeWindow: number; +} + +async function buildRateLimitedApp(opts: RateLimitOptions): Promise { + const app = Fastify(); + + if (opts.enabled) { + await app.register(rateLimit, { + global: true, + max: opts.max, + timeWindow: opts.timeWindow, + errorResponseBuilder: (_req, context) => ({ + statusCode: 429, + error: 'Too Many Requests', + message: `Rate limit exceeded. Retry after ${Math.ceil(context.ttl / 1000)} seconds.`, + retryAfter: Math.ceil(context.ttl / 1000), + }), + addHeaders: { + 'x-ratelimit-limit': true, + 'x-ratelimit-remaining': true, + 'x-ratelimit-reset': true, + 'retry-after': true, + }, + }); + } + + app.get('/health/live', { config: { rateLimit: false } }, async () => { + return { status: 'ok' }; + }); + + app.get('/v1/access/check', async () => { + return { allowed: true }; + }); + + app.get('/v1/communities/:communityId/members', { + config: { + rateLimit: opts.enabled + ? { max: opts.expensiveMax, timeWindow: opts.timeWindow } + : false, + }, + }, async () => { + return { members: [] }; + }); + + await app.ready(); + return app; +} + +describe('Rate limiting — allowed requests', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = await buildRateLimitedApp({ + enabled: true, + max: 5, + expensiveMax: 2, + timeWindow: 60_000, + }); + }); + + afterEach(async () => { + await app.close(); + }); + + it('allows requests up to the configured limit', async () => { + for (let i = 0; i < 5; i++) { + const res = await app.inject({ method: 'GET', url: '/v1/access/check' }); + expect(res.statusCode).toBe(200); + } + }); + + it('attaches rate limit headers to responses', async () => { + const res = await app.inject({ method: 'GET', url: '/v1/access/check' }); + expect(res.statusCode).toBe(200); + expect(res.headers['x-ratelimit-limit']).toBeDefined(); + expect(res.headers['x-ratelimit-remaining']).toBeDefined(); + expect(res.headers['x-ratelimit-reset']).toBeDefined(); + }); +}); + +describe('Rate limiting — blocked requests', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = await buildRateLimitedApp({ + enabled: true, + max: 3, + expensiveMax: 2, + timeWindow: 60_000, + }); + }); + + afterEach(async () => { + await app.close(); + }); + + it('returns 429 after the limit is exceeded', async () => { + for (let i = 0; i < 3; i++) { + const res = await app.inject({ method: 'GET', url: '/v1/access/check' }); + expect(res.statusCode).toBe(200); + } + + const blocked = await app.inject({ method: 'GET', url: '/v1/access/check' }); + expect(blocked.statusCode).toBe(429); + }); + + it('returns a JSON body with error and retryAfter on 429', async () => { + for (let i = 0; i < 3; i++) { + await app.inject({ method: 'GET', url: '/v1/access/check' }); + } + + const blocked = await app.inject({ method: 'GET', url: '/v1/access/check' }); + expect(blocked.statusCode).toBe(429); + + const body = blocked.json(); + expect(body.error).toBe('Too Many Requests'); + expect(body.message).toMatch(/Rate limit exceeded/); + expect(typeof body.retryAfter).toBe('number'); + }); + + it('returns 429 on expensive endpoint after its stricter limit', async () => { + for (let i = 0; i < 2; i++) { + const res = await app.inject({ + method: 'GET', + url: '/v1/communities/community-1/members', + }); + expect(res.statusCode).toBe(200); + } + + const blocked = await app.inject({ + method: 'GET', + url: '/v1/communities/community-1/members', + }); + expect(blocked.statusCode).toBe(429); + }); +}); + +describe('Rate limiting — disabled', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = await buildRateLimitedApp({ + enabled: false, + max: 2, + expensiveMax: 1, + timeWindow: 60_000, + }); + }); + + afterEach(async () => { + await app.close(); + }); + + it('allows unlimited requests when rate limiting is disabled', async () => { + for (let i = 0; i < 10; i++) { + const res = await app.inject({ method: 'GET', url: '/v1/access/check' }); + expect(res.statusCode).toBe(200); + } + }); + + it('does not attach x-ratelimit headers when disabled', async () => { + const res = await app.inject({ method: 'GET', url: '/v1/access/check' }); + expect(res.headers['x-ratelimit-limit']).toBeUndefined(); + expect(res.headers['x-ratelimit-remaining']).toBeUndefined(); + }); +}); + +describe('Rate limiting — health check exemption', () => { + let app: FastifyInstance; + + beforeEach(async () => { + app = await buildRateLimitedApp({ + enabled: true, + max: 2, + expensiveMax: 1, + timeWindow: 60_000, + }); + }); + + afterEach(async () => { + await app.close(); + }); + + it('never rate-limits the health/live endpoint', async () => { + for (let i = 0; i < 10; i++) { + const res = await app.inject({ method: 'GET', url: '/health/live' }); + expect(res.statusCode).toBe(200); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faee5b1..95beb86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: apps/access-api: dependencies: + '@fastify/rate-limit': + specifier: ^9.1.0 + version: 9.1.0 '@fastify/swagger': specifier: ^8.14.0 version: 8.15.0 @@ -354,6 +357,9 @@ packages: '@fastify/merge-json-schemas@0.1.1': resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==} + '@fastify/rate-limit@9.1.0': + resolution: {integrity: sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==} + '@fastify/send@2.1.0': resolution: {integrity: sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==} @@ -2207,6 +2213,12 @@ snapshots: dependencies: fast-deep-equal: 3.1.3 + '@fastify/rate-limit@9.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 4.5.1 + toad-cache: 3.7.1 + '@fastify/send@2.1.0': dependencies: '@lukeed/ms': 2.0.2