Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ CONNECT headers are no longer merged into origin `response.headers`. Use `respon

`agent.lastProxyHeaders` is last-write-wins. Prefer per-response `proxyHeaders` or `getProxyHeaders()`.

The got adapter is HTTPS-only: `http://` URLs and HTTPS→HTTP redirects are rejected so they cannot bypass the proxy. The undici adapter wraps origin TLS on the CONNECT socket (`httpSocket`) rather than dialing the target directly.

## Quick Examples

### axios
Expand Down
2 changes: 2 additions & 0 deletions docs/got.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ const response = await proxyPost('https://httpbin.org/post', {

## Accessing Proxy Headers

This adapter is HTTPS-only. `http://` URLs and HTTPS→HTTP redirects are rejected so traffic cannot silently bypass the proxy (got would otherwise use a default HTTP agent and connect directly).

CONNECT response headers are on `response.proxyHeaders` (a `Map`). They are **not** copied onto origin `response.headers`.

```javascript
Expand Down
3 changes: 3 additions & 0 deletions docs/undici.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ Make an HTTP request through a proxy with custom headers.
| `url` | `string \| URL` | Target URL |
| `options.proxy` | `string` | Proxy URL (required) |
| `options.proxyHeaders` | `Object` | Headers to send to the proxy |
| `options.proxyTlsOptions` | `Object` | TLS options for an `https://` proxy |
| `options.tlsOptions` | `Object` | TLS options for the origin connection over the CONNECT tunnel |
| `options.connectTimeout` | `number` | Origin CONNECT/TLS handshake timeout in milliseconds |
| `options.method` | `string` | HTTP method (default: 'GET') |
| `options.headers` | `Object` | Request headers |
| `options.body` | `*` | Request body |
Expand Down
20 changes: 20 additions & 0 deletions lib/got-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,32 @@ export async function createProxyGot(options) {
proxyTlsOptions,
});

const rejectHttpUrl = (options) => {
if (options?.url?.protocol === 'http:') {
throw new TypeError(
'javascript-proxy-headers/got only supports HTTPS; HTTP would bypass the proxy',
);
}
};

const instance = got.extend({
...gotOptions,
agent: {
https: agent,
// Same agent as HTTPS so a missed hook cannot fall back to got's
// default HTTP agent (direct connect). https.Agent rejects http:.
http: agent,
},
hooks: {
...gotOptions.hooks,
beforeRequest: [
...(gotOptions.hooks?.beforeRequest || []),
rejectHttpUrl,
],
beforeRedirect: [
...(gotOptions.hooks?.beforeRedirect || []),
rejectHttpUrl,
],
afterResponse: [
(response) => {
response.proxyHeaders = getProxyHeaders(response) || new Map();
Expand Down
44 changes: 40 additions & 4 deletions lib/undici-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,16 @@ async function createProxyTunnel(options) {
return;
}

// The socket is still flowing, and removeAllListeners('data') does
// not clear Node's data-listening flag, so unshift would emit the
// tail and drop it. Pause first so bytes that arrived with (or
// right after) the CONNECT response stay buffered for TLS.
socket.pause();
const headerEnd = buffer.indexOf('\r\n\r\n');
if (headerEnd !== -1 && headerEnd + 4 < buffer.length) {
socket.unshift(Buffer.from(buffer.subarray(headerEnd + 4)));
}

resolve({
socket,
proxyHeaders: response.headers,
Expand All @@ -115,6 +125,9 @@ async function createProxyTunnel(options) {
* @param {Object} options - Request options
* @param {string} options.proxy - Proxy URL (e.g., 'http://user:pass@proxy:8080')
* @param {Object} options.proxyHeaders - Headers to send to the proxy
* @param {Object} [options.proxyTlsOptions] - TLS options for an https:// proxy
* @param {Object} [options.tlsOptions] - TLS options for the origin connection over the tunnel
* @param {number} [options.connectTimeout] - Origin CONNECT/TLS handshake timeout in milliseconds
* @param {string} options.method - HTTP method (default: 'GET')
* @param {Object} options.headers - Request headers
* @param {*} options.body - Request body
Expand All @@ -132,7 +145,14 @@ async function createProxyTunnel(options) {
* console.log(proxyHeaders.get('x-proxymesh-ip'));
*/
export async function request(url, options = {}) {
const { proxy, proxyHeaders = {}, proxyTlsOptions = {}, ...requestOptions } = options;
const {
proxy,
proxyHeaders = {},
proxyTlsOptions = {},
tlsOptions = {},
connectTimeout,
...requestOptions
} = options;

if (!proxy) {
throw new Error('proxy option is required');
Expand Down Expand Up @@ -179,10 +199,26 @@ export async function request(url, options = {}) {
proxyTlsOptions,
});

// undici's connect.socket option is a TLS setting, not a pre-connected
// tunnel. Pass the CONNECT socket as httpSocket so origin TLS is wrapped
// on the proxy hop instead of dialing the target directly.
// Client connectTimeout is ignored when `connect` is a function; the
// timer has to be the connector's own timeout.
const connector = typeof undici.buildConnector === 'function'
? undici.buildConnector({
...tlsOptions,
...(connectTimeout != null ? { timeout: connectTimeout } : {}),
})
: null;
const client = new undici.Client(`https://${targetHost}:${targetPort}`, {
connect: {
socket: tunnel.socket,
},
...(connectTimeout != null ? { connectTimeout } : {}),
connect: connector
? (opts, callback) => {
connector({ ...opts, httpSocket: tunnel.socket }, callback);
}
: (opts, callback) => {
callback(new Error('undici.buildConnector is required to use the CONNECT tunnel'));
},
});

try {
Expand Down
209 changes: 209 additions & 0 deletions test/test_connect_security.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import tls from 'node:tls';
import http from 'node:http';
import https from 'node:https';

import {
Expand All @@ -21,8 +22,10 @@ import {
} from '../lib/core/utils.js';
import { ProxyHeadersAgent } from '../lib/core/proxy-headers-agent.js';
import { createProxyAxios } from '../lib/axios-proxy.js';
import { createProxyGot } from '../lib/got-proxy.js';
import { createProxyMakeFetchHappen } from '../lib/make-fetch-happen-proxy.js';
import { createProxyRestClient } from '../lib/typed-rest-client-proxy.js';
import { request as undiciProxyRequest } from '../lib/undici-proxy.js';
import { getProxyHeaders } from '../lib/core/proxy-headers-store.js';

test('buildConnectRequest rejects CRLF in target host', () => {
Expand Down Expand Up @@ -555,3 +558,209 @@ test('getProxyHeaders reads CONNECT headers from the TLS socket', async () => {
cleanup();
}
});

test('undici HTTPS sends origin traffic through the CONNECT tunnel', async () => {
const { cert, key, cleanup } = makeSelfSignedCert();
const proxy = createMitmConnectProxy({
cert,
key,
headerFactory: () => 'X-ProxyMesh-IP: 203.0.113.80\r\n',
});
await listen(proxy);
try {
const { port } = proxy.address();
const { statusCode, body, proxyHeaders } = await undiciProxyRequest(
'https://127.0.0.1/',
{
proxy: `http://127.0.0.1:${port}`,
tlsOptions: { rejectUnauthorized: false },
},
);
const data = await body.json();
assert.equal(statusCode, 200);
assert.equal(data.ok, true);
assert.equal(proxyHeaders.get('x-proxymesh-ip'), '203.0.113.80');
} finally {
proxy.close();
cleanup();
}
});

test('undici HTTPS does not dial the origin when CONNECT is not forwarded', async () => {
const { cert, key, cleanup } = makeSelfSignedCert();
const originHits = [];
const origin = https.createServer({ cert, key }, (req, res) => {
originHits.push(req.url);
res.end('{"via":"origin"}');
});
origin.on('connection', () => originHits.push('tcp'));
await listen(origin);
const originPort = origin.address().port;

const proxy = net.createServer((sock) => {
let buf = Buffer.alloc(0);
let replied = false;
sock.on('data', (d) => {
if (replied) return;
buf = Buffer.concat([buf, d]);
if (!buf.includes('\r\n\r\n')) return;
replied = true;
sock.write('HTTP/1.1 200 Connection Established\r\nX-ProxyMesh-IP: 198.51.100.9\r\n\r\n');
});
sock.on('error', () => {});
});
await listen(proxy);
const proxyPort = proxy.address().port;

try {
const started = Date.now();
await assert.rejects(
() => undiciProxyRequest(`https://127.0.0.1:${originPort}/secret`, {
proxy: `http://127.0.0.1:${proxyPort}`,
tlsOptions: { rejectUnauthorized: false },
connectTimeout: 500,
}),
(err) => {
assert.equal(err.code, 'UND_ERR_CONNECT_TIMEOUT');
assert.match(String(err.message), /timeout: 500ms/);
return true;
},
);
const elapsed = Date.now() - started;
assert.ok(elapsed < 4000, `connectTimeout was ignored (elapsed ${elapsed}ms)`);
await delay(50);
assert.equal(originHits.length, 0);
} finally {
origin.close();
proxy.close();
cleanup();
}
});

test('undici feeds bytes pipelined with the CONNECT response into origin TLS', async () => {
const proxy = net.createServer((sock) => {
let buf = Buffer.alloc(0);
let replied = false;
sock.on('data', (d) => {
if (replied) return;
buf = Buffer.concat([buf, d]);
if (!buf.includes('\r\n\r\n')) return;
replied = true;
sock.write('HTTP/1.1 200 Connection Established\r\n\r\nNOT-A-TLS-RECORD');
});
sock.on('error', () => {});
});
await listen(proxy);
const proxyPort = proxy.address().port;

try {
const started = Date.now();
await assert.rejects(
() => undiciProxyRequest('https://127.0.0.1/', {
proxy: `http://127.0.0.1:${proxyPort}`,
tlsOptions: { rejectUnauthorized: false },
connectTimeout: 5000,
}),
);
const elapsed = Date.now() - started;
assert.ok(
elapsed < 1500,
`pipelined tunnel bytes were dropped (elapsed ${elapsed}ms)`,
);
} finally {
proxy.close();
}
});

test('got rejects plain HTTP so it cannot bypass the proxy', async () => {
const httpHits = [];
const httpOrigin = http.createServer((req, res) => {
httpHits.push(req.url);
res.end('{"via":"http"}');
});
await listen(httpOrigin);
const httpPort = httpOrigin.address().port;

const proxyHits = [];
const proxy = net.createServer((sock) => {
sock.on('data', (d) => proxyHits.push(d.toString('utf8').split('\r\n')[0]));
sock.on('error', () => {});
});
await listen(proxy);
const proxyPort = proxy.address().port;

try {
const client = await createProxyGot({
proxy: `http://127.0.0.1:${proxyPort}`,
gotOptions: { throwHttpErrors: false },
});
await assert.rejects(
() => client(`http://127.0.0.1:${httpPort}/plain`),
/only supports HTTPS/,
);
assert.equal(httpHits.length, 0);
assert.equal(proxyHits.length, 0);
} finally {
httpOrigin.close();
proxy.close();
}
});

test('got refuses HTTPS to HTTP redirects that would bypass the proxy', async () => {
const { cert, key, cleanup } = makeSelfSignedCert();
const httpHits = [];
const httpOrigin = http.createServer((req, res) => {
httpHits.push(req.url);
res.end('{"via":"http-direct"}');
});
await listen(httpOrigin);
const httpPort = httpOrigin.address().port;

const proxy = net.createServer((sock) => {
let buf = Buffer.alloc(0);
let handed = false;
sock.on('data', (d) => {
if (handed) return;
buf = Buffer.concat([buf, d]);
if (buf.indexOf('\r\n\r\n') === -1) return;
handed = true;
sock.write('HTTP/1.1 200 Connection Established\r\nX-ProxyMesh-IP: 198.51.100.9\r\n\r\n');
const tlsSock = new tls.TLSSocket(sock, { isServer: true, cert, key });
tlsSock.on('secure', () => {
let httpBuf = Buffer.alloc(0);
tlsSock.on('data', (chunk) => {
httpBuf = Buffer.concat([httpBuf, chunk]);
if (httpBuf.indexOf('\r\n\r\n') === -1) return;
const location = `http://127.0.0.1:${httpPort}/leaked`;
tlsSock.end(
'HTTP/1.1 302 Found\r\n' +
`Location: ${location}\r\n` +
'Content-Length: 0\r\nConnection: close\r\n\r\n',
);
});
});
});
sock.on('error', () => {});
});
await listen(proxy);
const proxyPort = proxy.address().port;

try {
const client = await createProxyGot({
proxy: `http://127.0.0.1:${proxyPort}`,
gotOptions: { throwHttpErrors: false },
});
client.proxyAgent.tlsOptions = { rejectUnauthorized: false };

await assert.rejects(
() => client('https://127.0.0.1/start'),
/HTTP would bypass the proxy/,
);
await delay(50);
assert.equal(httpHits.length, 0);
} finally {
httpOrigin.close();
proxy.close();
cleanup();
}
});
1 change: 1 addition & 0 deletions test/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ async function typecheck() {
const undiciOptions: UndiciRequestOptions = {
proxy: "http://proxy.example.com:8080",
headers: { "x-test": "1" },
tlsOptions: { rejectUnauthorized: true },
};
const undiciResp: UndiciResponse = await request("https://example.com", undiciOptions);
void undiciResp.proxyHeaders;
Expand Down
4 changes: 4 additions & 0 deletions types/undici.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export interface UndiciRequestOptions {
proxyHeaders?: Record<string, string>;
/** TLS options for an https:// proxy */
proxyTlsOptions?: object;
/** TLS options for the origin connection over the CONNECT tunnel */
tlsOptions?: object;
/** CONNECT/TLS handshake timeout in ms for the origin connection */
connectTimeout?: number;
/** HTTP method */
method?: string;
/** Request headers */
Expand Down