diff --git a/app/middleware/rate-limit-key.js b/app/middleware/rate-limit-key.js new file mode 100644 index 0000000..71afccd --- /dev/null +++ b/app/middleware/rate-limit-key.js @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * Key generator for express-rate-limit. + * + * Authenticated requests are keyed by a sha256 hash prefix of the + * `authKey` header — so two clients sharing an IP (mobile carrier + * NAT, corporate proxy, etc.) get independent rate budgets, and a + * brute-force attacker rotating source IPs can't stretch a + * per-IP budget by switching networks. Anonymous requests fall + * back to IP (the brute-force path, where per-IP is the right + * granularity). + * + * The hash prefix (16 hex chars = 64 bits) is plenty unique for + * keyspace separation and keeps the raw token out of any + * downstream rate-limiter store. + * + * Exported separately from server.js so unit tests can exercise + * the keying directly without spinning up an HTTP server. + */ + +const crypto = require('crypto'); + +function keyByAuthKeyOrIp(req /*, res */) { + const authKey = req.get && req.get('authKey'); + if (authKey) { + return 'k:' + crypto.createHash('sha256').update(authKey).digest('hex').slice(0, 16); + } + return 'ip:' + (req.ip || (req.connection && req.connection.remoteAddress) || 'unknown'); +} + +module.exports = { keyByAuthKeyOrIp }; diff --git a/server.js b/server.js index 1071083..fcd3645 100644 --- a/server.js +++ b/server.js @@ -117,14 +117,25 @@ app.use(express.json({ })); // Rate limit the v1 surface to defend against authKey brute-force. -// Defaults: 100 requests / 15-minute window per IP. Operators can -// tune via RATE_LIMIT_WINDOW_MS and RATE_LIMIT_MAX. Set -// RATE_LIMIT_MAX=0 to disable entirely (e.g. for load testing). -// /healthz is intentionally NOT rate-limited so orchestrator probes -// never trip it. +// Defaults: 100 requests / 15-minute window. Operators can tune via +// RATE_LIMIT_WINDOW_MS and RATE_LIMIT_MAX. Set RATE_LIMIT_MAX=0 to +// disable entirely (e.g. for load testing). /healthz is intentionally +// NOT rate-limited so orchestrator probes never trip it. +// +// Key derivation: +// - Authenticated requests (authKey header present): key by the +// hash prefix of that authKey. Mobile-carrier-NAT users sharing +// an IP no longer poison each other's budget; brute-force +// attempts get cut off per-key regardless of how many IPs the +// attacker rotates through. +// - Anonymous requests (no header): key by IP, the +// express-rate-limit default. This is the brute-force path — +// someone trying keys to find a valid one — and per-IP is the +// right granularity there. const rateLimitMax = parseInt(process.env.RATE_LIMIT_MAX, 10); const rateLimitWindowMs = parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10); if (rateLimitMax !== 0) { + const { keyByAuthKeyOrIp } = require('./app/middleware/rate-limit-key.js'); const v1Limiter = rateLimit({ windowMs: Number.isFinite(rateLimitWindowMs) && rateLimitWindowMs > 0 ? rateLimitWindowMs @@ -135,6 +146,7 @@ if (rateLimitMax !== 0) { standardHeaders: true, // RateLimit-* headers legacyHeaders: false, // no X-RateLimit-* legacy headers message: { message: 'Too many requests — try again later.' }, + keyGenerator: keyByAuthKeyOrIp, }); app.use('/v1', v1Limiter); } diff --git a/tests/unit/rate-limit-key.test.js b/tests/unit/rate-limit-key.test.js new file mode 100644 index 0000000..00d27d6 --- /dev/null +++ b/tests/unit/rate-limit-key.test.js @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// Unit tests for the express-rate-limit key generator. Verifies: +// - authKey-present requests key by a hash-prefixed string +// - same authKey → same key (deterministic), regardless of IP +// - different authKeys → different keys (independent budgets) +// - no authKey → falls back to IP +// - raw authKey value NEVER appears in the returned key + +import { describe, test, expect } from 'vitest'; +import { keyByAuthKeyOrIp } from '../../app/middleware/rate-limit-key.js'; + +function fakeReq({ authKey, ip } = {}) { + return { + get: (h) => (h === 'authKey' ? authKey : undefined), + ip, + }; +} + +describe('keyByAuthKeyOrIp', () => { + test('returns a `k:` prefixed hash when authKey is set', () => { + const k = keyByAuthKeyOrIp(fakeReq({ authKey: 'live-token-abc' })); + expect(k.startsWith('k:')).toBe(true); + // 16 hex chars after the prefix. + expect(k).toMatch(/^k:[0-9a-f]{16}$/); + }); + + test('the raw authKey is never in the returned key', () => { + const secret = 'super-secret-token-xyz'; + const k = keyByAuthKeyOrIp(fakeReq({ authKey: secret })); + expect(k.includes(secret)).toBe(false); + }); + + test('same authKey → same key regardless of IP', () => { + const a = keyByAuthKeyOrIp(fakeReq({ authKey: 'tok', ip: '1.2.3.4' })); + const b = keyByAuthKeyOrIp(fakeReq({ authKey: 'tok', ip: '5.6.7.8' })); + expect(a).toBe(b); + }); + + test('different authKeys → different keys', () => { + const a = keyByAuthKeyOrIp(fakeReq({ authKey: 'tok-a' })); + const b = keyByAuthKeyOrIp(fakeReq({ authKey: 'tok-b' })); + expect(a).not.toBe(b); + }); + + test('no authKey + present IP → `ip:` prefix with the IP', () => { + const k = keyByAuthKeyOrIp(fakeReq({ ip: '203.0.113.42' })); + expect(k).toBe('ip:203.0.113.42'); + }); + + test('no authKey + no IP → falls back to "unknown"', () => { + const k = keyByAuthKeyOrIp(fakeReq({})); + expect(k).toBe('ip:unknown'); + }); + + test('two anonymous requests from the same IP get the same key (per-IP fallback works)', () => { + const a = keyByAuthKeyOrIp(fakeReq({ ip: '1.1.1.1' })); + const b = keyByAuthKeyOrIp(fakeReq({ ip: '1.1.1.1' })); + expect(a).toBe(b); + }); +});