@aikidosec/firewall
Version:
Zen by Aikido is an embedded Application Firewall that autonomously protects Node.js apps against common and critical attacks, provides rate limiting, detects malicious traffic (including bots), and more.
225 lines (224 loc) • 10.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.Fetch = void 0;
const dns_1 = require("dns");
const Context_1 = require("../agent/Context");
const getPortFromURL_1 = require("../helpers/getPortFromURL");
const normalizeHostname_1 = require("../helpers/normalizeHostname");
const tryParseURL_1 = require("../helpers/tryParseURL");
const checkContextForSSRF_1 = require("../vulnerabilities/ssrf/checkContextForSSRF");
const inspectDNSLookupCalls_1 = require("../vulnerabilities/ssrf/inspectDNSLookupCalls");
const wrapDispatch_1 = require("./undici/wrapDispatch");
const getInternalDispatcherOptions_1 = require("./undici/getInternalDispatcherOptions");
// Marks a dispatcher instance we've already patched to prevent double-patching
const patchedDispatcherSymbol = Symbol.for("zen.dispatcher.patched");
class Fetch {
constructor() {
this.patchedGlobalDispatcher = false;
}
inspectHostname(agent, hostname, port) {
hostname = (0, normalizeHostname_1.normalizeHostname)(hostname);
// Let the agent know that we are connecting to this hostname
// This is to build a list of all hostnames that the application is connecting to
if (typeof port === "number" && port > 0) {
agent.onConnectHostname(hostname, port);
}
if (agent.getConfig().shouldBlockOutgoingRequest(hostname)) {
return {
operation: "fetch",
hostname: hostname,
};
}
const context = (0, Context_1.getContext)();
if (!context) {
return undefined;
}
return (0, checkContextForSSRF_1.checkContextForSSRF)({
hostname: hostname,
operation: "fetch",
context: context,
port: port,
});
}
inspectFetch(args, agent) {
if (args.length > 0) {
// URL string
if (typeof args[0] === "string" && args[0].length > 0) {
const url = (0, tryParseURL_1.tryParseURL)(args[0]);
if (url) {
const attack = this.inspectHostname(agent, url.hostname, (0, getPortFromURL_1.getPortFromURL)(url));
if (attack) {
return attack;
}
}
}
// Fetch accepts any object with a stringifier. User input may be an array if the user provides an array
// query parameter (e.g., ?example[0]=https://example.com/) in frameworks like Express. Since an Array has
// a default stringifier, this is exploitable in a default setup.
// The following condition ensures that we see the same value as what's passed down to the sink.
if (Array.isArray(args[0])) {
const url = (0, tryParseURL_1.tryParseURL)(args[0].toString());
if (url) {
const attack = this.inspectHostname(agent, url.hostname, (0, getPortFromURL_1.getPortFromURL)(url));
if (attack) {
return attack;
}
}
}
// URL object
if (args[0] instanceof URL && args[0].hostname.length > 0) {
const attack = this.inspectHostname(agent, args[0].hostname, (0, getPortFromURL_1.getPortFromURL)(args[0]));
if (attack) {
return attack;
}
}
// Request object
if (args[0] instanceof Request) {
const url = (0, tryParseURL_1.tryParseURL)(args[0].url);
if (url) {
const attack = this.inspectHostname(agent, url.hostname, (0, getPortFromURL_1.getPortFromURL)(url));
if (attack) {
return attack;
}
}
}
}
return undefined;
}
// We'll set a global dispatcher that will allow us to inspect the resolved IPs (and thus preventing TOCTOU attacks)
patchGlobalDispatcher(agent) {
const sym1 = Symbol.for("undici.globalDispatcher.1");
// sym2 was introduced when undici v8 split the real Agent (sym2) from a legacy
// Dispatcher1Wrapper shim (sym1). In undici v7 after PR #5368, both symbols point
// to the same Agent with no wrapper.
const sym2 = Symbol.for("undici.globalDispatcher.2");
// @ts-expect-error Type is not defined
const dispatcher1 = globalThis[sym1];
// @ts-expect-error Type is not defined
const dispatcher2 = globalThis[sym2];
// The real undici Agent: prefer sym2 (Node.js v26+), fall back to sym1 (older).
const realDispatcher = dispatcher2 || dispatcher1;
if (!realDispatcher) {
agent.log(`global dispatcher not found for fetch, we can't provide protection!`);
return;
}
if (realDispatcher.constructor.name !== "Agent") {
agent.log(`Expected Agent as global dispatcher for fetch but found ${realDispatcher.constructor.name}, we can't provide protection!`);
return;
}
try {
const lookupFn = (0, inspectDNSLookupCalls_1.inspectDNSLookupCalls)(dns_1.lookup, agent, "fetch", "fetch");
// Create a new Agent with our custom DNS lookup interceptor.
const newAgent = new realDispatcher.constructor({
connect: { lookup: lookupFn },
});
newAgent.dispatch = (0, wrapDispatch_1.wrapDispatch)(newAgent.dispatch, agent);
if (dispatcher2) {
// @ts-expect-error Type is not defined
globalThis[sym2] = newAgent;
if (dispatcher1.constructor.name === "Dispatcher1Wrapper") {
// undici v8 (Node.js v26+): sym1 holds a Dispatcher1Wrapper shim around the Agent.
const newWrapper = new dispatcher1.constructor(newAgent);
newWrapper.dispatch = (0, wrapDispatch_1.wrapDispatch)(newWrapper.dispatch, agent);
// @ts-expect-error Type is not defined
globalThis[sym1] = newWrapper;
}
else {
// undici v7: PR #5368 (shipped in Node.js 24.17.0) changed setGlobalDispatcher to mirror
// the Agent directly to sym1 instead of wrapping it in Dispatcher1Wrapper.
// Creating a new agent would produce a broken dispatcher.
// @ts-expect-error Type is not defined
globalThis[sym1] = newAgent;
}
}
else {
// Older Node.js: only sym1 exists and it is already an Agent.
// @ts-expect-error Type is not defined
globalThis[sym1] = newAgent;
}
}
catch {
agent.log(`Failed to patch global dispatcher for fetch, we can't provide protection!`);
}
}
patchCustomDispatcher(dispatcher, agent) {
if (!dispatcher || typeof dispatcher !== "object") {
return;
}
const instance = dispatcher;
if (instance[patchedDispatcherSymbol]) {
// Already patched this exact dispatcher instance
return;
}
const originalDispatch = instance.dispatch;
if (typeof originalDispatch !== "function") {
return;
}
try {
instance.dispatch = (0, wrapDispatch_1.wrapDispatch)(originalDispatch.bind(instance), agent);
this.patchDispatcherConnectLookup(instance, agent);
Object.defineProperty(instance, patchedDispatcherSymbol, {
value: true,
enumerable: false,
});
}
catch {
agent.log(`Failed to patch custom dispatcher for fetch, we can't guarantee protection!`);
}
}
patchDispatcherConnectLookup(instance, agent) {
const options = (0, getInternalDispatcherOptions_1.getInternalDispatcherOptions)(instance);
if (!options) {
agent.log(`Could not find internal options on custom dispatcher for fetch, we can only provide partial protection!`);
return;
}
const canPatchConnect = options.connect === undefined ||
(typeof options.connect === "object" && options.connect !== null);
if (!canPatchConnect) {
agent.log(`Custom dispatcher for fetch uses a function-based connect option, we can only provide partial protection!`);
return;
}
const existingConnect = options.connect;
const existingLookup = typeof (existingConnect === null || existingConnect === void 0 ? void 0 : existingConnect.lookup) === "function"
? existingConnect.lookup
: dns_1.lookup;
const patchedLookupFn = (0, inspectDNSLookupCalls_1.inspectDNSLookupCalls)(existingLookup, agent, "fetch", "fetch");
options.connect = {
...existingConnect,
lookup: patchedLookupFn,
};
}
wrap(hooks) {
if (typeof globalThis.fetch === "function") {
// Fetch is lazy loaded in Node.js
// By calling fetch() we ensure that the global dispatcher is available
try {
// @ts-expect-error Type is not defined
globalThis.fetch().catch(() => { });
}
catch {
// Ignore errors
}
}
hooks.addGlobal("fetch", {
kind: "outgoing_http_op",
// Whenever a request is made, we'll check the hostname whether it's a private IP
inspectArgs: (args, agent) => this.inspectFetch(args, agent),
modifyArgs: (args, agent) => {
if (!this.patchedGlobalDispatcher) {
this.patchGlobalDispatcher(agent);
this.patchedGlobalDispatcher = true;
}
if (args.length > 1 &&
args[1] &&
typeof args[1] === "object" &&
!Array.isArray(args[1]) &&
"dispatcher" in args[1]) {
this.patchCustomDispatcher(args[1].dispatcher, agent);
}
return args;
},
});
}
}
exports.Fetch = Fetch;