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
34 changes: 34 additions & 0 deletions app/middleware/rate-limit-key.js
Original file line number Diff line number Diff line change
@@ -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 };
22 changes: 17 additions & 5 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
Expand Down
62 changes: 62 additions & 0 deletions tests/unit/rate-limit-key.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading