diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9c268f..159a1d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/README.md b/README.md index f9a835e..6871d42 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/request-filtering-agent.ts b/src/request-filtering-agent.ts index a7472b9..a18b356 100644 --- a/src/request-filtering-agent.ts +++ b/src/request-filtering-agent.ts @@ -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; } +export const DefaultRequestFilteringAgentOptions: Required = { + allowPrivateIPAddress: false, + allowMetaIPAddress: false, + allowIPAddressList: [], + denyIPAddressList: [] +}; /** * validate the address that is matched the validation options * @param address ip address @@ -41,7 +44,7 @@ export interface RequestFilteringAgentOptions { const validateIPAddress = ( { address, host, family }: { address: string; host?: string; family?: string | number }, options: Required -): null | undefined | Error => { +): undefined | Error => { // if it is not IP address, skip it if (net.isIP(address) === 0) { return; @@ -74,102 +77,74 @@ 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, 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(); - /** - * Apply request filter to http(s).Agent instance + * A subclass of http.Agent with request filtering */ -export function applyRequestFilter( - agent: T, - options?: RequestFilteringAgentOptions -): T { - if (appliedAgentSet.has(agent)) { - return agent; +export class RequestFilteringHttpAgent extends http.Agent { + private requestFilterOptions: Required; + + 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 = { - 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); } } @@ -177,9 +152,55 @@ export class RequestFilteringHttpAgent extends http.Agent { * A subclass of https.Agent with request filtering */ export class RequestFilteringHttpsAgent extends https.Agent { + private requestFilterOptions: Required; + 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; } } @@ -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); }; diff --git a/test/request-filtering-agent.test.ts b/test/request-filtering-agent.test.ts index 81baf09..3673b17 100644 --- a/test/request-filtering-agent.test.ts +++ b/test/request-filtering-agent.test.ts @@ -1,11 +1,6 @@ import * as assert from "assert"; import fetch from "node-fetch"; -import { - globalHttpAgent, - RequestFilteringHttpAgent, - useAgent, - applyRequestFilter -} from "../src/request-filtering-agent"; +import { globalHttpAgent, RequestFilteringHttpAgent, useAgent } from "../src/request-filtering-agent"; import * as http from "http"; const TEST_PORT = 12456; @@ -58,25 +53,6 @@ describe("request-filtering-agent", function () { } } }); - it("apply request filtering to existing http.Agent", async () => { - const agent = new http.Agent({ - keepAlive: true - }); - const agentWithFiltering = applyRequestFilter(agent, { - allowPrivateIPAddress: true - }); - const privateIPs = [`http://127.0.0.1:${TEST_PORT}`]; - for (const ipAddress of privateIPs) { - try { - await fetch(ipAddress, { - agent: agentWithFiltering, - timeout: 2000 - }); - } catch (error) { - assert.fail(new Error("should fetch, because it is allow, error" + error)); - } - } - }); it("0.0.0.0 and :: is metaAddress, it is disabled by default", async () => { const agent = new RequestFilteringHttpAgent(); const disAllowedIPs = [`http://0.0.0.0:${TEST_PORT}`, `http://[::]:${TEST_PORT}`]; @@ -97,7 +73,7 @@ describe("request-filtering-agent", function () { it("should allow http://127.0.0.1, but other private ip is disallowed", async () => { const agent = new RequestFilteringHttpAgent({ - allowIPAddressList: ["127.0.0.1"], + allowIPAddressList: ["127.0.0.1", "::1"], allowPrivateIPAddress: false }); const privateIPs = [`http://127.0.0.1:${TEST_PORT}`, `http://localhost:${TEST_PORT}`]; @@ -126,9 +102,8 @@ describe("request-filtering-agent", function () { } } }); - it("IPv4: should not request because Socket is closed", async () => { + it("IPv4: should not request because it is private IP", async () => { const privateIPs = [ - `http://0.0.0.0:${TEST_PORT}`, // 0.0.0.0 is special `http://127.0.0.1:${TEST_PORT}`, // `http://A.com@127.0.0.1:${TEST_PORT}` ]; @@ -143,7 +118,28 @@ describe("request-filtering-agent", function () { if (error instanceof ReferenceError) { assert.fail(error); } - assert.strictEqual(error.type, "system", `Failed at ${ipAddress}, error: ${error}`); + // shoud be validation error + assert.match(error.message, /It is private IP address/); + } + } + }); + it("IPv4: should not request because it is meta/unspecified IP", async () => { + const privateIPs = [ + `http://0.0.0.0:${TEST_PORT}` // 0.0.0.0 is special + ]; + for (const ipAddress of privateIPs) { + try { + await fetch(ipAddress, { + agent: useAgent(ipAddress), + timeout: 2000 + }); + throw new ReferenceError("SHOULD NOT BE CALLED"); + } catch (error) { + if (error instanceof ReferenceError) { + assert.fail(error); + } + // shoud be validation error + assert.match(error.message, /It is meta IP address/); } } }); @@ -169,8 +165,8 @@ describe("request-filtering-agent", function () { if (error instanceof ReferenceError) { assert.fail(error); } - // Should be system error - assert.strictEqual(error.type, "system", `Failed at ${ipAddress}, error: ${error}`); + // should be validation error + assert.match(error.message, /It is private IP address/); } } }); @@ -204,7 +200,10 @@ describe("request-filtering-agent", function () { if (error instanceof ReferenceError) { assert.fail(error); } - assert.ok(/It is private IP address/i.test(error.message), `Failed at ${ipAddress}, error: ${error}`); + assert.ok( + /Because, It is private IP address./i.test(error.message), + `Failed at ${ipAddress}, error: ${error}` + ); } } }); @@ -243,32 +242,4 @@ describe("request-filtering-agent", function () { assert.fail(new Error("should fetch public ip, but it is failed")); } }); - it("should fail to make request when stopPortScanningByUrlRedirection option is set to true", async () => { - const closedPort = TEST_PORT + 1; - const privateIPs = [ - `http://0.0.0.0:${closedPort}`, // 0.0.0.0 is special - `http://127.0.0.1:${closedPort}`, - `http://A.com@127.0.0.1:${closedPort}` - ]; - const agent = new RequestFilteringHttpAgent({ - stopPortScanningByUrlRedirection: true - }); - for (const ipAddress of privateIPs) { - try { - await fetch(ipAddress, { - agent, - timeout: 2000 - }); - throw new ReferenceError("SHOULD NOT BE CALLED"); - } catch (error) { - if (error instanceof ReferenceError) { - assert.fail(error); - } - assert.match( - error.message, - /^DNS lookup (0\.0\.0\.0|127\.0\.0\.1|undefined)\(family:undefined, host:(0\.0\.0\.0|127\.0\.0\.1|undefined)\) is not allowed. Because, It is private IP address.$/g - ); - } - } - }); }); diff --git a/tsconfig.json b/tsconfig.json index e1d6a02..046336d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,4 +33,4 @@ ".git", "node_modules" ] -} \ No newline at end of file +}