Skip to content
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

fix: support Node.js 20 #16

Merged
merged 14 commits into from
Oct 21, 2023
11 changes: 5 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
name: ci

on: [push]

on: [push, pull_request]
permissions:
contents: read
jobs:
build:
name: Test on node ${{ matrix.node_version }}
runs-on: ubuntu-18.04
runs-on: ubuntu-latest
strategy:
matrix:
node_version: [ 12, 14, 16 ]
node_version: [ 18, 20 ]

steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node_version }}
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node_version }}

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ This library block the request to these IP addresses by default.

So, This library block the request to non-`unicast` IP addresses.

:warning: Node.js's built-in `fetch` does not support `http.Agent`.

- [Passing a custom agent to `fetch()` · Issue #1489 · nodejs/undici](https://github.com/nodejs/undici/issues/1489)

## Support `http.Agent` libraries

This library provides Node.js's [http.Agent](https://nodejs.org/api/http.html#http_class_http_agent) implementation.
Expand Down
208 changes: 115 additions & 93 deletions src/request-filtering-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,37 @@ import { TcpNetConnectOpts } from "net";
import * as http from "http";
import * as https from "https";
import ipaddr from "ipaddr.js";
import { Socket } from "net";

export interface RequestFilteringAgentOptions {
// Allow to connect private IP address
// Allow to connect private IP address if allowPrivateIPAddress is true
// This includes Private IP addresses and Reserved IP addresses.
// https://en.wikipedia.org/wiki/Private_network
// https://en.wikipedia.org/wiki/Reserved_IP_addresses
// Example, http://127.0.0.1/, http://localhost/, https://169.254.169.254/
// Default: false
allowPrivateIPAddress?: boolean;
// Allow to connect meta address 0.0.0.0
// 0.0.0.0 (IPv4) and :: (IPv6) a meta address that routing another address
// Allow to connect meta address 0.0.0.0 if allowPrivateIPAddress is true
// 0.0.0.0 (IPv4) and :: (IPv6) a meta/unspecified address that routing another address
// https://en.wikipedia.org/wiki/Reserved_IP_addresses
// https://tools.ietf.org/html/rfc6890
// Default: false
allowMetaIPAddress?: boolean;
// Allow address list
// This values are preferred than denyAddressList
// These values are preferred than denyAddressList
// Default: []
allowIPAddressList?: string[];
// Deny address list
// Default: []
denyIPAddressList?: string[];
// prevent url redirection attack
// connection not made to private IP adresses where the port is closed
// Default: false
stopPortScanningByUrlRedirection?: boolean;
Comment on lines -28 to -31
Copy link
Owner Author

Choose a reason for hiding this comment

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

allowMetaIPAddress and allowPrivateIPAddress has same logics.

}

export const DefaultRequestFilteringAgentOptions: Required<RequestFilteringAgentOptions> = {
allowPrivateIPAddress: false,
allowMetaIPAddress: false,
allowIPAddressList: [],
denyIPAddressList: []
};
/**
* validate the address that is matched the validation options
* @param address ip address
Expand All @@ -41,7 +44,7 @@ export interface RequestFilteringAgentOptions {
const validateIPAddress = (
{ address, host, family }: { address: string; host?: string; family?: string | number },
options: Required<RequestFilteringAgentOptions>
): null | undefined | Error => {
): undefined | Error => {
// if it is not IP address, skip it
if (net.isIP(address) === 0) {
return;
Expand Down Expand Up @@ -74,112 +77,130 @@ const validateIPAddress = (
);
}
} catch (error) {
return error as Error; // if can not parsed IP address, throw error
return error as Error; // if can not parse IP address, throw error
}
return;
};

// dns lookup -> check
const addDropFilterSocket = (options: Required<RequestFilteringAgentOptions>, socket: net.Socket) => {
socket.addListener("lookup", (err, address, family, host) => {
if (err) {
return;
}
const error = validateIPAddress({ address, family, host }, options);
if (error) {
socket.destroy(error);
}
});
};

// public
// prevent twice apply
const appliedAgentSet = new WeakSet<http.Agent | https.Agent>();

/**
* Apply request filter to http(s).Agent instance
* A subclass of http.Agent with request filtering
*/
export function applyRequestFilter<T extends http.Agent | https.Agent>(
agent: T,
options?: RequestFilteringAgentOptions
): T {
if (appliedAgentSet.has(agent)) {
return agent;
export class RequestFilteringHttpAgent extends http.Agent {
private requestFilterOptions: Required<RequestFilteringAgentOptions>;

constructor(options?: http.AgentOptions & RequestFilteringAgentOptions) {
super(options);
this.requestFilterOptions = {
allowPrivateIPAddress:
options && options.allowPrivateIPAddress !== undefined
? options.allowPrivateIPAddress
: DefaultRequestFilteringAgentOptions.allowPrivateIPAddress,
allowMetaIPAddress:
options && options.allowMetaIPAddress !== undefined
? options.allowMetaIPAddress
: DefaultRequestFilteringAgentOptions.allowMetaIPAddress,
allowIPAddressList:
options && options.allowIPAddressList
? options.allowIPAddressList
: DefaultRequestFilteringAgentOptions.allowIPAddressList,
denyIPAddressList:
options && options.denyIPAddressList
? options.denyIPAddressList
: DefaultRequestFilteringAgentOptions.denyIPAddressList
};
}
appliedAgentSet.add(agent);
const requestFilterOptions: Required<RequestFilteringAgentOptions> = {
allowPrivateIPAddress:
options && options.allowPrivateIPAddress !== undefined ? options.allowPrivateIPAddress : false,
allowMetaIPAddress: options && options.allowMetaIPAddress !== undefined ? options.allowMetaIPAddress : false,
allowIPAddressList: options && options.allowIPAddressList ? options.allowIPAddressList : [],
denyIPAddressList: options && options.denyIPAddressList ? options.denyIPAddressList : [],
stopPortScanningByUrlRedirection:
options && options.stopPortScanningByUrlRedirection !== undefined
? options.stopPortScanningByUrlRedirection
: false
};

// override http.Agent#createConnection
// https://nodejs.org/api/http.html#http_agent_createconnection_options_callback
// https://nodejs.org/api/net.html#net_net_createconnection_options_connectlistener
// @ts-expect-error - @types/node does not defined createConnection
const createConnection = agent.createConnection;
// @ts-expect-error - @types/node does not defined createConnection
agent.createConnection = (options: TcpNetConnectOpts, connectionListener?: (error?: Error) => void) => {
if (requestFilterOptions.stopPortScanningByUrlRedirection) {
// Prevents malicious user from identifying which ports are open
const { host, family } = options;
if (host && net.isIP(host)) {
const addr = ipaddr.parse(host);
const range = addr.range();
if (range !== "unicast") {
throw new Error(
`DNS lookup ${host}(family:${family}, host:${host}) is not allowed. Because, It is private IP address.`
);
}
}
}

const socket = createConnection.call(agent, options, () => {
createConnection(options: TcpNetConnectOpts, connectionListener?: (error: Error | null, socket: Socket) => void) {
const { host } = options;
if (host !== undefined) {
// Direct ip address request without dns-lookup
// Example: http://127.0.0.1
// https://nodejs.org/api/net.html#net_socket_connect_options_connectlistener
const { host } = options;
if (host) {
// Direct ip address request without dns-lookup
// Example: http://127.0.0.1
// https://nodejs.org/api/net.html#net_socket_connect_options_connectlistener
const error = validateIPAddress({ address: host }, requestFilterOptions);
if (error) {
socket.destroy(error);
}
const validationError = validateIPAddress({ address: host }, this.requestFilterOptions);
if (validationError) {
throw validationError;
}
if (typeof connectionListener === "function") {
connectionListener();
}
});
}
// https://nodejs.org/api/net.html#net_socket_connect_options_connectlistener
// @ts-expect-error - @types/node does not defined createConnection
const socket: Socket = super.createConnection(options, connectionListener);
// Request with domain name
// Example: http://127.0.0.1.nip.io/
addDropFilterSocket(requestFilterOptions, socket);
const onLookup = (err: Error, address: string, family: string | number, host: string): void => {
if (err) {
return;
}
const validationError = validateIPAddress({ address, family, host }, this.requestFilterOptions);
if (validationError) {
socket.removeListener("lookup", onLookup);
// When just call destroy without end, Node.js 20 throws INTERNAL error.
// https://github.com/azu/request-filtering-agent/pull/16#discussion_r1367669822
socket.end(() => {
socket.destroy(validationError);
});
}
};
socket.addListener("lookup", onLookup);
return socket;
};
return agent;
}

/**
* A subclass of http.Agent with request filtering
*/
export class RequestFilteringHttpAgent extends http.Agent {
constructor(options?: http.AgentOptions & RequestFilteringAgentOptions) {
super(options);
applyRequestFilter(this, options);
}
}

/**
* A subclass of https.Agent with request filtering
*/
export class RequestFilteringHttpsAgent extends https.Agent {
private requestFilterOptions: Required<RequestFilteringAgentOptions>;

constructor(options?: https.AgentOptions & RequestFilteringAgentOptions) {
super(options);
applyRequestFilter(this, options);
this.requestFilterOptions = {
allowPrivateIPAddress:
options && options.allowPrivateIPAddress !== undefined ? options.allowPrivateIPAddress : false,
allowMetaIPAddress:
options && options.allowMetaIPAddress !== undefined ? options.allowMetaIPAddress : false,
allowIPAddressList: options && options.allowIPAddressList ? options.allowIPAddressList : [],
denyIPAddressList: options && options.denyIPAddressList ? options.denyIPAddressList : []
};
}

// override http.Agent#createConnection
// https://nodejs.org/api/http.html#http_agent_createconnection_options_callback
// https://nodejs.org/api/net.html#net_net_createconnection_options_connectlistener
createConnection(options: TcpNetConnectOpts, connectionListener?: (error: Error | null, socket: Socket) => void) {
const { host } = options;
if (host !== undefined) {
// Direct ip address request without dns-lookup
// Example: http://127.0.0.1
// https://nodejs.org/api/net.html#net_socket_connect_options_connectlistener
const validationError = validateIPAddress({ address: host }, this.requestFilterOptions);
if (validationError) {
throw validationError;
}
}
// https://nodejs.org/api/net.html#net_socket_connect_options_connectlistener
// @ts-expect-error - @types/node does not defined createConnection
const socket: Socket = super.createConnection(options, connectionListener);
// Request with domain name
// Example: http://127.0.0.1.nip.io/
const onLookup = (err: Error, address: string, family: string | number, host: string): void => {
if (err) {
return;
}
const validationError = validateIPAddress({ address, family, host }, this.requestFilterOptions);
if (validationError) {
socket.removeListener("lookup", onLookup);
// When just call destroy without end, Node.js 20 throws INTERNAL error.
// https://github.com/azu/request-filtering-agent/pull/16#discussion_r1367669822
socket.end(() => {
socket.destroy(validationError);
});
}
};
socket.addListener("lookup", onLookup);
return socket;
}
}

Expand All @@ -189,10 +210,11 @@ export const globalHttpsAgent = new RequestFilteringHttpsAgent();
* Get an agent for the url
* return http or https agent
* @param url
* @param options
*/
export const useAgent = (url: string, options?: https.AgentOptions & RequestFilteringAgentOptions) => {
if(!options) {
return url.startsWith("https") ? globalHttpsAgent : globalHttpAgent;
if (!options) {
return url.startsWith("https") ? globalHttpsAgent : globalHttpAgent;
}
return url.startsWith("https") ? new RequestFilteringHttpsAgent(options) : new RequestFilteringHttpAgent(options);
};
Loading