Files
clash-party/src/main/resolve/plugin/net-guard.test.ts
ezequielnick a092eb50c3 feat(plugin): CPX v2 availability hardening (squash of cpx-v2-availability-hardening)
Client-side hardening of the CPX airport plugin, squashed from 46 commits
forked at 89c2bb0e. Behaviour changes:

- Unified operation model (§0.4/0.5): one deadline + one AbortSignal + one
  persistence commit per operation; per-plugin lock → vault lock hierarchy;
  tombstone on delete; every wait (lock, DNS preflight, proxy resolution,
  vault decrypt) is bounded by the same budget.
- Routing: direct/proxy auto-fallback with a pre-send guard; proxied https
  builds its own CONNECT tunnel (an aborted hung CONNECT closes its socket);
  invalid local-proxy ports are refused instead of falling back to :80; the
  core's inbound credentials are carried to the local proxy; NAT64 and
  site-local IPv6 ranges are non-public.
- Gateways: multi-gateway recovery with one rediscovery per operation,
  normalized endpoint paths, signed discovery documents (Ed25519, seq/digest
  accept/align/rollback/equivocation), commit order vault → plugin.yaml.
- Subscriptions: a fetched subscription is validated by the core (mihomo -t)
  against the current override set before it replaces the profile, inside
  the profile write critical section (profile.yaml and override.yaml share
  one write queue); schedule fields are read at write time; the first
  subscription is activated through the real switch flow; profile deletion
  removes the record last so any failure stays retryable.
- Devices: a re-login that replaces a still-valid device records it in the
  vault (staleDevices) and retires it after the login, after later
  successful fetches and on removal; enroll compensation restores the old
  vault and keeps an un-revoked new device for retirement.
- Vault: on Linux the vault is persisted only behind a system secret store
  (backend name + ciphertext-prefix canary); otherwise it stays in memory.
  A cache-miss read releases the caller at the budget while the lock is held
  until the decrypt ends.
- Config caches (plugin.yaml, profile.yaml, override.yaml) can no longer be
  rolled back by a late cold read.
- Reference gateway and provider guides updated (deploy contract, discoveryUrls
  same-origin rule, /revoke after re-login); https-proxy-agent dropped.

Reviewed in a Codex loop (gpt-6, 38 calls): 74 findings, 68 fixed and
verified, 6 invalid, no backlog. Tests: vitest 501, gateway 106.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 22:44:13 +08:00

187 lines
7.1 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
isPrivateIp,
isForbiddenHost,
createGuardedLookup,
resolveAllPublicOrThrow
} from './net-guard'
describe('isForbiddenHost', () => {
it('forbids localhost and its variants', () => {
expect(isForbiddenHost('localhost')).toBe(true)
expect(isForbiddenHost('LocalHost')).toBe(true)
expect(isForbiddenHost('localhost.')).toBe(true)
expect(isForbiddenHost('foo.localhost')).toBe(true)
expect(isForbiddenHost('foo.localhost.')).toBe(true)
})
it('forbids private/loopback IP literals (incl. bracketed IPv6)', () => {
expect(isForbiddenHost('127.0.0.1')).toBe(true)
expect(isForbiddenHost('10.0.0.5')).toBe(true)
expect(isForbiddenHost('[::1]')).toBe(true)
expect(isForbiddenHost('')).toBe(true)
})
it('allows ordinary public hostnames (incl. lookalikes)', () => {
expect(isForbiddenHost('panel.xx.com')).toBe(false)
expect(isForbiddenHost('gw.front.com')).toBe(false)
expect(isForbiddenHost('localhost.com')).toBe(false)
expect(isForbiddenHost('mylocalhost')).toBe(false)
})
})
describe('isPrivateIp', () => {
it('flags loopback/private/link-local/metadata/ula/mapped', () => {
for (const ip of [
'127.0.0.1',
'10.0.0.5',
'172.16.0.1',
'172.31.255.255',
'192.168.1.1',
'169.254.169.254',
'0.0.0.0',
'100.64.0.1',
'192.0.2.1',
'198.18.0.1',
'198.51.100.1',
'203.0.113.1',
'::1',
'::',
'100::1',
'2001:db8::1',
'2002:c0a8:0101::1',
'fe80::1',
'fd00::1',
'ff02::1',
'::ffff:127.0.0.1',
// hex IPv4-mapped forms (SSRF bypass vectors)
'::ffff:7f00:1', // 127.0.0.1 in hex
'::ffff:0a00:1', // 10.0.0.1 in hex
'::ffff:c0a8:101', // 192.168.1.1 in hex
'::ffff:a9fe:a9fe', // 169.254.169.254 (metadata) in hex
// full fe80::/10 link-local (not just fe80 prefix)
'febf::1',
// fully-expanded loopback
'0:0:0:0:0:0:0:1'
]) {
expect(isPrivateIp(ip), ip).toBe(true)
}
})
it('allows public addresses', () => {
for (const ip of ['1.1.1.1', '8.8.8.8', '93.184.216.34', '2606:4700:4700::1111']) {
expect(isPrivateIp(ip), ip).toBe(false)
}
})
it('treats invalid input as unsafe', () => {
expect(isPrivateIp('not-an-ip')).toBe(true)
})
it('fails closed on malformed IPv6', () => {
expect(isPrivateIp(':::1'), ':::1').toBe(true)
expect(isPrivateIp('gg::1'), 'gg::1').toBe(true)
})
})
function callLookup(
lookup: ReturnType<typeof createGuardedLookup>,
host: string
): Promise<{ err: unknown; address: string }> {
return new Promise((resolve) => {
lookup(host, {}, (err, address) => resolve({ err, address: address as string }))
})
}
describe('createGuardedLookup', () => {
it('returns first address when all public', async () => {
const lookup = createGuardedLookup(async () => [{ address: '1.1.1.1', family: 4 }])
const r = await callLookup(lookup, 'example.com')
expect(r.err).toBeNull()
expect(r.address).toBe('1.1.1.1')
})
it('rejects if any resolved address is private (rebinding)', async () => {
const lookup = createGuardedLookup(async () => [
{ address: '1.1.1.1', family: 4 },
{ address: '127.0.0.1', family: 4 }
])
const r = await callLookup(lookup, 'evil.com')
expect(r.err).toBeTruthy()
})
it('rejects when nothing resolves', async () => {
const lookup = createGuardedLookup(async () => [])
const r = await callLookup(lookup, 'nx.example')
expect(r.err).toBeTruthy()
})
// Node's autoSelectFamily (Happy Eyeballs, default in modern Node/Electron) calls the
// custom lookup with { all: true } for dual-stack hosts and expects an ARRAY back —
// returning a single address there throws ERR_INVALID_IP_ADDRESS ("Invalid IP: undefined").
it('returns the full validated array when called with { all: true }', async () => {
const lookup = createGuardedLookup(async () => [
{ address: '2606:4700::6810:e784', family: 6 },
{ address: '104.16.231.132', family: 4 }
])
const r = await new Promise<{ err: unknown; addresses: unknown }>((resolve) => {
lookup('dual.example', { all: true }, (err, addresses) => resolve({ err, addresses }))
})
expect(r.err).toBeNull()
expect(r.addresses).toEqual([
{ address: '2606:4700::6810:e784', family: 6 },
{ address: '104.16.231.132', family: 4 }
])
})
it('still rejects a private address even under { all: true }', async () => {
const lookup = createGuardedLookup(async () => [
{ address: '104.16.231.132', family: 4 },
{ address: '192.168.1.5', family: 4 }
])
const r = await new Promise<{ err: unknown; addresses: unknown }>((resolve) => {
lookup('rebind.example', { all: true }, (err, addresses) => resolve({ err, addresses }))
})
expect(r.err).toBeTruthy()
})
})
describe('resolveAllPublicOrThrow', () => {
it('returns all addresses when every one is public', async () => {
const addrs = await resolveAllPublicOrThrow('gw.front.com', async () => [
{ address: '1.1.1.1', family: 4 },
{ address: '2606:4700::1', family: 6 }
])
expect(addrs).toHaveLength(2)
})
it('throws CPX_GUARD_REFUSED when any address is private', async () => {
await expect(
resolveAllPublicOrThrow('gw.front.com', async () => [
{ address: '1.1.1.1', family: 4 },
{ address: '192.168.1.1', family: 4 }
])
).rejects.toMatchObject({ code: 'CPX_GUARD_REFUSED', phase: 'pre-send' })
})
it('throws an ENOTFOUND-coded error when nothing resolves', async () => {
await expect(resolveAllPublicOrThrow('gw.front.com', async () => [])).rejects.toMatchObject({
code: 'ENOTFOUND'
})
})
})
describe('R2-ISS-044: 192.0.0.0/16 is not reserved as a whole', () => {
it('R2-ISS-062: the deprecated site-local range fec0::/10 is non-public', () => {
expect(isPrivateIp('fec0::1')).toBe(true)
expect(isPrivateIp('feff:ffff::1')).toBe(true) // top of the /10
expect(isPrivateIp('fe80::1')).toBe(true) // link-local still covered
expect(isPrivateIp('fe00::1')).toBe(false) // just outside both ranges
expect(isForbiddenHost('[fec0::1]')).toBe(true)
})
it('R2-ISS-058: NAT64 — the local-use prefix 64:ff9b:1::/48 is non-public; the well-known prefix follows its embedded IPv4', () => {
expect(isPrivateIp('64:ff9b:1::c0a8:1')).toBe(true) // 192.168.0.1 behind a local NAT64
expect(isPrivateIp('64:ff9b:1:ffff::1')).toBe(true) // anywhere in the /48
expect(isPrivateIp('64:ff9b::c0a8:1')).toBe(true) // well-known prefix embedding 192.168.0.1
expect(isPrivateIp('64:ff9b::808:808')).toBe(false) // well-known prefix embedding 8.8.8.8
expect(isPrivateIp('64:ff9c::1')).toBe(false) // adjacent public space untouched
})
it('rejects only the IETF /24 and TEST-NET-1, not public 192.0.x.x space', () => {
expect(isPrivateIp('192.0.0.5')).toBe(true) // 192.0.0.0/24 IETF protocol assignments
expect(isPrivateIp('192.0.2.1')).toBe(true) // TEST-NET-1
expect(isPrivateIp('192.0.78.24')).toBe(false) // public (192.0.64.0/18)
expect(isPrivateIp('192.0.1.1')).toBe(false)
})
})