The previous commit tightened login rate-limiting to key off the real
TCP socket peer, which fixed the X-Forwarded-For bypass but had the
known caveat of collapsing every client into a single global bucket
when the WebUI runs behind nginx / Caddy / a SaaS CDN.
Give operators an explicit, audited way to opt back into per-client
keying without re-introducing the spoof:
* `SNOWLUMA_WEBUI_TRUST_PROXY` env var. Modes:
- unset / empty → keep socket peer (safe default, unchanged)
- "loopback" → trust X-Real-IP / X-Forwarded-For when the
immediate socket peer is 127.0.0.1 / ::1
(nginx + WebUI on the same host)
- "1" / "true" / → trust headers from any peer (only safe behind
"all" a SaaS proxy that strips client-set headers)
- comma-separated → trust headers only when the socket peer
IP list matches one of these (proxy on another box)
* When trusting headers we prefer X-Real-IP (single value the proxy
overwrites unconditionally) and fall back to the first hop of XFF.
We deliberately do NOT walk the full XFF chain — without a fully
trusted proxy chain the leftmost value is still spoofable; a
single-tier proxy is the only setup this app realistically sees.
* Boot log prints "login rate-limit keyed by: …" so operators can
verify the mode took effect, plus an extra WARN for "all" reminding
them it's only safe with header-stripping in front.
Logic lives in webui/client-ip.ts as a pure pickClientIp() that takes
a socket-IP resolver, so the trust-proxy matrix can be exercised
without standing up a server — see webui-client-ip.test.ts for the
11 new unit tests covering parse + dispatch + loopback / list / all /
none modes.
Smoke-tested with SNOWLUMA_WEBUI_TRUST_PROXY=loopback:
- 5× failed login w/ X-Forwarded-For: 1.1.1.1 → 401
- 6th attempt same XFF → 429 (locked, per-XFF)
- 6th attempt X-Forwarded-For: 2.2.2.2 → 401 (fresh bucket)
- X-Real-IP overrides XFF when both present
93 lines
3.7 KiB
TypeScript
93 lines
3.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
describeTrustProxy,
|
|
parseTrustProxy,
|
|
pickClientIp,
|
|
type TrustProxyMode,
|
|
} from '../src/webui/client-ip';
|
|
|
|
function mockCtx(headers: Record<string, string> = {}) {
|
|
return {
|
|
req: {
|
|
header: (name: string) => headers[name.toLowerCase()],
|
|
},
|
|
} as unknown as Parameters<typeof pickClientIp>[0];
|
|
}
|
|
|
|
describe('parseTrustProxy', () => {
|
|
it('treats empty / unset as none (safe default)', () => {
|
|
expect(parseTrustProxy(undefined)).toEqual({ kind: 'none' });
|
|
expect(parseTrustProxy('')).toEqual({ kind: 'none' });
|
|
expect(parseTrustProxy(' ')).toEqual({ kind: 'none' });
|
|
});
|
|
|
|
it('recognises "1" / "true" / "all" as full trust', () => {
|
|
expect(parseTrustProxy('1')).toEqual({ kind: 'all' });
|
|
expect(parseTrustProxy('true')).toEqual({ kind: 'all' });
|
|
expect(parseTrustProxy('ALL')).toEqual({ kind: 'all' });
|
|
});
|
|
|
|
it('recognises "loopback" case-insensitively', () => {
|
|
expect(parseTrustProxy('Loopback')).toEqual({ kind: 'loopback' });
|
|
});
|
|
|
|
it('parses a comma-separated IP list', () => {
|
|
const mode = parseTrustProxy('10.0.0.1, 192.168.1.5, ');
|
|
expect(mode.kind).toBe('ip-list');
|
|
if (mode.kind !== 'ip-list') throw new Error('unreachable');
|
|
expect([...mode.ips].sort()).toEqual(['10.0.0.1', '192.168.1.5']);
|
|
});
|
|
});
|
|
|
|
describe('pickClientIp', () => {
|
|
const xff = { 'x-forwarded-for': '203.0.113.7, 10.0.0.1' };
|
|
const realIp = { 'x-real-ip': '203.0.113.42' };
|
|
const both = { ...xff, ...realIp };
|
|
|
|
it('returns socket peer and IGNORES headers when trust=none (default)', () => {
|
|
const mode: TrustProxyMode = { kind: 'none' };
|
|
expect(pickClientIp(mockCtx(both), mode, () => '198.51.100.9')).toBe('198.51.100.9');
|
|
});
|
|
|
|
it('honors X-Real-IP first, then XFF first hop, when trust=all', () => {
|
|
const mode: TrustProxyMode = { kind: 'all' };
|
|
expect(pickClientIp(mockCtx(both), mode, () => '10.0.0.1')).toBe('203.0.113.42');
|
|
expect(pickClientIp(mockCtx(xff), mode, () => '10.0.0.1')).toBe('203.0.113.7');
|
|
});
|
|
|
|
it('falls back to socket peer when trust=all but no headers present', () => {
|
|
const mode: TrustProxyMode = { kind: 'all' };
|
|
expect(pickClientIp(mockCtx({}), mode, () => '10.0.0.1')).toBe('10.0.0.1');
|
|
});
|
|
|
|
it('trust=loopback honors headers only when socket is loopback', () => {
|
|
const mode: TrustProxyMode = { kind: 'loopback' };
|
|
expect(pickClientIp(mockCtx(both), mode, () => '127.0.0.1')).toBe('203.0.113.42');
|
|
expect(pickClientIp(mockCtx(both), mode, () => '::1')).toBe('203.0.113.42');
|
|
expect(pickClientIp(mockCtx(both), mode, () => '::ffff:127.0.0.1')).toBe('203.0.113.42');
|
|
// Non-loopback peer → headers ignored, socket wins.
|
|
expect(pickClientIp(mockCtx(both), mode, () => '10.0.0.1')).toBe('10.0.0.1');
|
|
});
|
|
|
|
it('trust=ip-list honors headers only when socket is in the list', () => {
|
|
const mode: TrustProxyMode = { kind: 'ip-list', ips: new Set(['10.0.0.1', '10.0.0.2']) };
|
|
expect(pickClientIp(mockCtx(both), mode, () => '10.0.0.2')).toBe('203.0.113.42');
|
|
expect(pickClientIp(mockCtx(both), mode, () => '10.0.0.9')).toBe('10.0.0.9');
|
|
});
|
|
|
|
it('survives a socket resolver that throws', () => {
|
|
const mode: TrustProxyMode = { kind: 'none' };
|
|
expect(pickClientIp(mockCtx({}), mode, () => { throw new Error('boom'); })).toBe('127.0.0.1');
|
|
});
|
|
});
|
|
|
|
describe('describeTrustProxy', () => {
|
|
it('renders each mode in a recognisable form for the boot log', () => {
|
|
expect(describeTrustProxy({ kind: 'none' })).toContain('socket peer');
|
|
expect(describeTrustProxy({ kind: 'all' })).toContain('any peer');
|
|
expect(describeTrustProxy({ kind: 'loopback' })).toContain('loopback');
|
|
expect(describeTrustProxy({ kind: 'ip-list', ips: new Set(['10.0.0.1']) }))
|
|
.toContain('10.0.0.1');
|
|
});
|
|
});
|