Skip to content

Add HTTPS upstream proxy support with option to ignore proxy cert errors #577

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Mar 28, 2025
Merged
Show file tree
Hide file tree
Changes from 4 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
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,18 @@ const server = new ProxyChain.Server({
// requiring Basic authentication. Here you can verify user credentials.
requestAuthentication: username !== 'bob' || password !== 'TopSecret',

// Sets up an upstream HTTP/SOCKS proxy to which all the requests are forwarded.
// Sets up an upstream HTTP/HTTPS/SOCKS proxy to which all the requests are forwarded.
// If null, the proxy works in direct mode, i.e. the connection is forwarded directly
// to the target server. This field is ignored if "requestAuthentication" is true.
// The username and password must be URI-encoded.
upstreamProxyUrl: `http://username:[email protected]:3128`,
// Or use SOCKS4/5 proxy, e.g.
// upstreamProxyUrl: `socks://username:[email protected]:1080`,

// Applies to HTTPS upstream proxy. If set to true, requests made to the proxy will
// ignore certificate errors. Useful when upstream proxy uses self-signed certificate. By default "false".
ignoreUpstreamProxyCertificate: true

// If "requestAuthentication" is true, you can use the following property
// to define a custom error message to return to the client instead of the default "Proxy credentials required"
failMsg: 'Bad username or password, please try again.',
Expand Down Expand Up @@ -368,10 +372,13 @@ The package also provides several utility functions.

### `anonymizeProxy({ url, port }, callback)`

Parses and validates a HTTP proxy URL. If the proxy requires authentication,
Parses and validates a HTTP/HTTPS proxy URL. If the proxy requires authentication,
then the function starts an open local proxy server that forwards to the proxy.
The port (on which the local proxy server will start) can be set via the `port` property of the first argument, if not provided, it will be chosen randomly.

For HTTPS proxy with self-signed certificate, set `ignoreProxyCertificate` property of the first argument to `true` to ignore certificate errors in
proxy requests.

The function takes an optional callback that receives the anonymous proxy URL.
If no callback is supplied, the function returns a promise that resolves to a String with
anonymous proxy URL or the original URL if it was already anonymous.
Expand Down Expand Up @@ -420,13 +427,14 @@ If callback is not provided, the function returns a promise instead.

### `createTunnel(proxyUrl, targetHost, options, callback)`

Creates a TCP tunnel to `targetHost` that goes through a HTTP proxy server
Creates a TCP tunnel to `targetHost` that goes through a HTTP/HTTPS proxy server
specified by the `proxyUrl` parameter.

The optional `options` parameter is an object with the following properties:
- `port: Number` - Enables specifying the local port to listen at. By default `0`,
which means a random port will be selected.
- `hostname: String` - Local hostname to listen at. By default `localhost`.
- `ignoreProxyCertificate` - For HTTPS proxy, ignore certificate errors in proxy requests. Useful for proxy with self-signed certificate. By default `false`.
- `verbose: Boolean` - If `true`, the functions logs a lot. By default `false`.

The result of the function is a local endpoint in a form of `hostname:port`.
Expand Down
18 changes: 13 additions & 5 deletions src/anonymize_proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ const anonymizedProxyUrlToServer: Record<string, Server> = {};
export interface AnonymizeProxyOptions {
url: string;
port: number;
ignoreProxyCertificate?: boolean;
}

/**
* Parses and validates a HTTP proxy URL. If the proxy requires authentication, then the function
* Parses and validates a HTTP proxy URL. If the proxy requires authentication,
* or if it is an HTTPS proxy and `ignoreProxyCertificate` is `true`, then the function
* starts an open local proxy server that forwards to the upstream proxy.
*/
export const anonymizeProxy = async (
Expand All @@ -24,6 +26,7 @@ export const anonymizeProxy = async (
): Promise<string> => {
let proxyUrl: string;
let port = 0;
let ignoreProxyCertificate = false;

if (typeof options === 'string') {
proxyUrl = options;
Expand All @@ -36,15 +39,19 @@ export const anonymizeProxy = async (
'Invalid "port" option: only values equals or between 0-65535 are valid',
);
}

if (options.ignoreProxyCertificate !== undefined) {
ignoreProxyCertificate = options.ignoreProxyCertificate;
}
}

const parsedProxyUrl = new URL(proxyUrl);
if (!['http:', ...SOCKS_PROTOCOLS].includes(parsedProxyUrl.protocol)) {
throw new Error(`Invalid "proxyUrl" provided: URL must have one of the following protocols: "http", ${SOCKS_PROTOCOLS.map((p) => `"${p.replace(':', '')}"`).join(', ')} (was "${parsedProxyUrl}")`);
if (!['http:', 'https:', ...SOCKS_PROTOCOLS].includes(parsedProxyUrl.protocol)) {
throw new Error(`Invalid "proxyUrl" provided: URL must have one of the following protocols: "http", "https", ${SOCKS_PROTOCOLS.map((p) => `"${p.replace(':', '')}"`).join(', ')} (was "${parsedProxyUrl}")`);
}

// If upstream proxy requires no password, return it directly
if (!parsedProxyUrl.username && !parsedProxyUrl.password) {
// If upstream proxy requires no password or if there is no need to ignore HTTPS proxy cert errors, return it directly
if (!parsedProxyUrl.username && !parsedProxyUrl.password && (!ignoreProxyCertificate || parsedProxyUrl.protocol !== 'https:')) {
return nodeify(Promise.resolve(proxyUrl), callback);
}

Expand All @@ -60,6 +67,7 @@ export const anonymizeProxy = async (
return {
requestAuthentication: false,
upstreamProxyUrl: proxyUrl,
ignoreUpstreamProxyCertificate: ignoreProxyCertificate,
};
},
}) as Server & { port: number };
Expand Down
9 changes: 7 additions & 2 deletions src/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ interface Options {

export interface HandlerOpts {
upstreamProxyUrlParsed: URL;
ignoreUpstreamProxyCertificate: boolean;
localAddress?: string;
ipFamily?: number;
dnsLookup?: typeof dns['lookup'];
Expand Down Expand Up @@ -83,8 +84,12 @@ export const chain = (
options.headers.push('proxy-authorization', getBasicAuthorizationHeader(proxy));
}

const fn = proxy.protocol === 'https:' ? https.request : http.request;
const client = fn(proxy.origin, options as unknown as http.ClientRequestArgs);
const client = proxy.protocol === 'https:'
? https.request(proxy.origin, {
...options as unknown as https.RequestOptions,
rejectUnauthorized: !handlerOpts.ignoreUpstreamProxyCertificate,
})
: http.request(proxy.origin, options as unknown as http.RequestOptions);

client.once('socket', (targetSocket: SocketWithPreviousStats) => {
// Socket can be re-used by multiple requests.
Expand Down
17 changes: 12 additions & 5 deletions src/forward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface Options {

export interface HandlerOpts {
upstreamProxyUrlParsed: URL;
ignoreUpstreamProxyCertificate: boolean;
localAddress?: string;
ipFamily?: number;
dnsLookup?: typeof dns['lookup'];
Expand Down Expand Up @@ -79,10 +80,7 @@ export const forward = async (
}
}

const fn = origin!.startsWith('https:') ? https.request : http.request;

// We have to force cast `options` because @types/node doesn't support an array.
const client = fn(origin!, options as unknown as http.ClientRequestArgs, async (clientResponse) => {
const requestCallback = async (clientResponse: http.IncomingMessage) => {
try {
// This is necessary to prevent Node.js throwing an error
let statusCode = clientResponse.statusCode!;
Expand Down Expand Up @@ -113,7 +111,16 @@ export const forward = async (
// Client error, pipeline already destroys the streams, ignore.
resolve();
}
});
};

// We have to force cast `options` because @types/node doesn't support an array.
const client = origin!.startsWith('https:')
? https.request(origin!, {
...options as unknown as https.RequestOptions,
rejectUnauthorized: handlerOpts.upstreamProxyUrlParsed ? !handlerOpts.ignoreUpstreamProxyCertificate : undefined,
}, requestCallback)

: http.request(origin!, options as unknown as http.RequestOptions, requestCallback);

client.once('socket', (socket: SocketWithPreviousStats) => {
// Socket can be re-used by multiple requests.
Expand Down
7 changes: 7 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ type HandlerOpts = {
srcHead: Buffer | null;
trgParsed: URL | null;
upstreamProxyUrlParsed: URL | null;
ignoreUpstreamProxyCertificate: boolean;
isHttp: boolean;
customResponseFunction?: CustomResponseOpts['customResponseFunction'] | null;
customConnectServer?: http.Server | null;
Expand All @@ -80,6 +81,7 @@ export type PrepareRequestFunctionResult = {
requestAuthentication?: boolean;
failMsg?: string;
upstreamProxyUrl?: string | null;
ignoreUpstreamProxyCertificate?: boolean;
localAddress?: string;
ipFamily?: number;
dnsLookup?: typeof dns['lookup'];
Expand Down Expand Up @@ -341,6 +343,7 @@ export class Server extends EventEmitter {
srcHead: null,
trgParsed: null,
upstreamProxyUrlParsed: null,
ignoreUpstreamProxyCertificate: false,
isHttp: false,
srcResponse: null,
customResponseFunction: null,
Expand Down Expand Up @@ -468,6 +471,10 @@ export class Server extends EventEmitter {
}
}

if (funcResult.ignoreUpstreamProxyCertificate !== undefined) {
handlerOpts.ignoreUpstreamProxyCertificate = funcResult.ignoreUpstreamProxyCertificate;
}

const { proxyChainId } = request.socket as Socket;

if (funcResult.customResponseFunction) {
Expand Down
12 changes: 8 additions & 4 deletions src/tcp_tunnel_tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@
export async function createTunnel(
proxyUrl: string,
targetHost: string,
options: {
options?: {
verbose?: boolean;
ignoreProxyCertificate?: boolean;
},
callback?: (error: Error | null, result?: string) => void,
): Promise<string> {
const parsedProxyUrl = new URL(proxyUrl);
if (parsedProxyUrl.protocol !== 'http:') {
throw new Error(`The proxy URL must have the "http" protocol (was "${proxyUrl}")`);
if (parsedProxyUrl.protocol !== 'http:' && parsedProxyUrl.protocol !== 'https:') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please do something with an array ['http:', 'https:'] and not in?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

throw new Error(`The proxy URL must have the "http" or "https" protocol (was "${proxyUrl}")`);
}

const url = new URL(`connect://${targetHost || ''}`);
Expand All @@ -44,7 +45,7 @@
const server: net.Server & { log?: (...args: unknown[]) => void } = net.createServer();

const log = (...args: unknown[]): void => {
if (verbose) console.log(...args);

Check warning on line 48 in src/tcp_tunnel_tools.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
};

server.log = log;
Expand All @@ -67,7 +68,10 @@
chain({
request: { url: targetHost },
sourceSocket,
handlerOpts: { upstreamProxyUrlParsed: parsedProxyUrl },
handlerOpts: {
upstreamProxyUrlParsed: parsedProxyUrl,
ignoreUpstreamProxyCertificate: options?.ignoreProxyCertificate ?? false,
},
server: server as net.Server & { log: typeof log },
isPlain: true,
});
Expand Down
Loading