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
3 changes: 3 additions & 0 deletions .github/workflows/proxy_integration_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Run unit tests
run: npm run test:unit

- name: Require PROXY_URL Actions secret
env:
PROXY_URL: ${{ secrets.PROXY_URL }}
Expand Down
6 changes: 4 additions & 2 deletions docs/core-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,12 @@ new ProxyHeadersAgent(proxy, options)

| Name | Type | Description |
|------|------|-------------|
| `proxy` | `string \| URL` | Proxy URL (e.g., `http://user:pass@proxy:8080`) |
| `proxy` | `string \| URL` | Proxy URL (e.g., `http://user:pass@proxy:8080` or `https://user:pass@proxy:443`) |
| `options.proxyHeaders` | `Object` | Headers to send to the proxy |
| `options.onProxyConnect` | `Function` | Callback when CONNECT completes: `(headers: Map) => void` |
| `options.proxyTimeout` | `number` | Timeout for proxy CONNECT in ms (default: 30000) |
| `options.tlsOptions` | `Object` | TLS options for target connection |
| `options.proxyTlsOptions` | `Object` | TLS options for the connection to an `https://` proxy |

### Example

Expand Down Expand Up @@ -69,6 +70,7 @@ req.end();
| `proxyHost` | `string` | Proxy hostname |
| `proxyPort` | `number` | Proxy port |
| `proxyAuth` | `string \| null` | Base64-encoded proxy auth |
| `proxyProtocol` | `string` | Proxy URL protocol (`http:` or `https:`) |
| `proxyHeaders` | `Object` | Headers to send to proxy |
| `lastProxyHeaders` | `Map \| null` | Headers from last CONNECT response |

Expand Down Expand Up @@ -149,7 +151,7 @@ const { host, port } = parseTargetUrl('https://example.com:8443/path');

### buildConnectRequest(targetHost, targetPort, proxyAuth, proxyHeaders)

Build an HTTP CONNECT request string.
Build an HTTP CONNECT request string. `targetHost` must not contain CR, LF, or NUL characters; `targetPort` must be an integer from 1 to 65535. Header names and values are validated the same way.

```javascript
import { buildConnectRequest } from 'javascript-proxy-headers';
Expand Down
9 changes: 9 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,15 @@ const client = await createProxyAxios({
});
```

Use an `https://` proxy URL to speak TLS to the proxy (so `Proxy-Authorization` is not sent in the clear). Pass `proxyTlsOptions` if the proxy uses a private CA.

```javascript
const client = await createProxyAxios({
proxy: 'https://username:password@proxy.example.com:443',
proxyHeaders: { 'X-ProxyMesh-Country': 'US' }
});
```

## Using the Core Agent

For advanced use cases, you can use the core `ProxyHeadersAgent` directly with any library that accepts an `https.Agent`:
Expand Down
3 changes: 2 additions & 1 deletion lib/axios-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { ProxyHeadersAgent } from './core/proxy-headers-agent.js';
* console.log(response.headers['x-proxymesh-ip']);
*/
export async function createProxyAxios(options) {
const { proxy, proxyHeaders = {}, onProxyConnect, axiosOptions = {} } = options;
const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, axiosOptions = {} } = options;

let axios;
try {
Expand All @@ -39,6 +39,7 @@ export async function createProxyAxios(options) {
const agent = new ProxyHeadersAgent(proxy, {
proxyHeaders,
onProxyConnect,
proxyTlsOptions,
});

const instance = axios.create({
Expand Down
37 changes: 23 additions & 14 deletions lib/core/proxy-headers-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
*/

import { Agent } from 'https';
import net from 'net';
import tls from 'tls';
import { parseProxyUrl, buildConnectRequest } from './utils.js';
import { parseProxyUrl, buildConnectRequest, createProxySocket, proxyReadyEvent } from './utils.js';
import { parseConnectResponse, hasCompleteHeaders, ConnectError } from './connect-parser.js';

export class ProxyHeadersAgent extends Agent {
Expand All @@ -21,6 +20,7 @@ export class ProxyHeadersAgent extends Agent {
* @param {Function} options.onProxyConnect - Callback when CONNECT completes: (headers) => void
* @param {number} options.proxyTimeout - Timeout for proxy CONNECT (ms), default 30000
* @param {Object} options.tlsOptions - TLS options for target connection
* @param {Object} options.proxyTlsOptions - TLS options for the connection to an https:// proxy
*/
constructor(proxy, options = {}) {
super(options);
Expand All @@ -35,6 +35,7 @@ export class ProxyHeadersAgent extends Agent {
this.onProxyConnect = options.onProxyConnect || null;
this.proxyTimeout = options.proxyTimeout || 30000;
this.tlsOptions = options.tlsOptions || {};
this.proxyTlsOptions = options.proxyTlsOptions || {};

this.lastProxyHeaders = null;
}
Expand All @@ -48,10 +49,14 @@ export class ProxyHeadersAgent extends Agent {
const targetHost = options.host || options.hostname;
const targetPort = options.port || 443;

const proxySocket = net.connect({
host: this.proxyHost,
port: this.proxyPort,
});
const proxySocket = createProxySocket(
{
host: this.proxyHost,
port: this.proxyPort,
protocol: this.proxyProtocol,
},
this.proxyTlsOptions
);

let buffer = Buffer.alloc(0);
let connected = false;
Expand Down Expand Up @@ -87,14 +92,18 @@ export class ProxyHeadersAgent extends Agent {
}
});

proxySocket.on('connect', () => {
const connectRequest = buildConnectRequest(
targetHost,
targetPort,
this.proxyAuth,
this.proxyHeaders
);
proxySocket.write(connectRequest);
proxySocket.on(proxyReadyEvent(this.proxyProtocol), () => {
try {
const connectRequest = buildConnectRequest(
targetHost,
targetPort,
this.proxyAuth,
this.proxyHeaders
);
proxySocket.write(connectRequest);
} catch (err) {
handleError(err);
}
});

proxySocket.on('data', (data) => {
Expand Down
58 changes: 56 additions & 2 deletions lib/core/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
* Utility functions for proxy header handling.
*/

import net from 'net';
import tls from 'tls';

const INVALID_HEADER_CHAR = /[\r\n\0]/;

/**
Expand Down Expand Up @@ -72,6 +75,41 @@ export function parseTargetUrl(targetUrl) {
return { host, port };
}

/**
* Open a TCP or TLS socket to the proxy.
* HTTPS proxy URLs use tls.connect so Proxy-Authorization is not sent in the clear.
* @param {{ host: string, port: number, protocol: string }} proxyInfo
* @param {import('tls').ConnectionOptions} [proxyTlsOptions]
* @returns {import('net').Socket|import('tls').TLSSocket}
*/
export function createProxySocket(proxyInfo, proxyTlsOptions = {}) {
if (proxyInfo.protocol === 'https:') {
const tlsOpts = {
host: proxyInfo.host,
port: proxyInfo.port,
...proxyTlsOptions,
};
if (!net.isIP(proxyInfo.host) && tlsOpts.servername === undefined) {
tlsOpts.servername = proxyInfo.host;
}
return tls.connect(tlsOpts);
}
return net.connect({
host: proxyInfo.host,
port: proxyInfo.port,
});
}

/**
* Event that fires when the proxy socket is ready to write CONNECT.
* For HTTPS proxies this is secureConnect (after the TLS handshake).
* @param {string} protocol
* @returns {'secureConnect'|'connect'}
*/
export function proxyReadyEvent(protocol) {
return protocol === 'https:' ? 'secureConnect' : 'connect';
}

/**
* Build the CONNECT request string.
* @param {string} targetHost - Target hostname
Expand All @@ -81,9 +119,25 @@ export function parseTargetUrl(targetUrl) {
* @returns {string}
*/
export function buildConnectRequest(targetHost, targetPort, proxyAuth, proxyHeaders = {}) {
if (typeof targetHost !== 'string' || targetHost.length === 0) {
throw new TypeError('Target host must be a non-empty string');
}
if (INVALID_HEADER_CHAR.test(targetHost)) {
throw new TypeError(
`Invalid character in target host: ${JSON.stringify(targetHost.slice(0, 50))}`
);
}

const port = Number(targetPort);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new TypeError(
`Invalid target port: ${JSON.stringify(String(targetPort).slice(0, 50))}`
);
}

const lines = [
`CONNECT ${targetHost}:${targetPort} HTTP/1.1`,
`Host: ${targetHost}:${targetPort}`,
`CONNECT ${targetHost}:${port} HTTP/1.1`,
`Host: ${targetHost}:${port}`,
];

if (proxyAuth) {
Expand Down
3 changes: 2 additions & 1 deletion lib/got-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { ProxyHeadersAgent } from './core/proxy-headers-agent.js';
* console.log(response.headers['x-proxymesh-ip']);
*/
export async function createProxyGot(options) {
const { proxy, proxyHeaders = {}, onProxyConnect, gotOptions = {} } = options;
const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, gotOptions = {} } = options;

let got;
try {
Expand All @@ -39,6 +39,7 @@ export async function createProxyGot(options) {
const agent = new ProxyHeadersAgent(proxy, {
proxyHeaders,
onProxyConnect,
proxyTlsOptions,
});

const instance = got.extend({
Expand Down
4 changes: 2 additions & 2 deletions lib/ky-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { createProxyFetch } from './node-fetch-proxy.js';
* @returns {Promise<import('ky').KyInstance>}
*/
export async function createProxyKy(options) {
const { proxy, proxyHeaders = {}, onProxyConnect, kyOptions = {} } = options;
const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, kyOptions = {} } = options;

if (!proxy) {
throw new Error('proxy option is required');
Expand All @@ -30,7 +30,7 @@ export async function createProxyKy(options) {
throw new Error('ky is required. Install it with: npm install ky');
}

const fetch = createProxyFetch({ proxy, proxyHeaders, onProxyConnect });
const fetch = createProxyFetch({ proxy, proxyHeaders, onProxyConnect, proxyTlsOptions });

return ky.create({
...kyOptions,
Expand Down
3 changes: 2 additions & 1 deletion lib/make-fetch-happen-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function wrapFetchWithProxyResponse(fetchImpl, agent) {
* @returns {Function} Fetch function with .defaults() and .proxyAgent
*/
export function createProxyMakeFetchHappen(options) {
const { proxy, proxyHeaders = {}, onProxyConnect, ...makeFetchHappenOptions } = options;
const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, ...makeFetchHappenOptions } = options;

if (!proxy) {
throw new Error('proxy option is required');
Expand All @@ -41,6 +41,7 @@ export function createProxyMakeFetchHappen(options) {
const agent = new ProxyHeadersAgent(proxy, {
proxyHeaders,
onProxyConnect,
proxyTlsOptions,
});

const inner = makeFetchHappen.defaults({
Expand Down
6 changes: 4 additions & 2 deletions lib/needle-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function mergeProxyHeadersIntoResponse(res, map) {
* @returns {Promise<import('needle').NeedleResponse>}
*/
export async function proxyNeedleGet(url, options = {}) {
const { proxy, proxyHeaders = {}, onProxyConnect, needleOptions = {} } = options;
const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, needleOptions = {} } = options;

if (!proxy) {
throw new Error('proxy option is required');
Expand All @@ -39,6 +39,7 @@ export async function proxyNeedleGet(url, options = {}) {
const agent = new ProxyHeadersAgent(proxy, {
proxyHeaders,
onProxyConnect,
proxyTlsOptions,
});

return new Promise((resolve, reject) => {
Expand Down Expand Up @@ -70,7 +71,7 @@ export async function proxyNeedleGet(url, options = {}) {
* @returns {{ get: Function, proxyAgent: ProxyHeadersAgent }}
*/
export function createProxyNeedle(options) {
const { proxy, proxyHeaders = {}, onProxyConnect, needleOptions = {} } = options;
const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, needleOptions = {} } = options;

if (!proxy) {
throw new Error('proxy option is required');
Expand All @@ -79,6 +80,7 @@ export function createProxyNeedle(options) {
const agent = new ProxyHeadersAgent(proxy, {
proxyHeaders,
onProxyConnect,
proxyTlsOptions,
});

const needle = require('needle');
Expand Down
6 changes: 4 additions & 2 deletions lib/node-fetch-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { ProxyResponse } from './core/proxy-response.js';
* const data = await response.json();
*/
export async function proxyFetch(url, options = {}) {
const { proxy, proxyHeaders = {}, onProxyConnect, ...fetchOptions } = options;
const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions, ...fetchOptions } = options;

if (!proxy) {
throw new Error('proxy option is required');
Expand All @@ -44,6 +44,7 @@ export async function proxyFetch(url, options = {}) {
const agent = new ProxyHeadersAgent(proxy, {
proxyHeaders,
onProxyConnect,
proxyTlsOptions,
});

let requestUrl = url;
Expand Down Expand Up @@ -83,13 +84,14 @@ export async function proxyFetch(url, options = {}) {
* const response = await fetch('https://httpbin.org/ip');
*/
export function createProxyFetch(options) {
const { proxy, proxyHeaders = {}, onProxyConnect } = options;
const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions } = options;

return (url, fetchOptions = {}) => {
return proxyFetch(url, {
proxy,
proxyHeaders: { ...proxyHeaders, ...fetchOptions.proxyHeaders },
onProxyConnect,
proxyTlsOptions,
...fetchOptions,
});
};
Expand Down
3 changes: 2 additions & 1 deletion lib/superagent-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { ProxyHeadersAgent } from './core/proxy-headers-agent.js';
* console.log(response.headers['x-proxymesh-ip']);
*/
export function proxyPlugin(options) {
const { proxy, proxyHeaders = {}, onProxyConnect } = options;
const { proxy, proxyHeaders = {}, onProxyConnect, proxyTlsOptions } = options;

if (!proxy) {
throw new Error('proxy option is required');
Expand All @@ -39,6 +39,7 @@ export function proxyPlugin(options) {
const agent = new ProxyHeadersAgent(proxy, {
proxyHeaders,
onProxyConnect,
proxyTlsOptions,
});

return (request) => {
Expand Down
4 changes: 3 additions & 1 deletion lib/typed-rest-client-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function createProxyHeadersHttpClientClass(HttpClient) {
this.proxyAgent = new ProxyHeadersAgent(proxyOpts.proxy, {
proxyHeaders: proxyOpts.proxyHeaders || {},
onProxyConnect: proxyOpts.onProxyConnect,
proxyTlsOptions: proxyOpts.proxyTlsOptions,
});
}

Expand Down Expand Up @@ -90,6 +91,7 @@ export function createProxyRestClient(options) {
proxy,
proxyHeaders = {},
onProxyConnect,
proxyTlsOptions,
} = options;

if (!proxy) {
Expand All @@ -102,7 +104,7 @@ export function createProxyRestClient(options) {
const PHC = createProxyHeadersHttpClientClass(HttpClient);
const PRC = createProxyHeadersRestClientClass(RestClient, PHC);

const proxyOpts = { proxy, proxyHeaders, onProxyConnect };
const proxyOpts = { proxy, proxyHeaders, onProxyConnect, proxyTlsOptions };

return new PRC(userAgent, baseUrl, handlers, requestOptions, proxyOpts);
}
Loading