Files
clash-party/src/main/resolve/plugin/http-client.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

612 lines
22 KiB
TypeScript

import http from 'http'
import net from 'net'
import tls from 'tls'
import { execFileSync } from 'child_process'
import { mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { describe, it, expect, afterEach } from 'vitest'
import { requestOnce, buildProxyUrl } from './http-client'
let server: http.Server | undefined
afterEach(() => server?.close())
// A throwaway self-signed cert for the TLS-over-tunnel tests (skipped when openssl is unavailable)
function selfSignedCert(): { key: string; cert: string } | undefined {
const dir = mkdtempSync(join(tmpdir(), 'cpx-tls-'))
try {
execFileSync(
'openssl',
[
'req',
'-x509',
'-newkey',
'ec',
'-pkeyopt',
'ec_paramgen_curve:prime256v1',
'-nodes',
'-keyout',
join(dir, 'key.pem'),
'-out',
join(dir, 'cert.pem'),
'-days',
'1',
'-subj',
'/CN=target.invalid'
],
{ stdio: 'ignore' }
)
return {
key: readFileSync(join(dir, 'key.pem'), 'utf8'),
cert: readFileSync(join(dir, 'cert.pem'), 'utf8')
}
} catch {
return undefined
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
const TLS_FIXTURE = selfSignedCert()
async function waitFor(cond: () => boolean, ms = 1000): Promise<void> {
const until = Date.now() + ms
while (!cond() && Date.now() < until) await new Promise((r) => setTimeout(r, 10))
}
function start(handler: http.RequestListener): Promise<string> {
return new Promise((resolve) => {
server = http.createServer(handler)
server.listen(0, '127.0.0.1', () => {
const addr = server!.address()
const port = typeof addr === 'object' && addr ? addr.port : 0
resolve(`http://127.0.0.1:${port}`)
})
})
}
describe('requestOnce', () => {
it('performs a GET and returns status + body', async () => {
const url = await start((_req, res) => {
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{"ok":true}')
})
const r = await requestOnce(url + '/x', { method: 'GET', timeout: 5000, maxBytes: 1024 })
expect(r.status).toBe(200)
expect(r.body).toBe('{"ok":true}')
})
it('sends a POST body', async () => {
const url = await start((req, res) => {
const chunks: Buffer[] = []
req.on('data', (c) => chunks.push(c))
req.on('end', () => {
res.writeHead(200)
res.end(Buffer.concat(chunks).toString('utf-8'))
})
})
const r = await requestOnce(url + '/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"email":"a"}',
timeout: 5000,
maxBytes: 1024
})
expect(r.body).toBe('{"email":"a"}')
})
it('rejects redirects instead of following', async () => {
const url = await start((_req, res) => {
res.writeHead(302, { location: 'https://evil.example' })
res.end()
})
await expect(
requestOnce(url + '/r', { method: 'GET', timeout: 5000, maxBytes: 1024 })
).rejects.toMatchObject({
message: expect.stringMatching(/redirect/i),
code: 'CPX_REDIRECT_REFUSED',
phase: 'possibly-sent'
})
})
it('rejects oversized responses', async () => {
const url = await start((_req, res) => {
res.writeHead(200)
res.end('x'.repeat(5000))
})
await expect(
requestOnce(url + '/big', { method: 'GET', timeout: 5000, maxBytes: 1000 })
).rejects.toMatchObject({
message: expect.stringMatching(/too large/i),
code: 'CPX_RESPONSE_TOO_LARGE',
phase: 'possibly-sent'
})
})
it('rejects forbidden headers', async () => {
const url = await start((_req, res) => res.end('ok'))
await expect(
requestOnce(url + '/h', {
method: 'GET',
headers: { Host: 'evil' },
timeout: 5000,
maxBytes: 1024
})
).rejects.toThrow(/forbidden/i)
})
it('rejects too many request headers', async () => {
const url = await start((_req, res) => res.end('ok'))
await expect(
requestOnce(url + '/h', {
method: 'GET',
headers: Object.fromEntries(Array.from({ length: 33 }, (_, i) => [`X-Test-${i}`, 'v'])),
timeout: 5000,
maxBytes: 1024
})
).rejects.toThrow(/headers/i)
})
it('rejects oversized request headers', async () => {
const url = await start((_req, res) => res.end('ok'))
await expect(
requestOnce(url + '/h', {
method: 'GET',
headers: { 'X-Large': 'x'.repeat(16 * 1024 + 1) },
timeout: 5000,
maxBytes: 1024
})
).rejects.toThrow(/headers/i)
})
it('times out slow responses', async () => {
const url = await start((_req, res) => {
setTimeout(() => res.end('late'), 200)
})
await expect(
requestOnce(url + '/slow', { method: 'GET', timeout: 50, maxBytes: 1024 })
).rejects.toMatchObject({ message: expect.stringMatching(/timed out/i), code: 'CPX_TIMEOUT' })
})
it('aborts on the op signal and maps the abort to CPX_TIMEOUT', async () => {
const url = await start((_req, res) => {
setTimeout(() => res.end('late'), 500)
})
const ac = new AbortController()
setTimeout(() => ac.abort(new Error('budget exhausted')), 20)
await expect(
requestOnce(url + '/hang', {
method: 'GET',
timeout: 5000,
maxBytes: 1024,
signal: ac.signal
})
).rejects.toMatchObject({ code: 'CPX_TIMEOUT' })
})
it('routes the request through the configured proxy when proxy is set', async () => {
const seen: string[] = []
const proxy = http.createServer((req, res) => {
seen.push(req.url ?? '')
res.writeHead(200, { 'content-type': 'text/plain' })
res.end('via-proxy')
})
await new Promise<void>((r) => proxy.listen(0, '127.0.0.1', () => r()))
const proxyPort = (proxy.address() as { port: number }).port
try {
// target.invalid 永不可解析:没走代理就连不上,证明请求确实经由代理发出
const res = await requestOnce('http://target.invalid/getSub', {
method: 'GET',
timeout: 5000,
maxBytes: 4096,
proxy: { host: '127.0.0.1', port: proxyPort }
})
expect(res.body).toBe('via-proxy')
expect(seen).toContain('http://target.invalid/getSub')
} finally {
proxy.close()
}
})
it('marks a connection refusal as pre-send with its errno code', async () => {
const probe = http.createServer()
await new Promise<void>((r) => probe.listen(0, '127.0.0.1', () => r()))
const port = (probe.address() as { port: number }).port
await new Promise<void>((r) => probe.close(() => r()))
await expect(
requestOnce(`http://127.0.0.1:${port}/x`, { method: 'GET', timeout: 5000, maxBytes: 1024 })
).rejects.toMatchObject({ code: 'ECONNREFUSED', phase: 'pre-send' })
})
it('marks a reset after the request was sent as possibly-sent', async () => {
const url = await start((req) => {
req.socket.destroy()
})
await expect(
requestOnce(url + '/reset', { method: 'GET', timeout: 5000, maxBytes: 1024 })
).rejects.toMatchObject({ code: 'ECONNRESET', phase: 'possibly-sent' })
})
it('aborts a lookup that never returns via the signal and maps it to CPX_TIMEOUT', async () => {
const ac = new AbortController()
setTimeout(() => ac.abort(new Error('budget exhausted')), 30)
await expect(
requestOnce('http://never.example/x', {
method: 'GET',
timeout: 5000,
maxBytes: 1024,
signal: ac.signal,
lookup: () => {
/* never calls back */
}
})
).rejects.toMatchObject({ code: 'CPX_TIMEOUT', phase: 'pre-send' })
})
it('R2-ISS-007: a proxy that accepts the connection but never answers CONNECT is cut off by the signal', async () => {
const sockets: net.Socket[] = []
let closed = 0
const proxy = net.createServer((socket) => {
sockets.push(socket) // accept, never answer the CONNECT
socket.resume() // a paused server socket never reads, so it would never observe the client's FIN
socket.on('close', () => closed++)
})
await new Promise<void>((r) => proxy.listen(0, '127.0.0.1', () => r()))
const port = (proxy.address() as { port: number }).port
const ac = new AbortController()
setTimeout(() => ac.abort(new Error('budget exhausted')), 50)
const started = Date.now()
try {
await expect(
requestOnce('https://target.invalid/x', {
method: 'GET',
timeout: 5000,
maxBytes: 1024,
signal: ac.signal,
proxy: { host: '127.0.0.1', port }
})
).rejects.toMatchObject({ code: 'CPX_TIMEOUT' })
expect(Date.now() - started).toBeLessThan(2000)
// BL-004 (ISS-007 residual): the hung CONNECT connection is torn down by the abort, not left to the proxy
expect(sockets).toHaveLength(1)
await waitFor(() => closed === sockets.length)
expect(closed).toBe(1)
} finally {
proxy.close()
sockets.forEach((s) => s.destroy())
}
})
it('R2-ISS-054: an invalid proxy port (mixed-port disabled → 0) is refused before any connection', async () => {
for (const port of [0, -1, 70000, 1.5, NaN]) {
const started = Date.now()
await expect(
requestOnce('https://target.invalid/x', {
method: 'GET',
timeout: 5000,
maxBytes: 1024,
proxy: { host: '127.0.0.1', port, auth: { user: 'u', pass: 'p' } }
})
).rejects.toMatchObject({ code: 'CPX_PROXY_CONNECT_FAILED', phase: 'pre-send' })
expect(Date.now() - started).toBeLessThan(500)
}
})
it('BL-004: the CONNECT request carries the target authority and the proxy credentials', async () => {
const sockets: net.Socket[] = []
let head = ''
const proxy = net.createServer((socket) => {
sockets.push(socket)
socket.once('data', (d) => {
head = d.toString('latin1')
socket.end('HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n')
})
})
await new Promise<void>((r) => proxy.listen(0, '127.0.0.1', () => r()))
const port = (proxy.address() as { port: number }).port
try {
await expect(
requestOnce('https://target.invalid:8443/x', {
method: 'GET',
timeout: 5000,
maxBytes: 1024,
proxy: { host: '127.0.0.1', port, auth: { user: 'us@er', pass: 'p:a/ss' } }
})
).rejects.toMatchObject({ code: 'CPX_PROXY_CONNECT_FAILED', phase: 'pre-send' })
expect(head.startsWith('CONNECT target.invalid:8443 HTTP/1.1\r\n')).toBe(true)
expect(head).toContain('\r\nHost: target.invalid:8443\r\n')
expect(head).toContain(
`\r\nProxy-Authorization: Basic ${Buffer.from('us@er:p:a/ss').toString('base64')}\r\n`
)
} finally {
proxy.close()
sockets.forEach((s) => s.destroy())
}
})
it.skipIf(!TLS_FIXTURE)(
'R2-ISS-059: a request over the tunnel carries the right Host (no :80) and completes end to end',
async () => {
const sockets: net.Socket[] = []
const hosts: string[] = []
const proxy = net.createServer((socket) => {
sockets.push(socket)
socket.once('data', () => {
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
// from here on the proxy plays the target: a TLS server on the same connection
const secure = new tls.TLSSocket(socket, { isServer: true, ...TLS_FIXTURE })
secure.on('error', () => undefined)
secure.once('data', (d) => {
const m = /\r\nHost: ([^\r]+)\r\n/i.exec(d.toString('latin1'))
hosts.push(m?.[1] ?? '')
secure.end('HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok')
})
})
})
await new Promise<void>((r) => proxy.listen(0, '127.0.0.1', () => r()))
const port = (proxy.address() as { port: number }).port
const saved = process.env.NODE_TLS_REJECT_UNAUTHORIZED
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' // self-signed target; verification is not under test here
try {
const res = await requestOnce('https://target.invalid/x', {
method: 'GET',
timeout: 5000,
maxBytes: 1024,
proxy: { host: '127.0.0.1', port }
})
expect(res.status).toBe(200)
expect(res.body).toBe('ok')
const res2 = await requestOnce('https://target.invalid:8443/x', {
method: 'GET',
timeout: 5000,
maxBytes: 1024,
proxy: { host: '127.0.0.1', port }
})
expect(res2.body).toBe('ok')
expect(hosts).toEqual(['target.invalid', 'target.invalid:8443'])
} finally {
if (saved === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED
else process.env.NODE_TLS_REJECT_UNAUTHORIZED = saved
proxy.close()
sockets.forEach((s) => s.destroy())
}
}
)
it('BL-004: after CONNECT 200 the client starts TLS on the tunnel with the target name as SNI', async () => {
const sockets: net.Socket[] = []
let hello: Buffer | undefined
const proxy = net.createServer((socket) => {
sockets.push(socket)
socket.once('data', () => {
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
socket.once('data', (d) => {
hello = d
socket.destroy()
})
})
})
await new Promise<void>((r) => proxy.listen(0, '127.0.0.1', () => r()))
const port = (proxy.address() as { port: number }).port
try {
const err = await requestOnce('https://target.invalid/x', {
method: 'GET',
timeout: 5000,
maxBytes: 1024,
proxy: { host: '127.0.0.1', port }
}).then(
() => undefined,
(e: unknown) => e as { phase?: string }
)
expect(err?.phase).toBe('pre-send')
// TLS handshake record (0x16) carrying a ClientHello whose SNI is the target hostname
expect(hello?.[0]).toBe(0x16)
expect(hello?.includes('target.invalid')).toBe(true)
} finally {
proxy.close()
sockets.forEach((s) => s.destroy())
}
})
it('R2-ISS-022: a slow-drip response is cut off by the wall-clock timeout, not the idle timeout', async () => {
const sockets: net.Socket[] = []
const drip = net.createServer((socket) => {
sockets.push(socket)
socket.write('HTTP/1.1 200 OK\r\n')
const t = setInterval(() => socket.write('X-Drip: 1\r\n'), 40) // never finishes the headers
socket.on('close', () => clearInterval(t))
})
await new Promise<void>((r) => drip.listen(0, '127.0.0.1', () => r()))
const port = (drip.address() as { port: number }).port
const started = Date.now()
try {
await expect(
requestOnce(`http://127.0.0.1:${port}/x`, { method: 'GET', timeout: 300, maxBytes: 1024 })
).rejects.toMatchObject({ code: 'CPX_TIMEOUT' })
expect(Date.now() - started).toBeLessThan(1500)
} finally {
drip.close()
sockets.forEach((s) => s.destroy())
}
})
it('R2-ISS-039: a proxy that rejects CONNECT is a pre-send connection failure, not a target response', async () => {
const sockets: net.Socket[] = []
const proxy = net.createServer((socket) => {
sockets.push(socket)
socket.once('data', () => {
socket.end('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n')
})
})
await new Promise<void>((r) => proxy.listen(0, '127.0.0.1', () => r()))
const port = (proxy.address() as { port: number }).port
try {
const err = await requestOnce('https://target.invalid/x', {
method: 'GET',
timeout: 5000,
maxBytes: 1024,
proxy: { host: '127.0.0.1', port }
}).then(
() => undefined,
(e: unknown) => e as { code?: string; phase?: string; status?: number }
)
expect(err).toMatchObject({ code: 'CPX_PROXY_CONNECT_FAILED', phase: 'pre-send' })
expect(err?.status).toBeUndefined()
} finally {
proxy.close()
sockets.forEach((s) => s.destroy())
}
})
it('R2-ISS-043: a proxy that closes before answering CONNECT is a pre-send tunnel failure', async () => {
const sockets: net.Socket[] = []
const proxy = net.createServer((socket) => {
sockets.push(socket)
socket.once('data', () => socket.end()) // FIN without any CONNECT response
})
await new Promise<void>((r) => proxy.listen(0, '127.0.0.1', () => r()))
const port = (proxy.address() as { port: number }).port
try {
await expect(
requestOnce('https://target.invalid/x', {
method: 'GET',
timeout: 5000,
maxBytes: 1024,
proxy: { host: '127.0.0.1', port }
})
).rejects.toMatchObject({ code: 'CPX_PROXY_CONNECT_FAILED', phase: 'pre-send' })
} finally {
proxy.close()
sockets.forEach((s) => s.destroy())
}
})
it('R2-ISS-042: a TLS handshake failure over a proxy tunnel is pre-send (phase not flipped before secureConnect)', async () => {
const sockets: net.Socket[] = []
const proxy = net.createServer((socket) => {
sockets.push(socket)
socket.once('data', () => {
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
// answer the ClientHello with non-TLS bytes → handshake fails before secureConnect
setTimeout(() => socket.write('not a tls server\n'), 15)
})
})
await new Promise<void>((r) => proxy.listen(0, '127.0.0.1', () => r()))
const port = (proxy.address() as { port: number }).port
try {
const err = await requestOnce('https://target.invalid/x', {
method: 'GET',
timeout: 5000,
maxBytes: 1024,
proxy: { host: '127.0.0.1', port }
}).then(
() => undefined,
(e: unknown) => e as { phase?: string; code?: string }
)
// the handshake never completed → the request was never sent → pre-send (enroll may retry)
expect(err?.phase).toBe('pre-send')
} finally {
proxy.close()
sockets.forEach((s) => s.destroy())
}
})
it('R2-ISS-035: every direct request runs the guarded lookup — no connection reuse across requests', async () => {
const url = new URL(
await start((_req, res) => {
res.writeHead(200)
res.end('ok')
})
)
let lookups = 0
const lookup = ((hostname: string, options: unknown, callback?: unknown): void => {
lookups++
const cb = (typeof options === 'function' ? options : callback) as (
err: null,
address: string | { address: string; family: number }[],
family?: number
) => void
const all =
typeof options === 'object' && options !== null && (options as { all?: boolean }).all
if (all) cb(null, [{ address: '127.0.0.1', family: 4 }])
else cb(null, '127.0.0.1', 4)
}) as unknown as import('net').LookupFunction
const target = `http://guarded.invalid:${url.port}/x`
await requestOnce(target, { method: 'GET', timeout: 5000, maxBytes: 1024, lookup })
await requestOnce(target, { method: 'GET', timeout: 5000, maxBytes: 1024, lookup })
expect(lookups).toBe(2)
})
it('R2-ISS-030: a synchronous request-construction failure rejects cleanly and never fires the timer', async () => {
// a negative timeout makes http.request throw before any socket exists
await expect(
requestOnce('http://127.0.0.1:1/x', { method: 'GET', timeout: -1000, maxBytes: 1024 })
).rejects.toMatchObject({ code: 'ERR_OUT_OF_RANGE', phase: 'pre-send' })
// the wall-clock timer (scheduled for "-1000ms" → immediately) must have been cleared: an
// uncaught ReferenceError here would fail the test run
await new Promise((r) => setTimeout(r, 20))
})
it('R2-ISS-009: a failure after the response headers carries the status (503 then reset)', async () => {
const url = await start((_req, res) => {
// declare more body than is sent so the client has parsed the headers and is waiting on the
// body when the socket is reset
res.writeHead(503, { 'content-length': '100' })
res.write('partial')
setTimeout(() => res.socket?.destroy(), 50)
})
await expect(
requestOnce(url + '/x', { method: 'GET', timeout: 5000, maxBytes: 1024 })
).rejects.toMatchObject({ status: 503, phase: 'possibly-sent' })
})
it('R2-ISS-009: redirect refusal and oversize responses carry the status too', async () => {
const url = await start((req, res) => {
if (req.url === '/r') {
res.writeHead(302, { location: '/x' })
res.end()
} else {
res.writeHead(200)
res.end('x'.repeat(100))
}
})
await expect(
requestOnce(url + '/r', { method: 'GET', timeout: 5000, maxBytes: 1024 })
).rejects.toMatchObject({ code: 'CPX_REDIRECT_REFUSED', status: 302 })
await expect(
requestOnce(url + '/big', { method: 'GET', timeout: 5000, maxBytes: 10 })
).rejects.toMatchObject({ code: 'CPX_RESPONSE_TOO_LARGE', status: 200 })
})
it('sends URL-encoded proxy credentials (containing @, : and /) as Proxy-Authorization', async () => {
const seen: string[] = []
const proxy = http.createServer((req, res) => {
seen.push(req.headers['proxy-authorization'] ?? '')
res.writeHead(200)
res.end('ok')
})
await new Promise<void>((r) => proxy.listen(0, '127.0.0.1', () => r()))
const proxyPort = (proxy.address() as { port: number }).port
try {
const res = await requestOnce('http://target.invalid/x', {
method: 'GET',
timeout: 5000,
maxBytes: 4096,
proxy: { host: '127.0.0.1', port: proxyPort, auth: { user: 'us@er', pass: 'p:a/ss' } }
})
expect(res.body).toBe('ok')
expect(seen).toEqual([`Basic ${Buffer.from('us@er:p:a/ss').toString('base64')}`])
} finally {
proxy.close()
}
})
it('buildProxyUrl encodes credentials through the URL object instead of string concatenation', () => {
const u = new URL(
buildProxyUrl({ host: '127.0.0.1', port: 1, auth: { user: 'us@er', pass: 'p:a/ss' } })
)
expect(u.username).toBe('us%40er')
expect(u.password).toBe('p%3Aa%2Fss')
expect(buildProxyUrl({ host: '::1', port: 2 })).toBe('http://[::1]:2/')
})
})