mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
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.
62 lines
2.6 KiB
JavaScript
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();
|
|
});
|
|
});
|