import { describe, it, expect } from 'vitest'; import { getClientIp, isTrustedProxyIp } from './rateLimit.js'; // Minimal Hono-Context stand-in: headers + the node-server env with the // socket peer address. function fakeContext(opts: { peer?: string; headers?: Record }) { const headers = new Map( Object.entries(opts.headers || {}).map(([k, v]) => [k.toLowerCase(), v]) ); return { req: { header: (name: string) => headers.get(name.toLowerCase()) }, env: opts.peer ? { incoming: { socket: { remoteAddress: opts.peer } } } : {}, } as any; } describe('isTrustedProxyIp', () => { it('trusts loopback and private ranges, including IPv4-mapped IPv6', () => { expect(isTrustedProxyIp('127.0.0.1')).toBe(true); expect(isTrustedProxyIp('::1')).toBe(true); expect(isTrustedProxyIp('::ffff:127.0.0.1')).toBe(true); expect(isTrustedProxyIp('10.1.2.3')).toBe(true); expect(isTrustedProxyIp('172.18.0.5')).toBe(true); expect(isTrustedProxyIp('192.168.1.1')).toBe(true); }); it('does not trust public addresses or near-miss ranges', () => { expect(isTrustedProxyIp('203.0.113.7')).toBe(false); expect(isTrustedProxyIp('172.15.0.1')).toBe(false); // outside 172.16/12 expect(isTrustedProxyIp('172.32.0.1')).toBe(false); expect(isTrustedProxyIp('1270.0.0.1')).toBe(false); expect(isTrustedProxyIp('')).toBe(false); }); }); describe('getClientIp', () => { it('prefers X-Real-IP when the peer is a trusted proxy', () => { const c = fakeContext({ peer: '127.0.0.1', headers: { 'x-real-ip': '203.0.113.7', 'x-forwarded-for': '9.9.9.9' }, }); expect(getClientIp(c)).toBe('203.0.113.7'); }); it('walks X-Forwarded-For from the right past our own proxy hops', () => { // spoofed prefix, then the real client appended by nginx, then the Next // proxy hop — the rightmost untrusted entry wins const c = fakeContext({ peer: '127.0.0.1', headers: { 'x-forwarded-for': '9.9.9.9, 203.0.113.7, 127.0.0.1' }, }); expect(getClientIp(c)).toBe('203.0.113.7'); }); it('ignores forwarded headers entirely when the peer is untrusted', () => { // A client hitting the API directly cannot pick its own bucket const c = fakeContext({ peer: '198.51.100.4', headers: { 'x-forwarded-for': '9.9.9.9', 'x-real-ip': '8.8.8.8' }, }); expect(getClientIp(c)).toBe('198.51.100.4'); }); it('falls back to the socket address for local traffic with no headers', () => { expect(getClientIp(fakeContext({ peer: '127.0.0.1' }))).toBe('127.0.0.1'); expect(getClientIp(fakeContext({ peer: '::ffff:127.0.0.1' }))).toBe('127.0.0.1'); }); it('falls back to the socket address when every forwarded hop is internal', () => { const c = fakeContext({ peer: '127.0.0.1', headers: { 'x-forwarded-for': '127.0.0.1' }, }); expect(getClientIp(c)).toBe('127.0.0.1'); }); it('rejects junk header values instead of using them as bucket keys', () => { const c = fakeContext({ peer: '127.0.0.1', headers: { 'x-forwarded-for': 'not-an-ip; DROP TABLE users' }, }); expect(getClientIp(c)).toBe('127.0.0.1'); }); it('returns "unknown" without a socket address or trusted headers', () => { expect(getClientIp(fakeContext({}))).toBe('unknown'); }); });