Files
SnowLuma/packages/core/tests/webui-client-ip.test.ts
时瑾 ffe0c57abf feat(webui): add SNOWLUMA_WEBUI_TRUST_PROXY for reverse-proxy deploys
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
2026-05-16 21:31:54 +08:00

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');
});
});