Files
wownerogue/test/rateLimit.test.js
T
jwinterm 448cb2f528 fix: reconnect-proof rate limiting (stable id + real client IP)
Socket-event rate limits keyed on socket.id (new on every reconnect) and
passed NO ip argument, so the "IP-limited" prefixes never actually applied —
reconnecting reset every limit, and chat had no IP limit at all.

- New rateLimitContext helper resolves a STABLE identity (anon_token -> id,
  falling back to socket id) and the real client IP (X-Forwarded-For honoured
  only when TRUST_PROXY=true, else handshake address — no spoofing on direct
  exposure).
- game:start, address:set and chat:message now pass (stableId, action, ip);
  connection:new uses the real IP. 'chat:' added to IP-limited prefixes.
- Express 'trust proxy' enabled when TRUST_PROXY=true; documented in
  .env.example. Aligned the contradictory default limit values with the
  production override.

Adds rateLimit.test.js (reconnect can't bypass, IP backstop for fresh
identities, XFF gating). Full suite: 18 suites / 143 tests.
2026-05-31 10:21:28 -04:00

62 lines
2.6 KiB
JavaScript

/**
* Reconnect-proof rate limiting tests (Phase 2.2).
*/
const RateLimiter = require('../src/network/rateLimiter');
const { clientIp, stableId } = require('../src/network/rateLimitContext');
describe('rateLimitContext.stableId', () => {
test('prefers the session stable user id over the socket id', () => {
const sm = { sessions: new Map([['sockA', { id: 42 }]]) };
expect(stableId({ id: 'sockA' }, sm)).toBe('u:42');
});
test('falls back to socket id when no session', () => {
const sm = { sessions: new Map() };
expect(stableId({ id: 'sockZ' }, sm)).toBe('s:sockZ');
expect(stableId({ id: 'sockZ' }, undefined)).toBe('s:sockZ');
});
});
describe('rateLimitContext.clientIp', () => {
afterEach(() => { delete process.env.TRUST_PROXY; });
test('uses handshake address by default (ignores XFF when proxy not trusted)', () => {
expect(clientIp({ handshake: { headers: { 'x-forwarded-for': '9.9.9.9' }, address: '10.0.0.1' } })).toBe('10.0.0.1');
});
test('uses first X-Forwarded-For hop when TRUST_PROXY=true', () => {
process.env.TRUST_PROXY = 'true';
expect(clientIp({ handshake: { headers: { 'x-forwarded-for': '9.9.9.9, 10.0.0.1' }, address: '10.0.0.1' } })).toBe('9.9.9.9');
});
});
describe('reconnect cannot bypass the limit (stable-id keyed)', () => {
test('two sockets for the same user share the limit', async () => {
const rl = new RateLimiter({ limits: { 'game:start': { window: 60000, max: 2 } } });
const sm = { sessions: new Map([['sockA', { id: 7 }], ['sockB', { id: 7 }]]) };
const idA = stableId({ id: 'sockA' }, sm); // u:7
const idB = stableId({ id: 'sockB' }, sm); // u:7 (reconnected, new socket)
expect(idA).toBe(idB);
await rl.recordAttempt(idA, 'game:start', '1.2.3.4');
await rl.recordAttempt(idA, 'game:start', '1.2.3.4'); // at max
const afterReconnect = await rl.checkLimit(idB, 'game:start', '1.2.3.4');
expect(afterReconnect.allowed).toBe(false); // blocked despite the new socket id
rl.shutdown();
});
test('chat:message is now IP-limited', () => {
const rl = new RateLimiter();
expect(rl._shouldApplyIpLimit('chat:message')).toBe(true);
rl.shutdown();
});
test('IP limit blocks a fresh anonymous identity from the same IP', async () => {
const rl = new RateLimiter({ limits: { 'game:start': { window: 60000, max: 1 } } });
// Two different "users" (e.g. cleared token) but same IP.
await rl.recordAttempt('u:1', 'game:start', '5.5.5.5');
const other = await rl.checkLimit('u:2', 'game:start', '5.5.5.5');
expect(other.allowed).toBe(false); // IP bucket already exhausted
rl.shutdown();
});
});