mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/mihomo-party-org/clash-party.git
synced 2026-09-20 08:03:39 +08:00
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>
612 lines
22 KiB
TypeScript
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/')
|
|
})
|
|
})
|