diff --git a/src/sandbox/emitted-connection.ts b/src/sandbox/emitted-connection.ts index 058c8fbd1..6d71eb115 100644 --- a/src/sandbox/emitted-connection.ts +++ b/src/sandbox/emitted-connection.ts @@ -126,6 +126,19 @@ export function keepFlushedHeaderBatches(sock: Socket): void { } } +/** + * A header string holds one char per byte, as the HTTP parser read it from + * the upstream, and Node's server names latin1 when it writes a response's + * head. Bun's server for an emitted connection names no encoding, so the + * socket's default applies: as UTF-8, every header byte from 0x80 up would + * reach the client as two. So latin1 is made the default of a socket about + * to be emitted. Anything else written to it with no encoding named has to + * be bytes or ASCII (see respondDenied). + */ +export function writeHeadsAsLatin1(sock: Socket): void { + if (runsOnBun()) sock.setDefaultEncoding('latin1') +} + /** * Bytes a proxy's connections together may hold for slow parties (request * bodies waiting for an upstream, responses waiting for a client). Each diff --git a/src/sandbox/proxy-only.ts b/src/sandbox/proxy-only.ts index 8905de2a4..7bcd6d488 100644 --- a/src/sandbox/proxy-only.ts +++ b/src/sandbox/proxy-only.ts @@ -32,7 +32,10 @@ import { type Decider, } from './decider-client.js' import { matchesDomainPatternWithPort } from './domain-pattern.js' -import { keepFlushedHeaderBatches } from './emitted-connection.js' +import { + keepFlushedHeaderBatches, + writeHeadsAsLatin1, +} from './emitted-connection.js' import { createHttpProxyServer, markHandedInConnection } from './http-proxy.js' import { createMitmCA, disposeMitmCA, type MitmCA } from './mitm-ca.js' import { canonicalizeHost, type DirectLookup } from './parent-proxy.js' @@ -211,6 +214,7 @@ export function startProxyOnly(opts: ProxyOnlyOptions): ProxyOnly { return } markHandedInConnection(sock) + writeHeadsAsLatin1(sock) server.emit('connection', sock) keepFlushedHeaderBatches(sock) }) diff --git a/src/sandbox/request-filter.ts b/src/sandbox/request-filter.ts index 371a68c0b..fb0568285 100644 --- a/src/sandbox/request-filter.ts +++ b/src/sandbox/request-filter.ts @@ -801,7 +801,9 @@ export function respondDenied( 'Content-Type': 'text/plain', [name]: value, }) - res.end(reason + '\n') + // As bytes: a server may pass a string on to a socket whose default + // encoding is not UTF-8 (see writeHeadsAsLatin1). + res.end(Buffer.from(reason + '\n')) } /** diff --git a/src/sandbox/tls-terminate-proxy.ts b/src/sandbox/tls-terminate-proxy.ts index ef1e315ab..4758e4786 100644 --- a/src/sandbox/tls-terminate-proxy.ts +++ b/src/sandbox/tls-terminate-proxy.ts @@ -26,6 +26,7 @@ import { type ByteBudget, markEndsGracefully, runsOnBun, + writeHeadsAsLatin1, } from './emitted-connection.js' import type { MitmCA } from './mitm-ca.js' import { @@ -544,6 +545,7 @@ export function terminateAndForward( ), ) }) + writeHeadsAsLatin1(tls) inner.emit('connection', tls) keepFlushedHeaderBatches(tls) } diff --git a/src/sandbox/windows-sandbox-utils.ts b/src/sandbox/windows-sandbox-utils.ts index a170d0e9a..56a77854a 100644 --- a/src/sandbox/windows-sandbox-utils.ts +++ b/src/sandbox/windows-sandbox-utils.ts @@ -1774,6 +1774,9 @@ function windowsPathKey(p: string): string { let resolved = p if (!isUncPath(p)) { try { + // The final-path lookup itself, which also resolves 8.3 names and the + // case on disk. + // eslint-disable-next-line no-restricted-properties resolved = fs.realpathSync.native(p) } catch { // Not there, so nothing lies beneath it either. diff --git a/test/sandbox/response-header-bytes.test.ts b/test/sandbox/response-header-bytes.test.ts new file mode 100644 index 000000000..2f8ac4200 --- /dev/null +++ b/test/sandbox/response-header-bytes.test.ts @@ -0,0 +1,223 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import type { ServerResponse } from 'node:http' +import { + connect, + createServer, + type AddressInfo, + type Server, + type Socket, +} from 'node:net' +import { + connect as tlsConnect, + createServer as createTlsServer, + type TLSSocket, +} from 'node:tls' +import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' +import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' +import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js' +import { startProxyOnly } from '../../src/sandbox/proxy-only.js' +import { respondDenied } from '../../src/sandbox/request-filter.js' +import { SERVES_EMITTED_CONNECTIONS } from '../helpers/emitted-connections.js' +import { fakeDecider } from '../helpers/fake-decider.js' + +const cleanup: Array<() => Promise | void> = [] +afterEach(async () => { + while (cleanup.length) await cleanup.pop()!() +}) + +const latin1 = (s: string): Buffer => Buffer.from(s, 'latin1') + +/** Every byte past ASCII, and the headers a client acts on, in both of the encodings seen in the wild. */ +const SENT: Array<[string, Buffer]> = [ + ['x-high', Buffer.from(Array.from({ length: 128 }, (_, i) => 0x80 + i))], + ['location', Buffer.from('/café')], + ['content-disposition', latin1('attachment; filename="caf\xe9.txt"')], + ['set-cookie', latin1('a=\xe9')], + ['set-cookie', Buffer.from('b=é')], +] + +const REASON = 'refused: café ☃' + +/** An upstream that answers in raw bytes: no HTTP server writes these. */ +function answer(socket: Socket): void { + let seen = '' + socket.on('error', () => {}) + socket.on('data', chunk => { + seen += chunk.toString('latin1') + for (let end; (end = seen.indexOf('\r\n\r\n')) >= 0; ) { + seen = seen.slice(end + 4) + socket.write( + Buffer.concat([ + latin1('HTTP/1.1 200 OK\r\n'), + ...SENT.flatMap(([name, value]) => [ + latin1(`${name}: `), + value, + latin1('\r\n'), + ]), + latin1('Content-Length: 2\r\n\r\nok'), + ]), + ) + } + }) +} + +/** Which proxy, and whether the request goes in a TLS-terminated tunnel. */ +const PATHS = [ + 'full mode, plain HTTP', + 'full mode, TLS', + 'srt-proxy, plain HTTP', + 'srt-proxy, TLS', +] as const + +/** Ask for `paths` one after the other on one connection through the proxy; everything the client receives. */ +async function fetchRaw( + path: (typeof PATHS)[number], + paths: string[], +): Promise { + const tls = path.endsWith('TLS') + const authority = generateCa() + const ca = (): ReturnType => + createMitmCA({ caCertPem: authority.certPem, caKeyPem: authority.keyPem }) + let up: Server + if (tls) { + const leaf = mintLeafCert(ca(), '127.0.0.1') + const leafOnly = leaf.certPem.match( + /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----\r?\n?/, + )![0] + up = createTlsServer({ cert: leafOnly, key: leaf.keyPem }, answer) + } else { + up = createServer(answer) + } + await new Promise(r => up.listen(0, '127.0.0.1', r)) + cleanup.push(() => void up.close()) + const target = `127.0.0.1:${(up.address() as AddressInfo).port}` + + let port: number + if (path.startsWith('full mode')) { + const proxy = createHttpProxyServer({ + filter: () => true, + mitmCA: SERVES_EMITTED_CONNECTIONS ? ca() : undefined, + tlsTerminateUpstreamCA: authority.certPem, + filterRequest: async request => + request.url.endsWith('/refuse') + ? { action: 'deny', reason: REASON } + : { action: 'allow' }, + }) + await new Promise(r => proxy.listen(0, '127.0.0.1', r)) + cleanup.push(() => { + proxy.close() + proxy.closeAllConnections() + }) + port = (proxy.address() as AddressInfo).port + } else { + const d = fakeDecider([target], f => + f.path === '/refuse' + ? { action: 'deny', status: 403, reason: REASON } + : { action: 'allow' }, + ) + const proxy = startProxyOnly({ + listen: { host: '127.0.0.1', port: 0 }, + decider: d.streams, + deciderTimeoutMs: 2000, + ...(SERVES_EMITTED_CONNECTIONS + ? { + caPem: authority.keyPem + authority.certPem, + upstreamCA: authority.certPem, + } + : {}), + }) + cleanup.push(() => proxy.close()) + await proxy.ready + port = (proxy.listener.address() as AddressInfo).port + } + + let sock: Socket | TLSSocket = await new Promise( + (resolve, reject) => { + const c = connect(port, '127.0.0.1', () => resolve(c)) + c.on('error', reject) + }, + ) + if (tls) { + const raw = sock + raw.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`) + await new Promise(resolve => raw.once('data', () => resolve())) + sock = await new Promise((resolve, reject) => { + const t = tlsConnect({ + socket: raw, + ca: authority.certPem, + checkServerIdentity: () => undefined, + }) + t.on('error', reject) + t.once('secureConnect', () => resolve(t)) + }) + } + sock.on('error', () => {}) + let got = Buffer.alloc(0) + let onData = (): void => {} + sock.on('data', (chunk: Buffer) => { + got = Buffer.concat([got, chunk]) + onData() + }) + const closed = new Promise(r => sock.once('close', () => r())) + for (const [i, p] of paths.entries()) { + const last = i === paths.length - 1 + sock.write( + `GET ${tls ? p : `http://${target}${p}`} HTTP/1.1\r\nHost: ${target}\r\n` + + `${last ? 'Connection: close\r\n' : ''}\r\n`, + ) + // The next request goes once this one's answer is in. + await (last + ? closed + : new Promise(resolve => { + onData = () => { + if (got.subarray(-2).toString() === 'ok') resolve() + } + })) + } + return got +} + +describe.each([...PATHS])('bytes past ASCII through the proxy (%s)', path => { + const run = test.skipIf(path.endsWith('TLS') && !SERVES_EMITTED_CONNECTIONS) + + run( + 'a response header value reaches the client as the upstream sent it', + async () => { + // Twice on one connection: every head on it, not only the first. + const lines = (await fetchRaw(path, ['/one', '/two'])) + .toString('latin1') + .split('\r\n') + const once = SENT.map( + ([name, value]) => `${name}: ${value.toString('latin1')}`, + ) + expect( + lines.filter(l => SENT.some(([name]) => l.startsWith(`${name}:`))), + ).toEqual([...once, ...once]) + }, + ) + + run("a refusal's reason is UTF-8, after a relayed response too", async () => { + const raw = await fetchRaw(path, ['/one', '/refuse']) + const refusal = raw.subarray(raw.indexOf('HTTP/1.1 403')) + const body = Buffer.from(`${REASON}\n`) + expect( + refusal.subarray(refusal.indexOf('\r\n\r\n') + 4).toString('latin1'), + ).toBe( + `${body.length.toString(16)}\r\n${body.toString('latin1')}\r\n0\r\n\r\n`, + ) + }) +}) + +test("a refusal's reason is handed to the response as bytes", () => { + let body: unknown + respondDenied( + { + writeHead: () => {}, + end: (chunk: unknown) => { + body = chunk + }, + } as unknown as ServerResponse, + REASON, + ) + expect(body).toEqual(Buffer.from(`${REASON}\n`)) +})