From 72c93803f9b671140ef27db04bbfd2b794538efd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Fri, 31 May 2024 15:27:31 +0200 Subject: [PATCH 01/53] Improve absolute path traversal protection --- .../path-traversal/detectPathTraversal.test.ts | 11 +++++++++++ .../vulnerabilities/path-traversal/unsafePathStart.ts | 8 ++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts b/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts index eb6c62363..e57980aea 100644 --- a/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts +++ b/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts @@ -80,6 +80,17 @@ t.test("windows drive letter", async () => { t.same(detectPathTraversal("C:\\file.txt", "C:\\"), true); }); +t.test("possible bypass", async () => { + t.same(detectPathTraversal("/./etc/passwd", "/./etc/passwd"), true); +}); + +t.test("another bypass", async () => { + t.same( + detectPathTraversal("/./././root/test.txt", "/./././root/test.txt"), + true + ); +}); + t.test("no path traversal", async () => { t.same( detectPathTraversal("/appdata/storage/file.txt", "/storage/file.txt"), diff --git a/library/vulnerabilities/path-traversal/unsafePathStart.ts b/library/vulnerabilities/path-traversal/unsafePathStart.ts index 5049e5e89..7892b4ced 100644 --- a/library/vulnerabilities/path-traversal/unsafePathStart.ts +++ b/library/vulnerabilities/path-traversal/unsafePathStart.ts @@ -22,12 +22,12 @@ const linuxRootFolders = [ const dangerousPathStarts = [...linuxRootFolders, "c:/", "c:\\"]; export function startsWithUnsafePath(filePath: string, userInput: string) { - const lowerCasePath = filePath.toLowerCase(); - const lowerCaseUserInput = userInput.toLowerCase(); + const normalizedPath = filePath.replace(/^(\/\.)*/g, "").toLowerCase(); + const normalizedUserInput = userInput.replace(/^(\/\.)*/g, "").toLowerCase(); for (const dangerousStart of dangerousPathStarts) { if ( - lowerCasePath.startsWith(dangerousStart) && - lowerCasePath.startsWith(lowerCaseUserInput) + normalizedPath.startsWith(dangerousStart) && + normalizedPath.startsWith(normalizedUserInput) ) { return true; } From 5dc8480632de0d98319d36b02e81c41d7163845e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Fri, 31 May 2024 16:22:41 +0200 Subject: [PATCH 02/53] Fix unsafe path start check --- .../path-traversal/detectPathTraversal.test.ts | 4 ++++ .../path-traversal/unsafePathStart.ts | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts b/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts index e57980aea..a96317d5a 100644 --- a/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts +++ b/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts @@ -64,6 +64,10 @@ t.test("it flags ..\\..\\..\\", async () => { t.same(detectPathTraversal("..\\..\\..\\test.txt", "..\\..\\..\\"), true); }); +t.test("it flags ./../", async () => { + t.same(detectPathTraversal("./../test.txt", "./../"), true); +}); + t.test("user input is longer than file path", async () => { t.same(detectPathTraversal("../file.txt", "../../file.txt"), false); }); diff --git a/library/vulnerabilities/path-traversal/unsafePathStart.ts b/library/vulnerabilities/path-traversal/unsafePathStart.ts index 7892b4ced..d1314051b 100644 --- a/library/vulnerabilities/path-traversal/unsafePathStart.ts +++ b/library/vulnerabilities/path-traversal/unsafePathStart.ts @@ -1,3 +1,5 @@ +import { resolve } from "path"; + const linuxRootFolders = [ "/bin/", "/boot/", @@ -22,8 +24,20 @@ const linuxRootFolders = [ const dangerousPathStarts = [...linuxRootFolders, "c:/", "c:\\"]; export function startsWithUnsafePath(filePath: string, userInput: string) { - const normalizedPath = filePath.replace(/^(\/\.)*/g, "").toLowerCase(); - const normalizedUserInput = userInput.replace(/^(\/\.)*/g, "").toLowerCase(); + // Check if path is relative (not absolute or drive letter path) + // Required because resolve will build absolute paths from relative paths + if (!/^(\/|\w:).*/.test(filePath)) { + return false; + } + + let origResolve = resolve; + if (resolve.__wrapped) { + // @ts-expect-error Not type safe + origResolve = resolve.__original; + } + + const normalizedPath = origResolve(filePath).toLowerCase(); + const normalizedUserInput = origResolve(userInput).toLowerCase(); for (const dangerousStart of dangerousPathStarts) { if ( normalizedPath.startsWith(dangerousStart) && From 65007e8f7012dc53903c6d2f83a1ad2994499208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Fri, 14 Jun 2024 17:01:14 +0200 Subject: [PATCH 03/53] Add unit tests for relative path check --- .../path-traversal/unsafePathStart.test.ts | 23 +++++++++++++++++++ .../path-traversal/unsafePathStart.ts | 6 ++++- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 library/vulnerabilities/path-traversal/unsafePathStart.test.ts diff --git a/library/vulnerabilities/path-traversal/unsafePathStart.test.ts b/library/vulnerabilities/path-traversal/unsafePathStart.test.ts new file mode 100644 index 000000000..95bb896f0 --- /dev/null +++ b/library/vulnerabilities/path-traversal/unsafePathStart.test.ts @@ -0,0 +1,23 @@ +import * as t from "tap"; +import { isRelativePath } from "./unsafePathStart"; + +t.test("is relative path", async () => { + t.same(isRelativePath("../test"), true); + t.same(isRelativePath("test"), true); + t.same(isRelativePath("../../test"), true); + t.same(isRelativePath("test/folder"), true); + t.same(isRelativePath("./test"), true); +}); + +t.test("is not relative path", async () => { + t.same(isRelativePath("/test"), false); + t.same(isRelativePath("c:/test"), false); + t.same(isRelativePath("c:\\test"), false); + t.same(isRelativePath("C:/test/folder"), false); + t.same(isRelativePath("c:\\test\\folder"), false); + t.same(isRelativePath("D:\\test"), false); + t.same(isRelativePath("/./test"), false); + t.same(isRelativePath("/../test"), false); + t.same(isRelativePath("/test/folder"), false); + t.same(isRelativePath("//test"), false); +}); diff --git a/library/vulnerabilities/path-traversal/unsafePathStart.ts b/library/vulnerabilities/path-traversal/unsafePathStart.ts index d1314051b..439a70c99 100644 --- a/library/vulnerabilities/path-traversal/unsafePathStart.ts +++ b/library/vulnerabilities/path-traversal/unsafePathStart.ts @@ -26,7 +26,7 @@ const dangerousPathStarts = [...linuxRootFolders, "c:/", "c:\\"]; export function startsWithUnsafePath(filePath: string, userInput: string) { // Check if path is relative (not absolute or drive letter path) // Required because resolve will build absolute paths from relative paths - if (!/^(\/|\w:).*/.test(filePath)) { + if (isRelativePath(filePath)) { return false; } @@ -48,3 +48,7 @@ export function startsWithUnsafePath(filePath: string, userInput: string) { } return false; } + +export function isRelativePath(filePath: string) { + return !/^(\/|\w:).*/.test(filePath); +} From aa7c488efdc67fd0aa40adb2c3ede637702c54bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Mon, 2 Sep 2024 14:31:52 +0200 Subject: [PATCH 04/53] Improve undici request option parsing --- library/sinks/Undici.test.ts | 14 +++ library/sinks/Undici.ts | 85 ++------------ .../sinks/undici/getHostInfoFromArgs.test.ts | 106 ++++++++++++++++++ library/sinks/undici/getHostInfoFromArgs.ts | 94 ++++++++++++++++ 4 files changed, 225 insertions(+), 74 deletions(-) create mode 100644 library/sinks/undici/getHostInfoFromArgs.test.ts create mode 100644 library/sinks/undici/getHostInfoFromArgs.ts diff --git a/library/sinks/Undici.test.ts b/library/sinks/Undici.test.ts index 101c07452..8b000a9a0 100644 --- a/library/sinks/Undici.test.ts +++ b/library/sinks/Undici.test.ts @@ -139,6 +139,20 @@ t.test( ]); agent.getHostnames().clear(); + await request(require("url").parse("https://aikido.dev")); + t.same(agent.getHostnames().asArray(), [ + { hostname: "aikido.dev", port: "443" }, + ]); + agent.getHostnames().clear(); + + await request({ + origin: "https://aikido.dev", + }); + t.same(agent.getHostnames().asArray(), [ + { hostname: "aikido.dev", port: "443" }, + ]); + agent.getHostnames().clear(); + await t.rejects(() => request("invalid url")); await t.rejects(() => request({ hostname: "" })); diff --git a/library/sinks/Undici.ts b/library/sinks/Undici.ts index 319efcd3f..8e735680f 100644 --- a/library/sinks/Undici.ts +++ b/library/sinks/Undici.ts @@ -14,6 +14,7 @@ import { checkContextForSSRF } from "../vulnerabilities/ssrf/checkContextForSSRF import { inspectDNSLookupCalls } from "../vulnerabilities/ssrf/inspectDNSLookupCalls"; import { wrapDispatch } from "./undici/wrapDispatch"; import { isOptionsObject } from "./http-request/isOptionsObject"; +import { getHostInfoFromArgs } from "./undici/getHostInfoFromArgs"; const methods = [ "request", @@ -56,80 +57,16 @@ export class Undici implements Wrapper { agent: Agent, method: string ): InterceptorResult { - if (args.length > 0) { - if (typeof args[0] === "string" && args[0].length > 0) { - const url = tryParseURL(args[0]); - if (url) { - const attack = this.inspectHostname( - agent, - url.hostname, - getPortFromURL(url), - method - ); - 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 = tryParseURL(args[0].toString()); - if (url) { - const attack = this.inspectHostname( - agent, - url.hostname, - getPortFromURL(url), - method - ); - if (attack) { - return attack; - } - } - } - - if (args[0] instanceof URL && args[0].hostname.length > 0) { - const attack = this.inspectHostname( - agent, - args[0].hostname, - getPortFromURL(args[0]), - method - ); - if (attack) { - return attack; - } - } - - if ( - isOptionsObject(args[0]) && - typeof args[0].hostname === "string" && - args[0].hostname.length > 0 - ) { - let port = 80; - if (typeof args[0].protocol === "string") { - port = args[0].protocol === "https:" ? 443 : 80; - } - if (typeof args[0].port === "number") { - port = args[0].port; - } else if ( - typeof args[0].port === "string" && - Number.isInteger(parseInt(args[0].port, 10)) - ) { - port = parseInt(args[0].port, 10); - } - - const attack = this.inspectHostname( - agent, - args[0].hostname, - port, - method - ); - if (attack) { - return attack; - } + const hostInfo = getHostInfoFromArgs(args); + if (hostInfo) { + const attack = this.inspectHostname( + agent, + hostInfo.hostname, + hostInfo.port, + method + ); + if (attack) { + return attack; } } diff --git a/library/sinks/undici/getHostInfoFromArgs.test.ts b/library/sinks/undici/getHostInfoFromArgs.test.ts new file mode 100644 index 000000000..155a55bcc --- /dev/null +++ b/library/sinks/undici/getHostInfoFromArgs.test.ts @@ -0,0 +1,106 @@ +import * as t from "tap"; +import { getHostInfoFromArgs as get } from "./getHostInfoFromArgs"; +import { parse as parseUrl } from "url"; + +t.test("it works with url string", async (t) => { + t.same(get(["http://localhost:4000"]), { + hostname: "localhost", + port: 4000, + }); + t.same(get(["http://localhost?test=1"]), { + hostname: "localhost", + port: 80, + }); + t.same(get(["https://localhost"]), { + hostname: "localhost", + port: 443, + }); +}); + +t.test("it works with url object", async (t) => { + t.same(get([new URL("http://localhost:4000")]), { + hostname: "localhost", + port: 4000, + }); + t.same(get([new URL("http://localhost?test=1")]), { + hostname: "localhost", + port: 80, + }); + t.same(get([new URL("https://localhost")]), { + hostname: "localhost", + port: 443, + }); +}); + +t.test("it works with an array of strings", async (t) => { + t.same(get([["http://localhost:4000"]]), { + hostname: "localhost", + port: 4000, + }); + t.same(get([["http://localhost?test=1"]]), { + hostname: "localhost", + port: 80, + }); + t.same(get([["https://localhost"]]), { + hostname: "localhost", + port: 443, + }); +}); + +t.test("it works with an legacy url object", async (t) => { + t.same(get([parseUrl("http://localhost:4000")]), { + hostname: "localhost", + port: 4000, + }); + t.same(get([parseUrl("http://localhost?test=1")]), { + hostname: "localhost", + port: 80, + }); + t.same(get([parseUrl("https://localhost")]), { + hostname: "localhost", + port: 443, + }); +}); + +t.test("it works with an options object containing origin", async (t) => { + t.same(get([{ origin: "http://localhost:4000" }]), { + hostname: "localhost", + port: 4000, + }); + t.same(get([{ origin: "http://localhost?test=1" }]), { + hostname: "localhost", + port: 80, + }); + t.same(get([{ origin: "https://localhost" }]), { + hostname: "localhost", + port: 443, + }); +}); + +t.test( + "it works with an options object containing protocol, hostname and port", + async (t) => { + t.same(get([{ protocol: "http:", hostname: "localhost", port: 4000 }]), { + hostname: "localhost", + port: 4000, + }); + t.same(get([{ hostname: "localhost", port: 4000 }]), { + hostname: "localhost", + port: 4000, + }); + t.same(get([{ protocol: "https:", hostname: "localhost" }]), { + hostname: "localhost", + port: 443, + }); + } +); + +t.test("invalid origin url", async (t) => { + t.same(get([{ origin: "invalid url" }]), undefined); + t.same(get([{ origin: "" }]), undefined); +}); + +t.test("without hostname", async (t) => { + t.same(get([{}]), undefined); + t.same(get([{ protocol: "https:", port: 4000 }]), undefined); +}); diff --git a/library/sinks/undici/getHostInfoFromArgs.ts b/library/sinks/undici/getHostInfoFromArgs.ts new file mode 100644 index 000000000..36528a578 --- /dev/null +++ b/library/sinks/undici/getHostInfoFromArgs.ts @@ -0,0 +1,94 @@ +import { getPortFromURL } from "../../helpers/getPortFromURL"; +import { tryParseURL } from "../../helpers/tryParseURL"; +import { isOptionsObject } from "../http-request/isOptionsObject"; + +/** + * Extract hostname and port from the arguments of a undici request. + * Used for SSRF detection. + */ +export function getHostInfoFromArgs(args: unknown[]): + | { + hostname: string; + port: number | undefined; + } + | undefined { + let url: URL | undefined; + + if (args.length > 0) { + // URL provided as a string + if (typeof args[0] === "string" && args[0].length > 0) { + url = tryParseURL(args[0]); + } + // 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])) { + url = tryParseURL(args[0].toString()); + } + + // URL provided as a URL object + if (args[0] instanceof URL) { + url = args[0]; + } + + // If url is not undefined, extract the hostname and port + if (url && url.hostname.length > 0) { + return { + hostname: url.hostname, + port: getPortFromURL(url), + }; + } + + // Check if it can be a request options object + if (isOptionsObject(args[0])) { + return parseOptionsObject(args[0]); + } + } + return undefined; +} + +/** + * Parse a undici request options object to extract hostname and port. + */ +function parseOptionsObject(obj: any): + | { + hostname: string; + port: number | undefined; + } + | undefined { + // Origin is preferred over hostname + // See https://github.com/nodejs/undici/blob/c926a43ac5952b8b5a6c7d15529b56599bc1b762/lib/core/util.js#L177 + if (obj.origin != null && typeof obj.origin === "string") { + const url = tryParseURL(obj.origin); + if (url) { + return { + hostname: url.hostname, + port: getPortFromURL(url), + }; + } + // Undici should throw an error if the origin is not a valid URL + return undefined; + } + + let port = 80; + if (typeof obj.protocol === "string") { + port = obj.protocol === "https:" ? 443 : 80; + } + if (typeof obj.port === "number") { + port = obj.port; + } else if ( + typeof obj.port === "string" && + Number.isInteger(parseInt(obj.port, 10)) + ) { + port = parseInt(obj.port, 10); + } + // hostname is required by undici and host is not supported + if (typeof obj.hostname !== "string" || obj.hostname.length === 0) { + return undefined; + } + return { + hostname: obj.hostname, + port, + }; +} From ca43af54fde47fdb438b305ec8ff9bdb4fa40358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Wed, 4 Sep 2024 16:48:01 +0200 Subject: [PATCH 05/53] Enable firewall only if env vars are set --- library/agent/protect.ts | 17 ++------------- library/helpers/isDebugging.ts | 8 +++++++ library/helpers/shouldBlock.ts | 13 +++++++++++ library/helpers/shouldEnableFirewall.ts | 29 +++++++++++++++++++++++++ library/index.ts | 4 +++- 5 files changed, 55 insertions(+), 16 deletions(-) create mode 100644 library/helpers/isDebugging.ts create mode 100644 library/helpers/shouldBlock.ts create mode 100644 library/helpers/shouldEnableFirewall.ts diff --git a/library/agent/protect.ts b/library/agent/protect.ts index 2b930e99a..612775070 100644 --- a/library/agent/protect.ts +++ b/library/agent/protect.ts @@ -40,21 +40,8 @@ import { Hapi } from "../sources/Hapi"; import { Shelljs } from "../sinks/Shelljs"; import { NodeSQLite } from "../sinks/NodeSqlite"; import { BetterSQLite3 } from "../sinks/BetterSQLite3"; - -function isDebugging() { - return ( - process.env.AIKIDO_DEBUG === "true" || process.env.AIKIDO_DEBUG === "1" - ); -} - -function shouldBlock() { - return ( - process.env.AIKIDO_BLOCKING === "true" || - process.env.AIKIDO_BLOCKING === "1" || - process.env.AIKIDO_BLOCK === "true" || - process.env.AIKIDO_BLOCK === "1" - ); -} +import { isDebugging } from "../helpers/isDebugging"; +import { shouldBlock } from "../helpers/shouldBlock"; function getLogger(): Logger { if (isDebugging()) { diff --git a/library/helpers/isDebugging.ts b/library/helpers/isDebugging.ts new file mode 100644 index 000000000..175a13920 --- /dev/null +++ b/library/helpers/isDebugging.ts @@ -0,0 +1,8 @@ +/** + * Checks if AIKIDO_DEBUG is set to true or 1 + */ +export function isDebugging() { + return ( + process.env.AIKIDO_DEBUG === "true" || process.env.AIKIDO_DEBUG === "1" + ); +} diff --git a/library/helpers/shouldBlock.ts b/library/helpers/shouldBlock.ts new file mode 100644 index 000000000..699df2873 --- /dev/null +++ b/library/helpers/shouldBlock.ts @@ -0,0 +1,13 @@ +/** + * Check the environment variables to see if the firewall should block requests if an attack is detected. + * - AIKIDO_BLOCKING=true or AIKIDO_BLOCKING=1 + * - AIKIDO_BLOCK=true or AIKIDO_BLOCK=1 + */ +export function shouldBlock() { + return ( + process.env.AIKIDO_BLOCKING === "true" || + process.env.AIKIDO_BLOCKING === "1" || + process.env.AIKIDO_BLOCK === "true" || + process.env.AIKIDO_BLOCK === "1" + ); +} diff --git a/library/helpers/shouldEnableFirewall.ts b/library/helpers/shouldEnableFirewall.ts new file mode 100644 index 000000000..6f3654092 --- /dev/null +++ b/library/helpers/shouldEnableFirewall.ts @@ -0,0 +1,29 @@ +import { isDebugging } from "./isDebugging"; +import { shouldBlock } from "./shouldBlock"; + +/** + * Only enable firewall if at least one of the following environment variables is set to a valid value: + * - AIKIDO_BLOCKING + * - AIKIDO_BLOCK + * - AIKIDO_TOKEN + * - AIKIDO_DEBUG + */ +export default function shouldEnableFirewall() { + if (shouldBlock()) { + return true; + } + + if (process.env.AIKIDO_TOKEN) { + return true; + } + + if (isDebugging()) { + return true; + } + + // eslint-disable-next-line no-console + console.log( + "AIKIDO: Firewall is disabled. Configure one of the following environment variables to enable it: AIKIDO_BLOCK, AIKIDO_TOKEN, AIKIDO_DEBUG." + ); + return false; +} diff --git a/library/index.ts b/library/index.ts index e74fce00e..e28b75740 100644 --- a/library/index.ts +++ b/library/index.ts @@ -1,7 +1,9 @@ import isFirewallSupported from "./helpers/isFirewallSupported"; +import shouldEnableFirewall from "./helpers/shouldEnableFirewall"; const supported = isFirewallSupported(); +const shouldEnable = shouldEnableFirewall(); -if (supported) { +if (supported && shouldEnable) { require("./agent/protect").protect(); } From 5490bab5db50a4fc9e457e2d86182a289ded4eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Wed, 4 Sep 2024 16:54:39 +0200 Subject: [PATCH 06/53] Fix benchmarks --- benchmarks/hono-pg/benchmark.js | 2 +- library/helpers/shouldEnableFirewall.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/benchmarks/hono-pg/benchmark.js b/benchmarks/hono-pg/benchmark.js index e70bef949..9eaca474a 100644 --- a/benchmarks/hono-pg/benchmark.js +++ b/benchmarks/hono-pg/benchmark.js @@ -7,7 +7,7 @@ const spawn = require("child_process").spawn; async function startServer(firewallEnabled) { console.log("Spawning server. Firewall enabled:", firewallEnabled); - let env = { ...process.env }; + let env = { ...process.env, AIKIDO_CI: "true" }; if (firewallEnabled) { env = { ...env, diff --git a/library/helpers/shouldEnableFirewall.ts b/library/helpers/shouldEnableFirewall.ts index 6f3654092..4a983b660 100644 --- a/library/helpers/shouldEnableFirewall.ts +++ b/library/helpers/shouldEnableFirewall.ts @@ -1,3 +1,4 @@ +import { isAikidoCI } from "./isAikidoCI"; import { isDebugging } from "./isDebugging"; import { shouldBlock } from "./shouldBlock"; @@ -21,9 +22,11 @@ export default function shouldEnableFirewall() { return true; } - // eslint-disable-next-line no-console - console.log( - "AIKIDO: Firewall is disabled. Configure one of the following environment variables to enable it: AIKIDO_BLOCK, AIKIDO_TOKEN, AIKIDO_DEBUG." - ); + if (!isAikidoCI()) { + // eslint-disable-next-line no-console + console.log( + "AIKIDO: Firewall is disabled. Configure one of the following environment variables to enable it: AIKIDO_BLOCK, AIKIDO_TOKEN, AIKIDO_DEBUG." + ); + } return false; } From 2b32f563b67bbeca30c1db5079832c921c0ba12c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Wed, 4 Sep 2024 17:08:15 +0200 Subject: [PATCH 07/53] Add unit test for shouldEnableFirewall --- library/helpers/shouldEnableFirewall.test.ts | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 library/helpers/shouldEnableFirewall.test.ts diff --git a/library/helpers/shouldEnableFirewall.test.ts b/library/helpers/shouldEnableFirewall.test.ts new file mode 100644 index 000000000..b3ca4d239 --- /dev/null +++ b/library/helpers/shouldEnableFirewall.test.ts @@ -0,0 +1,37 @@ +import * as t from "tap"; +import shouldEnableFirewall from "./shouldEnableFirewall"; + +t.test("disabled by default", async () => { + t.same(shouldEnableFirewall(), false); +}); + +t.test("works with AIKIDO_DEBUG", async () => { + process.env.AIKIDO_DEBUG = "1"; + t.same(shouldEnableFirewall(), true); + process.env.AIKIDO_DEBUG = "true"; + t.same(shouldEnableFirewall(), true); + process.env.AIKIDO_DEBUG = ""; + t.same(shouldEnableFirewall(), false); +}); + +t.test("works with AIKIDO_BLOCK", async () => { + process.env.AIKIDO_BLOCK = "1"; + t.same(shouldEnableFirewall(), true); + process.env.AIKIDO_BLOCK = "true"; + t.same(shouldEnableFirewall(), true); + process.env.AIKIDO_BLOCK = ""; + t.same(shouldEnableFirewall(), false); +}); + +t.test("works with AIKIDO_TOKEN", async () => { + process.env.AIKIDO_TOKEN = "abc123"; + t.same(shouldEnableFirewall(), true); + process.env.AIKIDO_TOKEN = ""; + t.same(shouldEnableFirewall(), false); +}); + +t.test("it works if multiple are set", async () => { + process.env.AIKIDO_DEBUG = "1"; + process.env.AIKIDO_BLOCK = "1"; + t.same(shouldEnableFirewall(), true); +}); From f3c7e9e41e7cb1f9eddf65500432801e8a9e9aeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Thu, 19 Sep 2024 12:46:45 +0200 Subject: [PATCH 08/53] Extend shell command list --- .../vulnerabilities/shell-injection/containsShellSyntax.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/library/vulnerabilities/shell-injection/containsShellSyntax.ts b/library/vulnerabilities/shell-injection/containsShellSyntax.ts index 5592db0cc..87919f236 100644 --- a/library/vulnerabilities/shell-injection/containsShellSyntax.ts +++ b/library/vulnerabilities/shell-injection/containsShellSyntax.ts @@ -83,6 +83,13 @@ const commands = [ "set", "lsattr", "killall5", + "dmesg", + "history", + "free", + "uptime", + "finger", + "top", + "shopt", // Colon is a null command // it might occur in URLs that are passed as arguments to a binary From f494a7e2e0e027cf631b50417a020d03930586a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Thu, 19 Sep 2024 14:17:56 +0200 Subject: [PATCH 09/53] Only run npm install if package.json exists --- scripts/install.js | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/scripts/install.js b/scripts/install.js index c86a2f204..a32041355 100644 --- a/scripts/install.js +++ b/scripts/install.js @@ -1,29 +1,37 @@ -const { readdir, stat } = require("fs/promises"); +const { readdir, stat, access, constants } = require("fs/promises"); const { join } = require("path"); const { exec } = require("child_process"); const { promisify } = require("util"); const execAsync = promisify(exec); async function main() { - const sampleApps = await readdir(join(__dirname, "../sample-apps")); + const sampleAppsDir = join(__dirname, "../sample-apps"); + const sampleApps = await readdir(sampleAppsDir); await Promise.all( sampleApps.map(async (file) => { - const stats = await stat(join(__dirname, "../sample-apps", file)); + const stats = await stat(join(sampleAppsDir, file)); - if (!stats.isFile()) { + if ( + !stats.isFile() && + (await fileExists(join(sampleAppsDir, file, "package.json"))) + ) { await installSampleAppDeps(file); } }) ); - const benchmarks = await readdir(join(__dirname, "../benchmarks")); + const benchmarksDir = join(__dirname, "../benchmarks"); + const benchmarks = await readdir(benchmarksDir); await Promise.all( benchmarks.map(async (file) => { - const stats = await stat(join(__dirname, "../benchmarks", file)); + const stats = await stat(join(benchmarksDir, file)); - if (!stats.isFile()) { + if ( + !stats.isFile() && + (await fileExists(join(benchmarksDir, file, "package.json"))) + ) { await installBenchmarkDeps(file); } }) @@ -60,6 +68,15 @@ async function installBenchmarkDeps(benchmark) { } } +async function fileExists(path) { + try { + await access(path, constants.F_OK); + return true; + } catch { + return false; + } +} + (async () => { try { await main(); From ea7059ed6bb8b449622d4d5fbf8d1bdc0077cd84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Thu, 19 Sep 2024 15:39:46 +0200 Subject: [PATCH 10/53] Fix empty object is added to apispec --- library/agent/Routes.test.ts | 14 ++++++++++++++ library/agent/api-discovery/getApiInfo.ts | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/library/agent/Routes.test.ts b/library/agent/Routes.test.ts index 1064498dc..4efa6912f 100644 --- a/library/agent/Routes.test.ts +++ b/library/agent/Routes.test.ts @@ -548,3 +548,17 @@ t.test("it merges auth schema", async (t) => { }, ]); }); + +t.test("it ignores empty body objects", async (t) => { + const routes = new Routes(200); + routes.addRoute(getContext("GET", "/empty", {}, {}, {}, {})); + t.same(routes.asArray(), [ + { + method: "GET", + path: "/empty", + hits: 1, + graphql: undefined, + apispec: {}, + }, + ]); +}); diff --git a/library/agent/api-discovery/getApiInfo.ts b/library/agent/api-discovery/getApiInfo.ts index b609666b3..ad14157da 100644 --- a/library/agent/api-discovery/getApiInfo.ts +++ b/library/agent/api-discovery/getApiInfo.ts @@ -27,7 +27,11 @@ export function getApiInfo(context: Context): APISpec | undefined { try { let bodyInfo: APIBodyInfo | undefined; - if (context.body && typeof context.body === "object") { + if ( + context.body && + typeof context.body === "object" && + Object.keys(context.body).length > 0 + ) { bodyInfo = { type: getBodyDataType(context.headers), schema: getDataSchema(context.body), From cc598666f5e12373b13b2e9855d4e8e60b7f2b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Thu, 19 Sep 2024 16:33:18 +0200 Subject: [PATCH 11/53] Ignore GraphQL body in apispec --- library/agent/Routes.test.ts | 35 +++++++++++++++++++++++ library/agent/api-discovery/getApiInfo.ts | 3 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/library/agent/Routes.test.ts b/library/agent/Routes.test.ts index 4efa6912f..19816cb35 100644 --- a/library/agent/Routes.test.ts +++ b/library/agent/Routes.test.ts @@ -562,3 +562,38 @@ t.test("it ignores empty body objects", async (t) => { }, ]); }); + +t.test("it ignores body of graphql queries", async (t) => { + const routes = new Routes(200); + routes.addRoute({ + ...getContext( + "POST", + "/graphql", + { + "content-type": "application/json", + "x-api-key": "123", + }, + { + query: "query { user { name } }", + }, + {}, + {} + ), + ...{ + graphql: ["name"], + }, + }); + t.same(routes.asArray(), [ + { + method: "POST", + path: "/graphql", + hits: 1, + graphql: undefined, + apispec: { + body: undefined, + query: undefined, + auth: [{ type: "apiKey", in: "header", name: "x-api-key" }], + }, + }, + ]); +}); diff --git a/library/agent/api-discovery/getApiInfo.ts b/library/agent/api-discovery/getApiInfo.ts index ad14157da..2169acb7e 100644 --- a/library/agent/api-discovery/getApiInfo.ts +++ b/library/agent/api-discovery/getApiInfo.ts @@ -30,7 +30,8 @@ export function getApiInfo(context: Context): APISpec | undefined { if ( context.body && typeof context.body === "object" && - Object.keys(context.body).length > 0 + Object.keys(context.body).length > 0 && + !context.graphql ) { bodyInfo = { type: getBodyDataType(context.headers), From de5889214ff5ec17c95834afaf0c45c84c99cbd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Thu, 19 Sep 2024 17:03:47 +0200 Subject: [PATCH 12/53] Always set graphql in context if used --- library/sources/GraphQL.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/library/sources/GraphQL.ts b/library/sources/GraphQL.ts index 984c04883..fc2d9a0a8 100644 --- a/library/sources/GraphQL.ts +++ b/library/sources/GraphQL.ts @@ -58,12 +58,10 @@ export class GraphQL implements Wrapper { } } - if (userInputs.length > 0) { - if (Array.isArray(context.graphql)) { - updateContext(context, "graphql", context.graphql.concat(userInputs)); - } else { - updateContext(context, "graphql", userInputs); - } + if (Array.isArray(context.graphql)) { + updateContext(context, "graphql", context.graphql.concat(userInputs)); + } else { + updateContext(context, "graphql", userInputs); } } From 32afb0d38a05f8be9aa90a159c382b06225ad6ec Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Thu, 19 Sep 2024 17:31:57 +0200 Subject: [PATCH 13/53] Detect BSON object IDs in URLs --- library/helpers/buildRouteFromURL.test.ts | 13 +++++++++++++ library/helpers/buildRouteFromURL.ts | 5 +++++ library/package-lock.json | 7 +++++++ library/package.json | 1 + 4 files changed, 26 insertions(+) diff --git a/library/helpers/buildRouteFromURL.test.ts b/library/helpers/buildRouteFromURL.test.ts index bedb7e89b..d01ebe7b3 100644 --- a/library/helpers/buildRouteFromURL.test.ts +++ b/library/helpers/buildRouteFromURL.test.ts @@ -1,5 +1,6 @@ import * as t from "tap"; import { buildRouteFromURL } from "./buildRouteFromURL"; +import * as ObjectID from "bson-objectid"; t.test("it returns undefined for invalid URLs", async () => { t.same(buildRouteFromURL(""), undefined); @@ -144,3 +145,15 @@ t.test("it replaces secrets", async () => { "/confirm/:secret" ); }); + +t.test("it replaces BSON ObjectIDs", async () => { + t.same( + // @ts-expect-error It says that the expression isn't callable + buildRouteFromURL(`/posts/${ObjectID().toHexString()}`), + "/posts/:objectId" + ); + t.same( + buildRouteFromURL(`/posts/66ec29159d00113616fc7184`), + "/posts/:objectId" + ); +}); diff --git a/library/helpers/buildRouteFromURL.ts b/library/helpers/buildRouteFromURL.ts index aab5835bf..93f4d7dde 100644 --- a/library/helpers/buildRouteFromURL.ts +++ b/library/helpers/buildRouteFromURL.ts @@ -4,6 +4,7 @@ import { isIP } from "net"; const UUID = /(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i; +const OBJECT_ID = /^[0-9a-f]{24}$/i; const NUMBER = /^\d+$/; const DATE = /^\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4}$/; const EMAIL = @@ -43,6 +44,10 @@ function replaceURLSegmentWithParam(segment: string) { return ":uuid"; } + if (segment.length === 24 && OBJECT_ID.test(segment)) { + return ":objectId"; + } + if (startsWithNumber && DATE.test(segment)) { return ":date"; } diff --git a/library/package-lock.json b/library/package-lock.json index 62b9e9283..68f4c0b7a 100644 --- a/library/package-lock.json +++ b/library/package-lock.json @@ -30,6 +30,7 @@ "aws-sdk": "^2.1595.0", "axios": "^1.7.3", "better-sqlite3": "^11.2.0", + "bson-objectid": "^2.0.4", "cookie-parser": "^1.4.6", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", @@ -4567,6 +4568,12 @@ "node": ">=16.20.1" } }, + "node_modules/bson-objectid": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/bson-objectid/-/bson-objectid-2.0.4.tgz", + "integrity": "sha512-vgnKAUzcDoa+AeyYwXCoHyF2q6u/8H46dxu5JN+4/TZeq/Dlinn0K6GvxsCLb3LHUJl0m/TLiEK31kUwtgocMQ==", + "dev": true + }, "node_modules/buffer": { "version": "4.9.2", "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", diff --git a/library/package.json b/library/package.json index 3c660d277..3e2696de0 100644 --- a/library/package.json +++ b/library/package.json @@ -53,6 +53,7 @@ "aws-sdk": "^2.1595.0", "axios": "^1.7.3", "better-sqlite3": "^11.2.0", + "bson-objectid": "^2.0.4", "cookie-parser": "^1.4.6", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", From c486041894fe7e88fb34243c43e5d5b450c57b3d Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 20 Sep 2024 15:10:22 +0200 Subject: [PATCH 14/53] Set metadata in DNS lookup for SSRF attack --- .../ssrf/checkContextForSSRF.ts | 19 +------ .../ssrf/getMetadataForSSRFAttack.test.ts | 53 +++++++++++++++++++ .../ssrf/getMetadataForSSRFAttack.ts | 17 ++++++ .../ssrf/inspectDNSLookupCalls.test.ts | 4 ++ .../ssrf/inspectDNSLookupCalls.ts | 5 +- 5 files changed, 77 insertions(+), 21 deletions(-) create mode 100644 library/vulnerabilities/ssrf/getMetadataForSSRFAttack.test.ts create mode 100644 library/vulnerabilities/ssrf/getMetadataForSSRFAttack.ts diff --git a/library/vulnerabilities/ssrf/checkContextForSSRF.ts b/library/vulnerabilities/ssrf/checkContextForSSRF.ts index 0de95cbe7..7cbbac067 100644 --- a/library/vulnerabilities/ssrf/checkContextForSSRF.ts +++ b/library/vulnerabilities/ssrf/checkContextForSSRF.ts @@ -4,6 +4,7 @@ import { SOURCES } from "../../agent/Source"; import { extractStringsFromUserInputCached } from "../../helpers/extractStringsFromUserInputCached"; import { containsPrivateIPAddress } from "./containsPrivateIPAddress"; import { findHostnameInUserInput } from "./findHostnameInUserInput"; +import { getMetadataForSSRFAttack } from "./getMetadataForSSRFAttack"; /** * This function goes over all the different input types in the context and checks @@ -41,21 +42,3 @@ export function checkContextForSSRF({ } } } - -function getMetadataForSSRFAttack({ - hostname, - port, -}: { - hostname: string; - port: number | undefined; -}): Record { - const metadata: Record = { - hostname: hostname, - }; - - if (port) { - metadata.port = port.toString(); - } - - return metadata; -} diff --git a/library/vulnerabilities/ssrf/getMetadataForSSRFAttack.test.ts b/library/vulnerabilities/ssrf/getMetadataForSSRFAttack.test.ts new file mode 100644 index 000000000..32a29bd90 --- /dev/null +++ b/library/vulnerabilities/ssrf/getMetadataForSSRFAttack.test.ts @@ -0,0 +1,53 @@ +import * as t from "tap"; +import { getMetadataForSSRFAttack } from "./getMetadataForSSRFAttack"; + +t.test("port is undefined", async () => { + t.same( + getMetadataForSSRFAttack({ + hostname: "example.com", + port: undefined, + }), + { + hostname: "example.com", + } + ); +}); + +t.test("port is defined", async () => { + t.same( + getMetadataForSSRFAttack({ + hostname: "example.com", + port: 80, + }), + { + hostname: "example.com", + port: "80", + } + ); +}); + +t.test("port is 443", async () => { + t.same( + getMetadataForSSRFAttack({ + hostname: "example.com", + port: 443, + }), + { + hostname: "example.com", + port: "443", + } + ); +}); + +t.test("port is 0", async () => { + t.same( + getMetadataForSSRFAttack({ + hostname: "example.com", + port: 0, + }), + { + hostname: "example.com", + port: "0", + } + ); +}); diff --git a/library/vulnerabilities/ssrf/getMetadataForSSRFAttack.ts b/library/vulnerabilities/ssrf/getMetadataForSSRFAttack.ts new file mode 100644 index 000000000..863606375 --- /dev/null +++ b/library/vulnerabilities/ssrf/getMetadataForSSRFAttack.ts @@ -0,0 +1,17 @@ +export function getMetadataForSSRFAttack({ + hostname, + port, +}: { + hostname: string; + port: number | undefined; +}): Record { + const metadata: Record = { + hostname: hostname, + }; + + if (typeof port === "number") { + metadata.port = port.toString(); + } + + return metadata; +} diff --git a/library/vulnerabilities/ssrf/inspectDNSLookupCalls.test.ts b/library/vulnerabilities/ssrf/inspectDNSLookupCalls.test.ts index 3cb46ace1..ab186ea13 100644 --- a/library/vulnerabilities/ssrf/inspectDNSLookupCalls.test.ts +++ b/library/vulnerabilities/ssrf/inspectDNSLookupCalls.test.ts @@ -93,6 +93,10 @@ t.test("it blocks lookup in blocking mode", (t) => { type: "detected_attack", attack: { kind: "ssrf", + metadata: { + hostname: "localhost", + port: undefined, + }, }, }, ]); diff --git a/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts b/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts index f12672c8f..765b4c44f 100644 --- a/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts +++ b/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts @@ -5,6 +5,7 @@ import { attackKindHumanName } from "../../agent/Attack"; import { getContext } from "../../agent/Context"; import { escapeHTML } from "../../helpers/escapeHTML"; import { isPlainObject } from "../../helpers/isPlainObject"; +import { getMetadataForSSRFAttack } from "./getMetadataForSSRFAttack"; import { isPrivateIP } from "./isPrivateIP"; import { isIMDSIPAddress, isTrustedHostname } from "./imds"; import { RequestContextStorage } from "../../sinks/undici/RequestContextStorage"; @@ -179,9 +180,7 @@ function wrapDNSLookupCallback( blocked: agent.shouldBlock(), stack: new Error().stack!, path: found.pathToPayload, - metadata: { - hostname: hostname, - }, + metadata: getMetadataForSSRFAttack({ hostname, port }), request: context, payload: found.payload, }); From ddf1756ac6493c9e45fdf064b395e48a83a1bca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Fri, 20 Sep 2024 15:58:36 +0200 Subject: [PATCH 15/53] Update SSRF redirect test urls --- library/sinks/Fetch.test.ts | 15 ++++++--------- library/sinks/HTTPRequest.axios.test.ts | 3 +-- library/sinks/HTTPRequest.followRedirects.test.ts | 3 +-- library/sinks/HTTPRequest.needle.test.ts | 3 +-- library/sinks/HTTPRequest.nodeFetch.test.ts | 3 +-- library/sinks/HTTPRequest.redirect.test.ts | 8 ++++---- 6 files changed, 14 insertions(+), 21 deletions(-) diff --git a/library/sinks/Fetch.test.ts b/library/sinks/Fetch.test.ts index a4a02b07f..c1539850c 100644 --- a/library/sinks/Fetch.test.ts +++ b/library/sinks/Fetch.test.ts @@ -52,7 +52,8 @@ const context: Context = { route: "/posts/:id", }; -const redirectTestUrl = +const redirectTestUrl = "http://ssrf-redirects.testssandbox.com"; +const redirecTestUrl2 = "http://firewallssrfredirects-env-2.eba-7ifve22q.eu-north-1.elasticbeanstalk.com"; const redirectUrl = { @@ -291,16 +292,13 @@ t.test( ...context, ...{ body: { - image: - "http://ec2-13-60-120-68.eu-north-1.compute.amazonaws.com/ssrf-test-absolute-domain", + image: `${redirecTestUrl2}/ssrf-test-absolute-domain`, }, }, }, async () => { const error = await t.rejects(() => - fetch( - "http://ec2-13-60-120-68.eu-north-1.compute.amazonaws.com/ssrf-test-absolute-domain" - ) + fetch(`${redirecTestUrl2}/ssrf-test-absolute-domain`) ); if (error instanceof Error) { t.same( @@ -367,14 +365,13 @@ t.test( ...context, ...{ body: { - image: - "http://ec2-13-60-120-68.eu-north-1.compute.amazonaws.com/ssrf-test-absolute-domain", + image: `${redirecTestUrl2}/ssrf-test-absolute-domain`, }, }, }, async () => { const response = await fetch( - "http://ec2-13-60-120-68.eu-north-1.compute.amazonaws.com/ssrf-test-absolute-domain", + `${redirecTestUrl2}/ssrf-test-absolute-domain`, { redirect: "manual", } diff --git a/library/sinks/HTTPRequest.axios.test.ts b/library/sinks/HTTPRequest.axios.test.ts index 1af891b65..a84a667df 100644 --- a/library/sinks/HTTPRequest.axios.test.ts +++ b/library/sinks/HTTPRequest.axios.test.ts @@ -21,8 +21,7 @@ const context: Context = { route: "/posts/:id", }; -const redirectTestUrl = - "http://firewallssrfredirects-env-2.eba-7ifve22q.eu-north-1.elasticbeanstalk.com"; +const redirectTestUrl = "http://ssrf-redirects.testssandbox.com"; t.test("it works", { skip: "SSRF redirect check disabled atm" }, async (t) => { const agent = new Agent( diff --git a/library/sinks/HTTPRequest.followRedirects.test.ts b/library/sinks/HTTPRequest.followRedirects.test.ts index 5f796c2fc..01895b2db 100644 --- a/library/sinks/HTTPRequest.followRedirects.test.ts +++ b/library/sinks/HTTPRequest.followRedirects.test.ts @@ -39,8 +39,7 @@ t.before(async () => { }); }); -const redirectTestUrl = - "http://firewallssrfredirects-env-2.eba-7ifve22q.eu-north-1.elasticbeanstalk.com"; +const redirectTestUrl = "http://ssrf-redirects.testssandbox.com"; t.test("it works", { skip: "SSRF redirect check disabled atm" }, (t) => { const agent = new Agent( diff --git a/library/sinks/HTTPRequest.needle.test.ts b/library/sinks/HTTPRequest.needle.test.ts index e81603edd..cc1bd643d 100644 --- a/library/sinks/HTTPRequest.needle.test.ts +++ b/library/sinks/HTTPRequest.needle.test.ts @@ -21,8 +21,7 @@ const context: Context = { route: "/posts/:id", }; -const redirectTestUrl = - "http://firewallssrfredirects-env-2.eba-7ifve22q.eu-north-1.elasticbeanstalk.com"; +const redirectTestUrl = "http://ssrf-redirects.testssandbox.com"; t.test("it works", { skip: "SSRF redirect check disabled atm" }, async (t) => { const agent = new Agent( diff --git a/library/sinks/HTTPRequest.nodeFetch.test.ts b/library/sinks/HTTPRequest.nodeFetch.test.ts index cce99c772..7ad3951d8 100644 --- a/library/sinks/HTTPRequest.nodeFetch.test.ts +++ b/library/sinks/HTTPRequest.nodeFetch.test.ts @@ -21,8 +21,7 @@ const context: Context = { route: "/posts/:id", }; -const redirectTestUrl = - "http://firewallssrfredirects-env-2.eba-7ifve22q.eu-north-1.elasticbeanstalk.com"; +const redirectTestUrl = "http://ssrf-redirects.testssandbox.com"; t.test("it works", { skip: "SSRF redirect check disabled atm" }, async (t) => { const agent = new Agent( diff --git a/library/sinks/HTTPRequest.redirect.test.ts b/library/sinks/HTTPRequest.redirect.test.ts index b752268f0..66374adf7 100644 --- a/library/sinks/HTTPRequest.redirect.test.ts +++ b/library/sinks/HTTPRequest.redirect.test.ts @@ -22,7 +22,8 @@ const context: Context = { route: "/posts/:id", }; -const redirectTestUrl = +const redirectTestUrl = "http://ssrf-redirects.testssandbox.com"; +const redirecTestUrl2 = "http://firewallssrfredirects-env-2.eba-7ifve22q.eu-north-1.elasticbeanstalk.com"; const redirectUrl = { @@ -141,14 +142,13 @@ t.test("it works", { skip: "SSRF redirect check disabled atm" }, (t) => { ...context, ...{ body: { - image: - "http://ec2-13-60-120-68.eu-north-1.compute.amazonaws.com/ssrf-test-absolute-domain", + image: `${redirecTestUrl2}/ssrf-test-absolute-domain`, }, }, }, () => { const response1 = http.request( - "http://ec2-13-60-120-68.eu-north-1.compute.amazonaws.com/ssrf-test-absolute-domain", + `${redirecTestUrl2}/ssrf-test-absolute-domain`, (res) => { t.same(res.statusCode, 302); t.same(res.headers.location, redirectUrl.domain); From 01b94ed893dec2f6724b2d4a5c2be4adf197572b Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 20 Sep 2024 17:07:09 +0200 Subject: [PATCH 16/53] Also accept redirects and ignore all error status codes --- .../http-server/shouldDiscoverRoute.test.ts | 54 ++++++++----------- .../http-server/shouldDiscoverRoute.ts | 13 ++--- 2 files changed, 24 insertions(+), 43 deletions(-) diff --git a/library/sources/http-server/shouldDiscoverRoute.test.ts b/library/sources/http-server/shouldDiscoverRoute.test.ts index c748d1f87..ac10649fc 100644 --- a/library/sources/http-server/shouldDiscoverRoute.test.ts +++ b/library/sources/http-server/shouldDiscoverRoute.test.ts @@ -1,41 +1,29 @@ import * as t from "tap"; import { shouldDiscoverRoute } from "./shouldDiscoverRoute"; -t.test( - "it does not discover route if not found or method not allowed", - async () => { +t.test("it rejects invalid status codes", async () => { + for (let code = 100; code <= 199; code++) { t.same( - shouldDiscoverRoute({ statusCode: 404, route: "/", method: "GET" }), + shouldDiscoverRoute({ statusCode: code, route: "/", method: "GET" }), false ); + } + + for (let code = 400; code <= 599; code++) { t.same( - shouldDiscoverRoute({ statusCode: 405, route: "/", method: "GET" }), + shouldDiscoverRoute({ statusCode: code, route: "/", method: "GET" }), false ); } -); +}); -t.test("it discovers route for all other status codes", async () => { - t.same( - shouldDiscoverRoute({ statusCode: 200, route: "/", method: "GET" }), - true - ); - t.same( - shouldDiscoverRoute({ statusCode: 500, route: "/", method: "GET" }), - true - ); - t.same( - shouldDiscoverRoute({ statusCode: 400, route: "/", method: "GET" }), - true - ); - t.same( - shouldDiscoverRoute({ statusCode: 300, route: "/", method: "GET" }), - true - ); - t.same( - shouldDiscoverRoute({ statusCode: 201, route: "/", method: "GET" }), - true - ); +t.test("it accepts valid status codes", async () => { + for (let code = 200; code <= 399; code++) { + t.same( + shouldDiscoverRoute({ statusCode: code, route: "/", method: "GET" }), + true + ); + } }); t.test("it does not discover route for OPTIONS or HEAD methods", async () => { @@ -304,25 +292,25 @@ t.test("it ignores files that end with .config", async () => { ); }); -t.test("it ignores redirects", async () => { +t.test("it allows redirects", async () => { t.same( shouldDiscoverRoute({ statusCode: 301, route: "/", method: "GET" }), - false + true ); t.same( shouldDiscoverRoute({ statusCode: 302, route: "/", method: "GET" }), - false + true ); t.same( shouldDiscoverRoute({ statusCode: 303, route: "/", method: "GET" }), - false + true ); t.same( shouldDiscoverRoute({ statusCode: 307, route: "/", method: "GET" }), - false + true ); t.same( shouldDiscoverRoute({ statusCode: 308, route: "/", method: "GET" }), - false + true ); }); diff --git a/library/sources/http-server/shouldDiscoverRoute.ts b/library/sources/http-server/shouldDiscoverRoute.ts index ce0f5715e..80f88ca5f 100644 --- a/library/sources/http-server/shouldDiscoverRoute.ts +++ b/library/sources/http-server/shouldDiscoverRoute.ts @@ -1,9 +1,4 @@ import { extname } from "path"; -import { isRedirectStatusCode } from "../../helpers/isRedirectStatusCode"; - -const NOT_FOUND = 404; -const METHOD_NOT_ALLOWED = 405; -const ERROR_CODES = [NOT_FOUND, METHOD_NOT_ALLOWED]; const EXCLUDED_METHODS = ["OPTIONS", "HEAD"]; const IGNORE_EXTENSIONS = ["properties", "php", "asp", "aspx", "jsp", "config"]; @@ -18,15 +13,13 @@ export function shouldDiscoverRoute({ route: string; method: string; }) { - if (EXCLUDED_METHODS.includes(method)) { - return false; - } + const validStatusCode = statusCode >= 200 && statusCode <= 399; - if (ERROR_CODES.includes(statusCode)) { + if (!validStatusCode) { return false; } - if (isRedirectStatusCode(statusCode)) { + if (EXCLUDED_METHODS.includes(method)) { return false; } From 6c9cdf123ead448db5b4d1d394aaf67ad8ab5339 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 20 Sep 2024 19:09:03 +0200 Subject: [PATCH 17/53] Improve stack trace for http/https.request(...) And always clean stack trace. --- library/sinks/HTTPRequest.ts | 10 +++++++--- .../ssrf/inspectDNSLookupCalls.ts | 18 +++++++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/library/sinks/HTTPRequest.ts b/library/sinks/HTTPRequest.ts index 371d69412..f842876d2 100644 --- a/library/sinks/HTTPRequest.ts +++ b/library/sinks/HTTPRequest.ts @@ -102,6 +102,7 @@ export class HTTPRequest implements Wrapper { ); const url = getUrlFromHTTPRequestArgs(args, module); + const stackTraceCallingLocation = new Error(); if (!optionObj) { const newOpts = { @@ -110,7 +111,8 @@ export class HTTPRequest implements Wrapper { agent, module, `${module}.request`, - url + url, + stackTraceCallingLocation ), }; @@ -129,7 +131,8 @@ export class HTTPRequest implements Wrapper { agent, module, `${module}.request`, - url + url, + stackTraceCallingLocation ) as RequestOptions["lookup"]; } else { optionObj.lookup = inspectDNSLookupCalls( @@ -137,7 +140,8 @@ export class HTTPRequest implements Wrapper { agent, module, `${module}.request`, - url + url, + stackTraceCallingLocation ) as RequestOptions["lookup"]; } diff --git a/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts b/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts index 765b4c44f..d02eb5393 100644 --- a/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts +++ b/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts @@ -1,8 +1,10 @@ import { isIP } from "net"; import { LookupAddress } from "dns"; +import { resolve } from "path"; import { Agent } from "../../agent/Agent"; import { attackKindHumanName } from "../../agent/Attack"; import { getContext } from "../../agent/Context"; +import { cleanupStackTrace } from "../../helpers/cleanupStackTrace"; import { escapeHTML } from "../../helpers/escapeHTML"; import { isPlainObject } from "../../helpers/isPlainObject"; import { getMetadataForSSRFAttack } from "./getMetadataForSSRFAttack"; @@ -18,7 +20,8 @@ export function inspectDNSLookupCalls( agent: Agent, module: string, operation: string, - url?: URL + url?: URL, + stackTraceCallingLocation?: Error ): Function { return function inspectDNSLookup(...args: unknown[]) { const hostname = @@ -44,6 +47,7 @@ export function inspectDNSLookupCalls( module, agent, operation, + stackTraceCallingLocation, url ), ] @@ -55,6 +59,7 @@ export function inspectDNSLookupCalls( module, agent, operation, + stackTraceCallingLocation, url ), ]; @@ -70,7 +75,8 @@ function wrapDNSLookupCallback( module: string, agent: Agent, operation: string, - urlArg?: URL + urlArg?: URL, + callingLocationStackTrace?: Error ): Function { // eslint-disable-next-line max-lines-per-function return function wrappedDNSLookupCallback( @@ -172,13 +178,19 @@ function wrapDNSLookupCallback( return callback(err, addresses, family); } + const libraryRoot = resolve(__dirname, "../.."); + + // Used to get the stack trace of the calling location + // We don't throw the error, we just use it to get the stack trace + const stackTraceError = callingLocationStackTrace || new Error(); + agent.onDetectedAttack({ module: module, operation: operation, kind: "ssrf", source: found.source, blocked: agent.shouldBlock(), - stack: new Error().stack!, + stack: cleanupStackTrace(stackTraceError.stack!, libraryRoot), path: found.pathToPayload, metadata: getMetadataForSSRFAttack({ hostname, port }), request: context, From d96f19d014a13121088f304fbeeee7a2580313f7 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 20 Sep 2024 19:13:34 +0200 Subject: [PATCH 18/53] Fix args --- library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts b/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts index d02eb5393..f83ff32e6 100644 --- a/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts +++ b/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts @@ -47,8 +47,8 @@ export function inspectDNSLookupCalls( module, agent, operation, - stackTraceCallingLocation, - url + url, + stackTraceCallingLocation ), ] : [ @@ -59,8 +59,8 @@ export function inspectDNSLookupCalls( module, agent, operation, - stackTraceCallingLocation, - url + url, + stackTraceCallingLocation ), ]; From 4e77234ea2692c93141f5e00871c7a5fc3951043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Mon, 23 Sep 2024 11:36:23 +0200 Subject: [PATCH 19/53] Use node:path isAbsolute --- .../path-traversal/unsafePathStart.test.ts | 23 ------------------- .../path-traversal/unsafePathStart.ts | 8 ++----- 2 files changed, 2 insertions(+), 29 deletions(-) delete mode 100644 library/vulnerabilities/path-traversal/unsafePathStart.test.ts diff --git a/library/vulnerabilities/path-traversal/unsafePathStart.test.ts b/library/vulnerabilities/path-traversal/unsafePathStart.test.ts deleted file mode 100644 index 95bb896f0..000000000 --- a/library/vulnerabilities/path-traversal/unsafePathStart.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as t from "tap"; -import { isRelativePath } from "./unsafePathStart"; - -t.test("is relative path", async () => { - t.same(isRelativePath("../test"), true); - t.same(isRelativePath("test"), true); - t.same(isRelativePath("../../test"), true); - t.same(isRelativePath("test/folder"), true); - t.same(isRelativePath("./test"), true); -}); - -t.test("is not relative path", async () => { - t.same(isRelativePath("/test"), false); - t.same(isRelativePath("c:/test"), false); - t.same(isRelativePath("c:\\test"), false); - t.same(isRelativePath("C:/test/folder"), false); - t.same(isRelativePath("c:\\test\\folder"), false); - t.same(isRelativePath("D:\\test"), false); - t.same(isRelativePath("/./test"), false); - t.same(isRelativePath("/../test"), false); - t.same(isRelativePath("/test/folder"), false); - t.same(isRelativePath("//test"), false); -}); diff --git a/library/vulnerabilities/path-traversal/unsafePathStart.ts b/library/vulnerabilities/path-traversal/unsafePathStart.ts index 439a70c99..539302ba2 100644 --- a/library/vulnerabilities/path-traversal/unsafePathStart.ts +++ b/library/vulnerabilities/path-traversal/unsafePathStart.ts @@ -1,4 +1,4 @@ -import { resolve } from "path"; +import { isAbsolute, resolve } from "path"; const linuxRootFolders = [ "/bin/", @@ -26,7 +26,7 @@ const dangerousPathStarts = [...linuxRootFolders, "c:/", "c:\\"]; export function startsWithUnsafePath(filePath: string, userInput: string) { // Check if path is relative (not absolute or drive letter path) // Required because resolve will build absolute paths from relative paths - if (isRelativePath(filePath)) { + if (!isAbsolute(filePath)) { return false; } @@ -48,7 +48,3 @@ export function startsWithUnsafePath(filePath: string, userInput: string) { } return false; } - -export function isRelativePath(filePath: string) { - return !/^(\/|\w:).*/.test(filePath); -} From 3d347b4da7d6951a36d1b702f88fc366010a5b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Mon, 23 Sep 2024 11:37:28 +0200 Subject: [PATCH 20/53] Remove test only working on windows --- .../path-traversal/detectPathTraversal.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts b/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts index a96317d5a..3f604ca75 100644 --- a/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts +++ b/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts @@ -80,10 +80,6 @@ t.test("linux user directory", async () => { t.same(detectPathTraversal("/home/user/file.txt", "/home/user/"), true); }); -t.test("windows drive letter", async () => { - t.same(detectPathTraversal("C:\\file.txt", "C:\\"), true); -}); - t.test("possible bypass", async () => { t.same(detectPathTraversal("/./etc/passwd", "/./etc/passwd"), true); }); From 0aadabd6a3fc3801ad6f8457f5c787f75bb2bd82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Mon, 23 Sep 2024 11:39:54 +0200 Subject: [PATCH 21/53] Fix types after merge --- library/vulnerabilities/path-traversal/unsafePathStart.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/library/vulnerabilities/path-traversal/unsafePathStart.ts b/library/vulnerabilities/path-traversal/unsafePathStart.ts index 539302ba2..55fda7e45 100644 --- a/library/vulnerabilities/path-traversal/unsafePathStart.ts +++ b/library/vulnerabilities/path-traversal/unsafePathStart.ts @@ -31,6 +31,7 @@ export function startsWithUnsafePath(filePath: string, userInput: string) { } let origResolve = resolve; + // @ts-expect-error __wrapped is not typed if (resolve.__wrapped) { // @ts-expect-error Not type safe origResolve = resolve.__original; From d5cd28ace17ac7a3ef12aca8c62016007e679e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Mon, 23 Sep 2024 12:17:38 +0200 Subject: [PATCH 22/53] Add isWrapped and CI debug logs --- library/helpers/wrap.test.ts | 15 ++++++++++++++- library/helpers/wrap.ts | 13 +++++++++++++ .../path-traversal/detectPathTraversal.ts | 11 ++++++++++- .../path-traversal/unsafePathStart.ts | 4 ++-- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/library/helpers/wrap.test.ts b/library/helpers/wrap.test.ts index 468d43673..abe18c6fb 100644 --- a/library/helpers/wrap.test.ts +++ b/library/helpers/wrap.test.ts @@ -1,5 +1,5 @@ import * as t from "tap"; -import { wrap } from "./wrap"; +import { isWrapped, wrap } from "./wrap"; class MyClass { abc = "abc"; @@ -42,3 +42,16 @@ t.test("it wraps a method", async (t) => { const myClass = new MyClass(); t.same(myClass.aMethod(), "wrapped"); }); + +t.test("isWrapped returns true for wrapped function", async (t) => { + const myClass = new MyClass(); + t.same(isWrapped(myClass.aMethod), true); +}); + +t.test("it returns false for unwrapped function or property", async (t) => { + const myClass = new MyClass(); + t.same(isWrapped(myClass.abc), false); + + const test = () => "test"; + t.same(isWrapped(test), false); +}); diff --git a/library/helpers/wrap.ts b/library/helpers/wrap.ts index 2b4bd823e..f3c1d6b11 100644 --- a/library/helpers/wrap.ts +++ b/library/helpers/wrap.ts @@ -47,3 +47,16 @@ function defineProperty(obj: unknown, name: string, value: unknown) { value: value, }); } + +/** + * Checks if a function is a wrapped function. + */ +export function isWrapped(fn: any) { + return ( + typeof fn === "function" && + "__wrapped" in fn && + fn.__wrapped === true && + "__original" in fn && + typeof fn.__original === "function" + ); +} diff --git a/library/vulnerabilities/path-traversal/detectPathTraversal.ts b/library/vulnerabilities/path-traversal/detectPathTraversal.ts index 1a15934b9..e5da0375c 100644 --- a/library/vulnerabilities/path-traversal/detectPathTraversal.ts +++ b/library/vulnerabilities/path-traversal/detectPathTraversal.ts @@ -37,12 +37,21 @@ export function detectPathTraversal( } if (containsUnsafePathParts(filePath) && containsUnsafePathParts(userInput)) { + console.log("relative"); + console.log("filePath", filePath); + console.log("userInput", userInput); return true; } if (checkPathStart) { // Check for absolute path traversal - return startsWithUnsafePath(filePath, userInput); + const res = startsWithUnsafePath(filePath, userInput); + if (res) { + console.log("absolute"); + console.log("filePath", filePath); + console.log("userInput", userInput); + } + return res; } return false; diff --git a/library/vulnerabilities/path-traversal/unsafePathStart.ts b/library/vulnerabilities/path-traversal/unsafePathStart.ts index 55fda7e45..27c94e6aa 100644 --- a/library/vulnerabilities/path-traversal/unsafePathStart.ts +++ b/library/vulnerabilities/path-traversal/unsafePathStart.ts @@ -1,4 +1,5 @@ import { isAbsolute, resolve } from "path"; +import { isWrapped } from "../../helpers/wrap"; const linuxRootFolders = [ "/bin/", @@ -31,8 +32,7 @@ export function startsWithUnsafePath(filePath: string, userInput: string) { } let origResolve = resolve; - // @ts-expect-error __wrapped is not typed - if (resolve.__wrapped) { + if (isWrapped(resolve)) { // @ts-expect-error Not type safe origResolve = resolve.__original; } From 60f5c59886d9a4fa275217e98c151a9a1c38706c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Mon, 23 Sep 2024 12:38:13 +0200 Subject: [PATCH 23/53] Debug test failing only in CI --- end2end/tests/nestjs-sentry.test.js | 4 ++++ end2end/tests/nextjs-standalone.test.js | 4 ++++ library/vulnerabilities/path-traversal/detectPathTraversal.ts | 3 +++ 3 files changed, 11 insertions(+) diff --git a/end2end/tests/nestjs-sentry.test.js b/end2end/tests/nestjs-sentry.test.js index 1db24e1b2..1090c86c8 100644 --- a/end2end/tests/nestjs-sentry.test.js +++ b/end2end/tests/nestjs-sentry.test.js @@ -36,11 +36,13 @@ t.test("it blocks in blocking mode", (t) => { let stdout = ""; server.stdout.on("data", (data) => { + console.log(data.toString()); stdout += data.toString(); }); let stderr = ""; server.stderr.on("data", (data) => { + console.log(data.toString()); stderr += data.toString(); }); @@ -100,11 +102,13 @@ t.test("it does not block in non-blocking mode", (t) => { let stdout = ""; server.stdout.on("data", (data) => { + console.log(data.toString()); stdout += data.toString(); }); let stderr = ""; server.stderr.on("data", (data) => { + console.log(data.toString()); stderr += data.toString(); }); diff --git a/end2end/tests/nextjs-standalone.test.js b/end2end/tests/nextjs-standalone.test.js index edbcfdfba..991868bd6 100644 --- a/end2end/tests/nextjs-standalone.test.js +++ b/end2end/tests/nextjs-standalone.test.js @@ -61,11 +61,13 @@ t.test("it blocks in blocking mode", (t) => { let stdout = ""; server.stdout.on("data", (data) => { + console.log(data.toString()); stdout += data.toString(); }); let stderr = ""; server.stderr.on("data", (data) => { + console.log(data.toString()); stderr += data.toString(); }); @@ -142,11 +144,13 @@ t.test("it does not block in dry mode", (t) => { let stdout = ""; server.stdout.on("data", (data) => { + console.log(data.toString()); stdout += data.toString(); }); let stderr = ""; server.stderr.on("data", (data) => { + console.log(data.toString()); stderr += data.toString(); }); diff --git a/library/vulnerabilities/path-traversal/detectPathTraversal.ts b/library/vulnerabilities/path-traversal/detectPathTraversal.ts index e5da0375c..9af3034a2 100644 --- a/library/vulnerabilities/path-traversal/detectPathTraversal.ts +++ b/library/vulnerabilities/path-traversal/detectPathTraversal.ts @@ -21,6 +21,9 @@ export function detectPathTraversal( if (isUrl && containsUnsafePathParts(userInput)) { const filePathFromUrl = parseAsFileUrl(userInput); if (filePathFromUrl && filePath.includes(filePathFromUrl)) { + console.log("url"); + console.log("filePath", filePath); + console.log("userInput", userInput); return true; } } From 207a31734582c53339dae5bd8aa944e0a7773cab Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 23 Sep 2024 13:18:45 +0200 Subject: [PATCH 24/53] Simplify --- library/sinks/HTTPRequest.ts | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/library/sinks/HTTPRequest.ts b/library/sinks/HTTPRequest.ts index f842876d2..06e75c977 100644 --- a/library/sinks/HTTPRequest.ts +++ b/library/sinks/HTTPRequest.ts @@ -125,26 +125,20 @@ export class HTTPRequest implements Wrapper { return args.concat(newOpts); } - if (optionObj.lookup) { - optionObj.lookup = inspectDNSLookupCalls( - optionObj.lookup, - agent, - module, - `${module}.request`, - url, - stackTraceCallingLocation - ) as RequestOptions["lookup"]; - } else { - optionObj.lookup = inspectDNSLookupCalls( - lookup, - agent, - module, - `${module}.request`, - url, - stackTraceCallingLocation - ) as RequestOptions["lookup"]; + let nativeLookup: Function = lookup; + if ("lookup" in optionObj && typeof optionObj.lookup === "function") { + nativeLookup = optionObj.lookup; } + optionObj.lookup = inspectDNSLookupCalls( + nativeLookup, + agent, + module, + `${module}.request`, + url, + stackTraceCallingLocation + ) as RequestOptions["lookup"]; + return args; } From 73995503dcbcb4e5c53803b07dddee671a3a7bba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Mon, 23 Sep 2024 13:52:06 +0200 Subject: [PATCH 25/53] Try fixing false positive --- library/helpers/wrap.ts | 12 ++++++++---- .../path-traversal/detectPathTraversal.test.ts | 2 ++ .../path-traversal/unsafePathStart.ts | 3 +-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/library/helpers/wrap.ts b/library/helpers/wrap.ts index f3c1d6b11..133068859 100644 --- a/library/helpers/wrap.ts +++ b/library/helpers/wrap.ts @@ -1,3 +1,7 @@ +type WrappedFunction = T & { + __original: T; +}; + export function wrap( nodule: any, name: string, @@ -49,14 +53,14 @@ function defineProperty(obj: unknown, name: string, value: unknown) { } /** - * Checks if a function is a wrapped function. + * Check if a function is wrapped */ -export function isWrapped(fn: any) { +export function isWrapped(fn: T): fn is WrappedFunction { return ( - typeof fn === "function" && + fn instanceof Function && "__wrapped" in fn && fn.__wrapped === true && "__original" in fn && - typeof fn.__original === "function" + fn.__original instanceof Function ); } diff --git a/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts b/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts index 3f604ca75..af44a2c6d 100644 --- a/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts +++ b/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts @@ -1,5 +1,6 @@ import * as t from "tap"; import { detectPathTraversal } from "./detectPathTraversal"; +import { join, resolve } from "path"; t.test("empty user input", async () => { t.same(detectPathTraversal("test.txt", ""), false); @@ -89,6 +90,7 @@ t.test("another bypass", async () => { detectPathTraversal("/./././root/test.txt", "/./././root/test.txt"), true ); + t.same(detectPathTraversal("/./././root/test.txt", "/./././root"), true); }); t.test("no path traversal", async () => { diff --git a/library/vulnerabilities/path-traversal/unsafePathStart.ts b/library/vulnerabilities/path-traversal/unsafePathStart.ts index 27c94e6aa..da0ac68a6 100644 --- a/library/vulnerabilities/path-traversal/unsafePathStart.ts +++ b/library/vulnerabilities/path-traversal/unsafePathStart.ts @@ -27,13 +27,12 @@ const dangerousPathStarts = [...linuxRootFolders, "c:/", "c:\\"]; export function startsWithUnsafePath(filePath: string, userInput: string) { // Check if path is relative (not absolute or drive letter path) // Required because resolve will build absolute paths from relative paths - if (!isAbsolute(filePath)) { + if (!isAbsolute(filePath) || !isAbsolute(userInput)) { return false; } let origResolve = resolve; if (isWrapped(resolve)) { - // @ts-expect-error Not type safe origResolve = resolve.__original; } From b162d69c1a5e2aee878ab7c7d127ef1530cd8ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Mon, 23 Sep 2024 14:03:54 +0200 Subject: [PATCH 26/53] Remove debug log lines --- end2end/tests/nestjs-sentry.test.js | 4 ---- end2end/tests/nextjs-standalone.test.js | 4 ---- .../path-traversal/detectPathTraversal.ts | 14 +------------- 3 files changed, 1 insertion(+), 21 deletions(-) diff --git a/end2end/tests/nestjs-sentry.test.js b/end2end/tests/nestjs-sentry.test.js index 1090c86c8..1db24e1b2 100644 --- a/end2end/tests/nestjs-sentry.test.js +++ b/end2end/tests/nestjs-sentry.test.js @@ -36,13 +36,11 @@ t.test("it blocks in blocking mode", (t) => { let stdout = ""; server.stdout.on("data", (data) => { - console.log(data.toString()); stdout += data.toString(); }); let stderr = ""; server.stderr.on("data", (data) => { - console.log(data.toString()); stderr += data.toString(); }); @@ -102,13 +100,11 @@ t.test("it does not block in non-blocking mode", (t) => { let stdout = ""; server.stdout.on("data", (data) => { - console.log(data.toString()); stdout += data.toString(); }); let stderr = ""; server.stderr.on("data", (data) => { - console.log(data.toString()); stderr += data.toString(); }); diff --git a/end2end/tests/nextjs-standalone.test.js b/end2end/tests/nextjs-standalone.test.js index 991868bd6..edbcfdfba 100644 --- a/end2end/tests/nextjs-standalone.test.js +++ b/end2end/tests/nextjs-standalone.test.js @@ -61,13 +61,11 @@ t.test("it blocks in blocking mode", (t) => { let stdout = ""; server.stdout.on("data", (data) => { - console.log(data.toString()); stdout += data.toString(); }); let stderr = ""; server.stderr.on("data", (data) => { - console.log(data.toString()); stderr += data.toString(); }); @@ -144,13 +142,11 @@ t.test("it does not block in dry mode", (t) => { let stdout = ""; server.stdout.on("data", (data) => { - console.log(data.toString()); stdout += data.toString(); }); let stderr = ""; server.stderr.on("data", (data) => { - console.log(data.toString()); stderr += data.toString(); }); diff --git a/library/vulnerabilities/path-traversal/detectPathTraversal.ts b/library/vulnerabilities/path-traversal/detectPathTraversal.ts index 9af3034a2..1a15934b9 100644 --- a/library/vulnerabilities/path-traversal/detectPathTraversal.ts +++ b/library/vulnerabilities/path-traversal/detectPathTraversal.ts @@ -21,9 +21,6 @@ export function detectPathTraversal( if (isUrl && containsUnsafePathParts(userInput)) { const filePathFromUrl = parseAsFileUrl(userInput); if (filePathFromUrl && filePath.includes(filePathFromUrl)) { - console.log("url"); - console.log("filePath", filePath); - console.log("userInput", userInput); return true; } } @@ -40,21 +37,12 @@ export function detectPathTraversal( } if (containsUnsafePathParts(filePath) && containsUnsafePathParts(userInput)) { - console.log("relative"); - console.log("filePath", filePath); - console.log("userInput", userInput); return true; } if (checkPathStart) { // Check for absolute path traversal - const res = startsWithUnsafePath(filePath, userInput); - if (res) { - console.log("absolute"); - console.log("filePath", filePath); - console.log("userInput", userInput); - } - return res; + return startsWithUnsafePath(filePath, userInput); } return false; From 9f25913e82a67de9e7a2603f04b0233e2232f5fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Mon, 23 Sep 2024 14:22:24 +0200 Subject: [PATCH 27/53] Add windows only test again --- .../path-traversal/detectPathTraversal.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts b/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts index af44a2c6d..1fe0acfff 100644 --- a/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts +++ b/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts @@ -122,3 +122,11 @@ t.test("does not absolute path inside another folder", async () => { t.test("disable checkPathStart", async () => { t.same(detectPathTraversal("/etc/passwd", "/etc/passwd", false), false); }); + +t.test( + "windows drive letter", + { skip: process.platform !== "win32" ? "Windows only" : false }, + async () => { + t.same(detectPathTraversal("C:\\file.txt", "C:\\"), true); + } +); From 366f7e8dd164a66f531c58ab36ab1f4d1d2e3cc0 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 23 Sep 2024 17:27:20 +0200 Subject: [PATCH 28/53] Add test for DNS lookup stack trace --- .github/workflows/end-to-end-tests.yml | 1 + end2end/tests/express-mongodb.ssrf.test.js | 157 +++++ sample-apps/express-mongodb/app.js | 24 + sample-apps/express-mongodb/fetchImage.js | 34 + server/Dockerfile | 11 + server/app.js | 22 + server/package-lock.json | 707 +++++++++++++++++++++ server/package.json | 9 + server/src/handlers/captureEvent.js | 18 + server/src/handlers/createApp.js | 9 + server/src/handlers/getConfig.js | 8 + server/src/handlers/listEvents.js | 11 + server/src/middleware/checkToken.js | 23 + server/src/zen/apps.js | 47 ++ server/src/zen/config.js | 16 + server/src/zen/events.js | 23 + 16 files changed, 1120 insertions(+) create mode 100644 end2end/tests/express-mongodb.ssrf.test.js create mode 100644 sample-apps/express-mongodb/fetchImage.js create mode 100644 server/Dockerfile create mode 100644 server/app.js create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/src/handlers/captureEvent.js create mode 100644 server/src/handlers/createApp.js create mode 100644 server/src/handlers/getConfig.js create mode 100644 server/src/handlers/listEvents.js create mode 100644 server/src/middleware/checkToken.js create mode 100644 server/src/zen/apps.js create mode 100644 server/src/zen/config.js create mode 100644 server/src/zen/events.js diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 9de58e898..8d5eaf720 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -39,6 +39,7 @@ jobs: uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} + - run: cd server && docker build -t server . && docker run -d -p 5874:3000 - run: make install - run: make build - run: make end2end diff --git a/end2end/tests/express-mongodb.ssrf.test.js b/end2end/tests/express-mongodb.ssrf.test.js new file mode 100644 index 000000000..d0a535696 --- /dev/null +++ b/end2end/tests/express-mongodb.ssrf.test.js @@ -0,0 +1,157 @@ +const t = require("tap"); +const { spawn } = require("child_process"); +const { resolve } = require("path"); +const timeout = require("../timeout"); + +const pathToApp = resolve( + __dirname, + "../../sample-apps/express-mongodb", + "app.js" +); + +const testServerUrl = "http://localhost:5874"; +const safeImage = "https://nodejs.org/static/images/favicons/favicon.png"; +const unsafeImage = "http://local.aikido.io/favicon.ico"; + +t.setTimeout(60000); + +let token; + +t.beforeEach(async () => { + const response = await fetch(`${testServerUrl}/api/runtime/apps`, { + method: "POST", + }); + const body = await response.json(); + token = body.token; +}); + +t.test("it blocks in blocking mode", (t) => { + const server = spawn(`node`, ["--preserve-symlinks", pathToApp, "4000"], { + env: { + ...process.env, + AIKIDO_DEBUG: "true", + AIKIDO_BLOCKING: "true", + AIKIDO_TOKEN: token, + AIKIDO_URL: testServerUrl, + }, + }); + + server.on("close", () => { + t.end(); + }); + + server.on("error", (err) => { + t.fail(err.message); + }); + + let stdout = ""; + server.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + let stderr = ""; + server.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + // Wait for the server to start + timeout(2000) + .then(() => { + return Promise.all([ + fetch(`http://127.0.0.1:4000/images/${encodeURIComponent(safeImage)}`, { + signal: AbortSignal.timeout(5000), + }), + fetch( + `http://127.0.0.1:4000/images/${encodeURIComponent(unsafeImage)}`, + { + signal: AbortSignal.timeout(5000), + } + ), + ]); + }) + .then(([safeRequest, ssrfRequest]) => { + t.equal(safeRequest.status, 200); + t.equal(ssrfRequest.status, 500); + t.match(stdout, /Starting agent/); + t.match(stderr, /Zen has blocked a server-side request forgery/); + + return fetch(`${testServerUrl}/api/runtime/events`, { + method: "GET", + headers: { + Authorization: token, + }, + }); + }) + .then((response) => { + return response.json(); + }) + .then((events) => { + const attacks = events.filter( + (event) => event.type === "detected_attack" + ); + t.same(attacks.length, 1); + const [attack] = attacks; + t.match(attack.attack.stack, /app\.js/); + t.match(attack.attack.stack, /fetchImage\.js/); + t.match(attack.attack.stack, /express-async-handler/); + }) + .catch((error) => { + t.fail(error.message); + }) + .finally(() => { + server.kill(); + }); +}); + +t.test("it does not block in dry mode", (t) => { + const server = spawn(`node`, ["--preserve-symlinks", pathToApp, "4001"], { + env: { + ...process.env, + AIKIDO_DEBUG: "true", + AIKIDO_TOKEN: token, + AIKIDO_URL: testServerUrl, + }, + }); + + server.on("close", () => { + t.end(); + }); + + let stdout = ""; + server.stdout.on("data", (data) => { + stdout += data.toString(); + }); + + let stderr = ""; + server.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + // Wait for the server to start + timeout(2000) + .then(() => + Promise.all([ + fetch(`http://127.0.0.1:4001/images/${encodeURIComponent(safeImage)}`, { + signal: AbortSignal.timeout(5000), + }), + fetch( + `http://127.0.0.1:4001/images/${encodeURIComponent(unsafeImage)}`, + { + signal: AbortSignal.timeout(5000), + } + ), + ]) + ) + .then(([safeRequest, ssrfRequest]) => { + t.equal(safeRequest.status, 200); + t.equal(ssrfRequest.status, 200); + t.match(stdout, /Starting agent/); + t.notMatch(stderr, /Zen has blocked a server-side request forgery/); + }) + .catch((error) => { + t.fail(error.message); + }) + .finally(() => { + server.kill(); + }); +}); diff --git a/sample-apps/express-mongodb/app.js b/sample-apps/express-mongodb/app.js index 6e0fd8f7f..051712984 100644 --- a/sample-apps/express-mongodb/app.js +++ b/sample-apps/express-mongodb/app.js @@ -9,6 +9,8 @@ const { escape } = require("./escape"); const morgan = require("morgan"); const cookieParser = require("cookie-parser"); const { exec } = require("child_process"); +const { extname } = require("path"); +const fetchImage = require("./fetchImage"); require("@aikidosec/firewall/nopp"); @@ -146,6 +148,28 @@ async function main(port) { }) ); + app.get( + "/images/:url", + asyncHandler(async (req, res) => { + // This code is vulnerable to SSRF + const url = req.params.url; + + if (!url) { + return res.status(400).send("url parameter is required"); + } + + const extension = extname(url) || ".jpg"; + const { statusCode, body } = await fetchImage(url); + + if (statusCode !== 200) { + return res.status(statusCode).send("Failed to fetch image"); + } + + res.attachment(`image${extension}`); + res.send(body); + }) + ); + return new Promise((resolve, reject) => { try { app.listen(port, () => { diff --git a/sample-apps/express-mongodb/fetchImage.js b/sample-apps/express-mongodb/fetchImage.js new file mode 100644 index 000000000..383dbfbd0 --- /dev/null +++ b/sample-apps/express-mongodb/fetchImage.js @@ -0,0 +1,34 @@ +const https = require("https"); +const http = require("http"); + +async function fetchImage(url) { + return new Promise((resolve, reject) => { + const protocol = url.startsWith("https") ? https : http; + + protocol + .get(url, (response) => { + let data = []; + + response.on("data", (chunk) => { + data.push(chunk); + }); + + response.on("end", () => { + const buffer = Buffer.concat(data); + resolve({ + statusCode: response.statusCode, + body: buffer, + }); + }); + + response.on("error", (err) => { + reject(err); + }); + }) + .on("error", (err) => { + reject(err); + }); + }); +} + +module.exports = fetchImage; diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 000000000..865b93032 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22 + +WORKDIR /app + +COPY package.json /app + +RUN npm install + +COPY . /app + +CMD ["node" , "/app/app.js"] diff --git a/server/app.js b/server/app.js new file mode 100644 index 000000000..e5d438ae8 --- /dev/null +++ b/server/app.js @@ -0,0 +1,22 @@ +const express = require("express"); +const config = require("./src/handlers/getConfig"); +const captureEvent = require("./src/handlers/captureEvent"); +const listEvents = require("./src/handlers/listEvents"); +const createApp = require("./src/handlers/createApp"); +const checkToken = require("./src/middleware/checkToken"); + +const app = express(); + +const port = process.env.PORT || 3000; + +app.use(express.json()); + +app.get("/api/runtime/config", checkToken, config); +app.post("/api/runtime/events", checkToken, captureEvent); + +app.get("/api/runtime/events", checkToken, listEvents); +app.post("/api/runtime/apps", createApp); + +app.listen(port, () => { + console.log(`Server is running on port ${port}`); +}); diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 000000000..06ac91129 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,707 @@ +{ + "name": "server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "server", + "version": "1.0.0", + "dependencies": { + "express": "^4.21.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", + "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.10", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 000000000..3d1bab564 --- /dev/null +++ b/server/package.json @@ -0,0 +1,9 @@ +{ + "name": "server", + "version": "1.0.0", + "main": "index.js", + "private": true, + "dependencies": { + "express": "^4.21.0" + } +} diff --git a/server/src/handlers/captureEvent.js b/server/src/handlers/captureEvent.js new file mode 100644 index 000000000..f44312f2b --- /dev/null +++ b/server/src/handlers/captureEvent.js @@ -0,0 +1,18 @@ +const { generateConfig } = require("../zen/config"); +const { captureEvent: capture } = require("../zen/events"); + +module.exports = function captureEvent(req, res) { + if (!req.app) { + throw new Error("App is missing"); + } + + capture(req.body, req.app); + + if (req.body.type === "detected_attack") { + return res.json({ + success: true, + }); + } + + return res.json(generateConfig(req.app)); +}; diff --git a/server/src/handlers/createApp.js b/server/src/handlers/createApp.js new file mode 100644 index 000000000..21a11b503 --- /dev/null +++ b/server/src/handlers/createApp.js @@ -0,0 +1,9 @@ +const { createApp: create } = require("../zen/apps"); + +module.exports = function createApp(req, res) { + const token = create(); + + res.json({ + token: token, + }); +}; diff --git a/server/src/handlers/getConfig.js b/server/src/handlers/getConfig.js new file mode 100644 index 000000000..f6fae0a87 --- /dev/null +++ b/server/src/handlers/getConfig.js @@ -0,0 +1,8 @@ +const { generateConfig } = require("../zen/config"); +module.exports = function getConfig(req, res) { + if (!req.app) { + throw new Error("App is missing"); + } + + res.json(generateConfig(req.app)); +}; diff --git a/server/src/handlers/listEvents.js b/server/src/handlers/listEvents.js new file mode 100644 index 000000000..33f703e17 --- /dev/null +++ b/server/src/handlers/listEvents.js @@ -0,0 +1,11 @@ +const { listEvents: list } = require("../zen/events"); + +function listEvents(req, res) { + if (!req.app) { + throw new Error("App is missing"); + } + + res.json(list(req.app)); +} + +module.exports = listEvents; diff --git a/server/src/middleware/checkToken.js b/server/src/middleware/checkToken.js new file mode 100644 index 000000000..2e35d891f --- /dev/null +++ b/server/src/middleware/checkToken.js @@ -0,0 +1,23 @@ +const { getByToken } = require("../zen/apps"); + +module.exports = function checkToken(req, res, next) { + const token = req.headers["authorization"]; + + if (!token) { + return res.status(401).json({ + message: "Token is required", + }); + } + + const app = getByToken(token); + + if (!app) { + return res.status(401).json({ + message: "Invalid token", + }); + } + + req.app = app; + + next(); +}; diff --git a/server/src/zen/apps.js b/server/src/zen/apps.js new file mode 100644 index 000000000..a3a102277 --- /dev/null +++ b/server/src/zen/apps.js @@ -0,0 +1,47 @@ +const { randomInt, timingSafeEqual } = require("crypto"); + +const apps = []; + +let id = 1; +function createApp() { + const appId = id++; + const token = `AIK_RUNTIME_1_${appId}_${generateRandomString(48)}`; + const app = { + id: appId, + token: token, + configUpdatedAt: Date.now(), + }; + + apps.push(app); + + return token; +} + +function getByToken(token) { + return apps.find((app) => { + if (app.token.length !== token.length) { + return false; + } + + return timingSafeEqual(Buffer.from(app.token), Buffer.from(token)); + }); +} + +function generateRandomString(length) { + const chars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + const size = chars.length; + let str = ""; + + for (let i = 0; i < length; i++) { + const randomIndex = randomInt(0, size); + str += chars[randomIndex]; + } + + return str; +} + +module.exports = { + createApp, + getByToken, +}; diff --git a/server/src/zen/config.js b/server/src/zen/config.js new file mode 100644 index 000000000..bd2aa4e71 --- /dev/null +++ b/server/src/zen/config.js @@ -0,0 +1,16 @@ +function generateConfig(app) { + return { + success: true, + serviceId: app.id, + configUpdatedAt: app.configUpdatedAt, + heartbeatIntervalInMS: 10 * 60 * 1000, + endpoints: [], + blockedUserIds: [], + allowedIPAddresses: [], + receivedAnyStats: true, + }; +} + +module.exports = { + generateConfig, +}; diff --git a/server/src/zen/events.js b/server/src/zen/events.js new file mode 100644 index 000000000..c40f30a88 --- /dev/null +++ b/server/src/zen/events.js @@ -0,0 +1,23 @@ +const events = new Map(); + +function captureEvent(event, app) { + if (event.type === "heartbeat") { + // Ignore heartbeats + return; + } + + if (!events.has(app.id)) { + events.set(app.id, []); + } + + events.get(app.id).push(event); +} + +function listEvents(app) { + return events.get(app.id) || []; +} + +module.exports = { + captureEvent, + listEvents, +}; From 78a6380a200f7caa82eebf093beea15fad078c27 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 23 Sep 2024 17:27:49 +0200 Subject: [PATCH 29/53] Improve types --- library/sinks/HTTPRequest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/library/sinks/HTTPRequest.ts b/library/sinks/HTTPRequest.ts index 06e75c977..49847d196 100644 --- a/library/sinks/HTTPRequest.ts +++ b/library/sinks/HTTPRequest.ts @@ -125,7 +125,7 @@ export class HTTPRequest implements Wrapper { return args.concat(newOpts); } - let nativeLookup: Function = lookup; + let nativeLookup: NonNullable = lookup; if ("lookup" in optionObj && typeof optionObj.lookup === "function") { nativeLookup = optionObj.lookup; } @@ -137,7 +137,7 @@ export class HTTPRequest implements Wrapper { `${module}.request`, url, stackTraceCallingLocation - ) as RequestOptions["lookup"]; + ); return args; } From 1609c36a2f41a538ad4f65abd14cd31cd020280c Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 23 Sep 2024 17:29:42 +0200 Subject: [PATCH 30/53] Add container image name --- .github/workflows/end-to-end-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 8d5eaf720..f31361ee9 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -39,7 +39,7 @@ jobs: uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} - - run: cd server && docker build -t server . && docker run -d -p 5874:3000 + - run: cd server && docker build -t server . && docker run -d -p 5874:3000 server - run: make install - run: make build - run: make end2end From 143fd24f613dbf1d1eeacc88bd5b64f44bdd31b9 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 23 Sep 2024 17:32:28 +0200 Subject: [PATCH 31/53] Add missing type --- library/sinks/HTTPRequest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/sinks/HTTPRequest.ts b/library/sinks/HTTPRequest.ts index 49847d196..593d93dad 100644 --- a/library/sinks/HTTPRequest.ts +++ b/library/sinks/HTTPRequest.ts @@ -137,7 +137,7 @@ export class HTTPRequest implements Wrapper { `${module}.request`, url, stackTraceCallingLocation - ); + ) as NonNullable; return args; } From 5076e11723609e847ba1be8629670658182ada00 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 23 Sep 2024 17:49:48 +0200 Subject: [PATCH 32/53] Add local.aikido.io to hosts --- .github/workflows/end-to-end-tests.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index f31361ee9..ee5600651 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -39,7 +39,12 @@ jobs: uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} - - run: cd server && docker build -t server . && docker run -d -p 5874:3000 server + - name: Add local.aikido.io to /etc/hosts + run: | + sudo echo "127.0.0.1 local.aikido.io" | sudo tee -a /etc/hosts + - name: Build and run server + run: | + cd server && docker build -t server . && docker run -d -p 5874:3000 server - run: make install - run: make build - run: make end2end From 255a1c606c4bff934ebe05a260edf382887c046c Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 23 Sep 2024 18:24:04 +0200 Subject: [PATCH 33/53] Fix end2end test --- end2end/tests/express-mongodb.ssrf.test.js | 33 +++++++++++++++++++-- end2end/tests/fixtures/favicon.png | Bin 0 -> 1345 bytes 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 end2end/tests/fixtures/favicon.png diff --git a/end2end/tests/express-mongodb.ssrf.test.js b/end2end/tests/express-mongodb.ssrf.test.js index d0a535696..2255875b6 100644 --- a/end2end/tests/express-mongodb.ssrf.test.js +++ b/end2end/tests/express-mongodb.ssrf.test.js @@ -1,5 +1,7 @@ const t = require("tap"); const { spawn } = require("child_process"); +const { readFile } = require("fs/promises"); +const { createServer } = require("http"); const { resolve } = require("path"); const timeout = require("../timeout"); @@ -11,12 +13,35 @@ const pathToApp = resolve( const testServerUrl = "http://localhost:5874"; const safeImage = "https://nodejs.org/static/images/favicons/favicon.png"; -const unsafeImage = "http://local.aikido.io/favicon.ico"; +const unsafeImage = "http://local.aikido.io:5875/favicon.png"; t.setTimeout(60000); -let token; +let server; +t.before(async () => { + const contents = await readFile(resolve(__dirname, "./fixtures/favicon.png")); + + return new Promise((resolve) => { + server = createServer((req, res) => { + if (req.url === "/favicon.png") { + res.writeHead(200, { "Content-Type": "image/png" }); + res.write(contents); + res.end(); + } else { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end(); + } + }); + + server.listen(5875, () => { + resolve(); + }); + + server.unref(); + }); +}); +let token; t.beforeEach(async () => { const response = await fetch(`${testServerUrl}/api/runtime/apps`, { method: "POST", @@ -155,3 +180,7 @@ t.test("it does not block in dry mode", (t) => { server.kill(); }); }); + +t.after(async () => { + server.close(); +}); diff --git a/end2end/tests/fixtures/favicon.png b/end2end/tests/fixtures/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..974acbf15ec70e8a2614308d2289235a6144ee5d GIT binary patch literal 1345 zcmV-H1-|-;P)g~9Hm<9IW%t-`+wWd6ul=k{MW>-fWqMz=Su{2 z-{c60LLFL^phOg5w@WGQNy*<+*1_v5}-xW%wF@$?g}Sd5vdFeAB> za}cGFBPbE7pb&m;f*?BmwTCL{LgOGK5?!BCG@? z^G$!6n)>qh|N8sQKmX_7O|^(@n|ktYbzcuGm^6}|a2HA)Qip+nnWaUR*4FIOn|JjI zGu6V~!;^xpVKCQV&O|XY3PC9WQBD{S_RP*8sjRny073wbrz3B%u`%6;0tAyKpv#3) zf(kP+y|~C|G@>!#7!|688Ay)1u;Q%G^e_)5O)i<7gA$o8cN|z;z?1+(6_uct40o%9 zC(+LLC?jLibUD+N0%6Q7FOnl;W?&+R5g4jPN^6?$1!xmav30TDIFe{SGBMJRWhuSV0vYi#rc-S#UYY0NrGS$nZ`w9hF3D-2-SjA zkOC}_8}nEpik(bH<+Q8a*cfr(&f&zyMioEU?DP0d`~yAT|S%sy2a5ufdHXBDc2Hsz@J!yB(5I(wu!Hvs-z*@-)G ziRrl&)&}jxIj|Z0$<43lmLA=9omZ3+QK4EK)zRgOQJeqP<$PnreSwV=P6U_nkE!W# ze$iG9s{3wf`Zw9VW!pb0uCVh`x#B3*;%qLnW%oBOvu*blj5nP463s8KGQYHjVVomx z=%wD~9hTGtm9 Date: Mon, 23 Sep 2024 18:36:45 +0200 Subject: [PATCH 34/53] Move server --- .github/workflows/end-to-end-tests.yml | 2 +- {server => end2end/server}/Dockerfile | 0 {server => end2end/server}/app.js | 0 {server => end2end/server}/package-lock.json | 0 {server => end2end/server}/package.json | 0 {server => end2end/server}/src/handlers/captureEvent.js | 0 {server => end2end/server}/src/handlers/createApp.js | 0 {server => end2end/server}/src/handlers/getConfig.js | 0 {server => end2end/server}/src/handlers/listEvents.js | 0 {server => end2end/server}/src/middleware/checkToken.js | 0 {server => end2end/server}/src/zen/apps.js | 0 {server => end2end/server}/src/zen/config.js | 0 {server => end2end/server}/src/zen/events.js | 0 13 files changed, 1 insertion(+), 1 deletion(-) rename {server => end2end/server}/Dockerfile (100%) rename {server => end2end/server}/app.js (100%) rename {server => end2end/server}/package-lock.json (100%) rename {server => end2end/server}/package.json (100%) rename {server => end2end/server}/src/handlers/captureEvent.js (100%) rename {server => end2end/server}/src/handlers/createApp.js (100%) rename {server => end2end/server}/src/handlers/getConfig.js (100%) rename {server => end2end/server}/src/handlers/listEvents.js (100%) rename {server => end2end/server}/src/middleware/checkToken.js (100%) rename {server => end2end/server}/src/zen/apps.js (100%) rename {server => end2end/server}/src/zen/config.js (100%) rename {server => end2end/server}/src/zen/events.js (100%) diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index ee5600651..77a296461 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -44,7 +44,7 @@ jobs: sudo echo "127.0.0.1 local.aikido.io" | sudo tee -a /etc/hosts - name: Build and run server run: | - cd server && docker build -t server . && docker run -d -p 5874:3000 server + cd end2end/server && docker build -t server . && docker run -d -p 5874:3000 server - run: make install - run: make build - run: make end2end diff --git a/server/Dockerfile b/end2end/server/Dockerfile similarity index 100% rename from server/Dockerfile rename to end2end/server/Dockerfile diff --git a/server/app.js b/end2end/server/app.js similarity index 100% rename from server/app.js rename to end2end/server/app.js diff --git a/server/package-lock.json b/end2end/server/package-lock.json similarity index 100% rename from server/package-lock.json rename to end2end/server/package-lock.json diff --git a/server/package.json b/end2end/server/package.json similarity index 100% rename from server/package.json rename to end2end/server/package.json diff --git a/server/src/handlers/captureEvent.js b/end2end/server/src/handlers/captureEvent.js similarity index 100% rename from server/src/handlers/captureEvent.js rename to end2end/server/src/handlers/captureEvent.js diff --git a/server/src/handlers/createApp.js b/end2end/server/src/handlers/createApp.js similarity index 100% rename from server/src/handlers/createApp.js rename to end2end/server/src/handlers/createApp.js diff --git a/server/src/handlers/getConfig.js b/end2end/server/src/handlers/getConfig.js similarity index 100% rename from server/src/handlers/getConfig.js rename to end2end/server/src/handlers/getConfig.js diff --git a/server/src/handlers/listEvents.js b/end2end/server/src/handlers/listEvents.js similarity index 100% rename from server/src/handlers/listEvents.js rename to end2end/server/src/handlers/listEvents.js diff --git a/server/src/middleware/checkToken.js b/end2end/server/src/middleware/checkToken.js similarity index 100% rename from server/src/middleware/checkToken.js rename to end2end/server/src/middleware/checkToken.js diff --git a/server/src/zen/apps.js b/end2end/server/src/zen/apps.js similarity index 100% rename from server/src/zen/apps.js rename to end2end/server/src/zen/apps.js diff --git a/server/src/zen/config.js b/end2end/server/src/zen/config.js similarity index 100% rename from server/src/zen/config.js rename to end2end/server/src/zen/config.js diff --git a/server/src/zen/events.js b/end2end/server/src/zen/events.js similarity index 100% rename from server/src/zen/events.js rename to end2end/server/src/zen/events.js From 027933ad0fea207a5234f79bd1e561ffc619fa91 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 23 Sep 2024 18:40:23 +0200 Subject: [PATCH 35/53] Remove lock file, not used (Docker container) --- end2end/server/package-lock.json | 707 ------------------------------- 1 file changed, 707 deletions(-) delete mode 100644 end2end/server/package-lock.json diff --git a/end2end/server/package-lock.json b/end2end/server/package-lock.json deleted file mode 100644 index 06ac91129..000000000 --- a/end2end/server/package-lock.json +++ /dev/null @@ -1,707 +0,0 @@ -{ - "name": "server", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "server", - "version": "1.0.0", - "dependencies": { - "express": "^4.21.0" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, - "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", - "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", - "dependencies": { - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" - } - } - } -} From 03eb78b3db8c24cc1dfe0b5957864cc2b5a9d420 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Mon, 23 Sep 2024 18:45:47 +0200 Subject: [PATCH 36/53] Add comment --- library/sinks/HTTPRequest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/library/sinks/HTTPRequest.ts b/library/sinks/HTTPRequest.ts index 593d93dad..a428c63a5 100644 --- a/library/sinks/HTTPRequest.ts +++ b/library/sinks/HTTPRequest.ts @@ -127,6 +127,7 @@ export class HTTPRequest implements Wrapper { let nativeLookup: NonNullable = lookup; if ("lookup" in optionObj && typeof optionObj.lookup === "function") { + // If the user has passed a custom lookup function, we'll use that instead nativeLookup = optionObj.lookup; } From 6b88caae4b675ba570eacdbab95d773599fcc7c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Tue, 24 Sep 2024 13:10:02 +0200 Subject: [PATCH 37/53] Fix NoSQL injection bypass --- .../detectNoSQLInjection.test.ts | 40 +++++++++++++++++++ .../nosql-injection/detectNoSQLInjection.ts | 9 +++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts b/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts index 807bd4e29..c5f71bba1 100644 --- a/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts +++ b/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts @@ -731,3 +731,43 @@ t.test("it ignores safe pipeline aggregations", async () => { } ); }); + +t.test("detects root injection", async () => { + t.same( + detectNoSQLInjection( + createContext({ + body: { + username: "admin", + $where: "test", + }, + }), + { username: "admin", $where: "test" } + ), + { + injection: true, + source: "body", + pathToPayload: ".", + payload: { $where: "test" }, + } + ); +}); + +t.test("detects injection", async () => { + t.same( + detectNoSQLInjection( + createContext({ + body: { + username: "admin", + test: { $ne: "", hello: "world" }, + }, + }), + { username: "admin", test: { $ne: "", hello: "world" } } + ), + { + injection: true, + source: "body", + pathToPayload: ".test", + payload: { $ne: "" }, + } + ); +}); diff --git a/library/vulnerabilities/nosql-injection/detectNoSQLInjection.ts b/library/vulnerabilities/nosql-injection/detectNoSQLInjection.ts index f6cbcad35..cd44f0768 100644 --- a/library/vulnerabilities/nosql-injection/detectNoSQLInjection.ts +++ b/library/vulnerabilities/nosql-injection/detectNoSQLInjection.ts @@ -21,11 +21,12 @@ function matchFilterPartInUser( } } - if (isDeepStrictEqual(userInput, filterPart)) { - return { match: true, pathToPayload: buildPathToPayload(pathToPayload) }; - } - if (isPlainObject(userInput)) { + const filteredInput = removeKeysThatDontStartWithDollarSign(userInput); + if (isDeepStrictEqual(filteredInput, filterPart)) { + return { match: true, pathToPayload: buildPathToPayload(pathToPayload) }; + } + for (const key in userInput) { const match = matchFilterPartInUser( userInput[key], From 5ffb3c809b5730698cb0a674a2819b22cd21d82e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Tue, 24 Sep 2024 14:08:01 +0200 Subject: [PATCH 38/53] Add test (no injection) --- .../detectNoSQLInjection.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts b/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts index c5f71bba1..377b8b32f 100644 --- a/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts +++ b/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts @@ -771,3 +771,20 @@ t.test("detects injection", async () => { } ); }); + +t.test("it does not detect", async () => { + t.same( + detectNoSQLInjection( + createContext({ + body: { + username: "admin", + password: "test", + }, + }), + { username: "admin", password: "test" } + ), + { + injection: false, + } + ); +}); From bdf958b824da5bda7f04c50b541474a5d813c5d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Tue, 24 Sep 2024 14:53:13 +0200 Subject: [PATCH 39/53] Check filter for NoSQL of mongodb distinct --- library/sinks/MongoDB.test.ts | 13 +++++++++++++ library/sinks/MongoDB.ts | 25 ++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/library/sinks/MongoDB.test.ts b/library/sinks/MongoDB.test.ts index 77b55ab78..8f0d849df 100644 --- a/library/sinks/MongoDB.test.ts +++ b/library/sinks/MongoDB.test.ts @@ -185,6 +185,19 @@ t.test("it inspects method calls and blocks if needed", async (t) => { ); } + const distinctError = await t.rejects(async () => { + await runWithContext(unsafeContext, () => { + return collection.distinct("title", { title: { $ne: null } }); + }); + }); + t.ok(distinctError instanceof Error); + if (distinctError instanceof Error) { + t.same( + distinctError.message, + "Zen has blocked a NoSQL injection: MongoDB.Collection.distinct(...) originating from body.myTitle" + ); + } + // Test if it checks arguments await runWithContext(safeContext, async () => { await t.rejects(async () => collection.bulkWrite()); diff --git a/library/sinks/MongoDB.ts b/library/sinks/MongoDB.ts index 4d3f96e9e..d2b2f60dd 100644 --- a/library/sinks/MongoDB.ts +++ b/library/sinks/MongoDB.ts @@ -22,6 +22,8 @@ const OPERATIONS_WITH_FILTER = [ "replaceOne", ] as const; +const OPERATIONS_WITH_FILTER_SECOND_ARGUMENT = ["distinct"] as const; + const BULK_WRITE_OPERATIONS_WITH_FILTER = [ "replaceOne", "updateOne", @@ -137,7 +139,8 @@ export class MongoDB implements Wrapper { private inspectOperation( operation: string, args: unknown[], - collection: Collection + collection: Collection, + filterPosition = 0 ): InterceptorResult { const context = getContext(); @@ -145,9 +148,19 @@ export class MongoDB implements Wrapper { return undefined; } - if (args.length > 0 && isPlainObject(args[0])) { - const filter = args[0]; + let filter: unknown; + + if (filterPosition === 0 && args.length > 0 && isPlainObject(args[0])) { + filter = args[0]; + } else if ( + filterPosition === 1 && + args.length > 1 && + isPlainObject(args[1]) + ) { + filter = args[1]; + } + if (filter) { return this.inspectFilter( collection.dbName, collection.collectionName, @@ -175,6 +188,12 @@ export class MongoDB implements Wrapper { ); }); + OPERATIONS_WITH_FILTER_SECOND_ARGUMENT.forEach((operation) => { + collection.inspect(operation, (args, collection) => + this.inspectOperation(operation, args, collection as Collection, 1) + ); + }); + collection.inspect("bulkWrite", (args, collection) => this.inspectBulkWrite(args, collection as Collection) ); From 6f101ca703072776674004c44dde544d524ffc6f Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Tue, 24 Sep 2024 17:51:54 +0200 Subject: [PATCH 40/53] Undici/Fetch: Add metadata for SSRF --- library/sinks/Fetch.test.ts | 15 +++++++++++++-- library/sinks/Undici.test.ts | 19 ++++++++++++------- library/sinks/undici/wrapDispatch.ts | 6 +++++- .../ssrf/findHostnameInContext.ts | 4 ++++ .../ssrf/isRedirectToPrivateIP.ts | 1 + 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/library/sinks/Fetch.test.ts b/library/sinks/Fetch.test.ts index c1539850c..b2ed54089 100644 --- a/library/sinks/Fetch.test.ts +++ b/library/sinks/Fetch.test.ts @@ -2,6 +2,7 @@ import * as t from "tap"; import { Agent } from "../agent/Agent"; import { ReportingAPIForTesting } from "../agent/api/ReportingAPIForTesting"; +import { Token } from "../agent/api/Token"; import { Context, runWithContext } from "../agent/Context"; import { LoggerNoop } from "../agent/logger/LoggerNoop"; import { wrap } from "../helpers/wrap"; @@ -69,11 +70,12 @@ t.test( "it works", { skip: !global.fetch ? "fetch is not available" : false }, async (t) => { + const api = new ReportingAPIForTesting(); const agent = new Agent( true, new LoggerNoop(), - new ReportingAPIForTesting(), - undefined, + api, + new Token("123"), undefined ); agent.start([new Fetch()]); @@ -122,6 +124,15 @@ t.test( ); } + const events = api + .getEvents() + .filter((e) => e.type === "detected_attack"); + t.same(events.length, 1); + t.same(events[0].attack.metadata, { + hostname: "localhost", + port: 4000, + }); + const error2 = await t.rejects(() => fetch(new URL("http://localhost:4000/api/internal")) ); diff --git a/library/sinks/Undici.test.ts b/library/sinks/Undici.test.ts index fbfb7ca18..2e0155c93 100644 --- a/library/sinks/Undici.test.ts +++ b/library/sinks/Undici.test.ts @@ -62,13 +62,8 @@ t.test( }, async (t) => { const logger = new LoggerForTesting(); - const agent = new Agent( - true, - logger, - new ReportingAPIForTesting(), - new Token("123"), - undefined - ); + const api = new ReportingAPIForTesting(); + const agent = new Agent(true, logger, api, new Token("123"), undefined); agent.start([new Undici()]); @@ -160,6 +155,16 @@ t.test( "Zen has blocked a server-side request forgery: undici.request(...) originating from body.image" ); } + + const events = api + .getEvents() + .filter((e) => e.type === "detected_attack"); + t.same(events.length, 1); + t.same(events[0].attack.metadata, { + hostname: "localhost", + port: 4000, + }); + const error2 = await t.rejects(() => request(new URL("http://localhost:4000/api/internal")) ); diff --git a/library/sinks/undici/wrapDispatch.ts b/library/sinks/undici/wrapDispatch.ts index 00c6f5b75..66deacb1e 100644 --- a/library/sinks/undici/wrapDispatch.ts +++ b/library/sinks/undici/wrapDispatch.ts @@ -1,4 +1,5 @@ import type { Dispatcher } from "undici"; +import { getMetadataForSSRFAttack } from "../../vulnerabilities/ssrf/getMetadataForSSRFAttack"; import { RequestContextStorage } from "./RequestContextStorage"; import { Context, getContext } from "../../agent/Context"; import { tryParseURL } from "../../helpers/tryParseURL"; @@ -89,7 +90,10 @@ function blockRedirectToPrivateIP(url: URL, context: Context, agent: Agent) { blocked: agent.shouldBlock(), stack: new Error().stack!, path: found.pathToPayload, - metadata: {}, + metadata: getMetadataForSSRFAttack({ + hostname: found.hostname, + port: found.port, + }), request: context, payload: found.payload, }); diff --git a/library/vulnerabilities/ssrf/findHostnameInContext.ts b/library/vulnerabilities/ssrf/findHostnameInContext.ts index 66709eddf..b2a421574 100644 --- a/library/vulnerabilities/ssrf/findHostnameInContext.ts +++ b/library/vulnerabilities/ssrf/findHostnameInContext.ts @@ -7,6 +7,8 @@ type HostnameLocation = { source: Source; pathToPayload: string; payload: string; + port: number | undefined; + hostname: string; }; export function findHostnameInContext( @@ -27,6 +29,8 @@ export function findHostnameInContext( source: source, pathToPayload: path, payload: str, + port: port, + hostname: hostname, }; } } diff --git a/library/vulnerabilities/ssrf/isRedirectToPrivateIP.ts b/library/vulnerabilities/ssrf/isRedirectToPrivateIP.ts index aa1929139..446cb17e6 100644 --- a/library/vulnerabilities/ssrf/isRedirectToPrivateIP.ts +++ b/library/vulnerabilities/ssrf/isRedirectToPrivateIP.ts @@ -29,5 +29,6 @@ export function isRedirectToPrivateIP(url: URL, context: Context) { ); } } + return undefined; } From bcc9ae7a282ca5017cb401de0027f299d896b044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Tue, 24 Sep 2024 18:35:30 +0200 Subject: [PATCH 41/53] Use separate method for distinct --- library/sinks/MongoDB.ts | 50 ++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/library/sinks/MongoDB.ts b/library/sinks/MongoDB.ts index d2b2f60dd..9179843b4 100644 --- a/library/sinks/MongoDB.ts +++ b/library/sinks/MongoDB.ts @@ -22,8 +22,6 @@ const OPERATIONS_WITH_FILTER = [ "replaceOne", ] as const; -const OPERATIONS_WITH_FILTER_SECOND_ARGUMENT = ["distinct"] as const; - const BULK_WRITE_OPERATIONS_WITH_FILTER = [ "replaceOne", "updateOne", @@ -139,8 +137,7 @@ export class MongoDB implements Wrapper { private inspectOperation( operation: string, args: unknown[], - collection: Collection, - filterPosition = 0 + collection: Collection ): InterceptorResult { const context = getContext(); @@ -148,25 +145,40 @@ export class MongoDB implements Wrapper { return undefined; } - let filter: unknown; + if (args.length > 0 && isPlainObject(args[0])) { + const filter = args[0]; + + return this.inspectFilter( + collection.dbName, + collection.collectionName, + context, + filter, + operation + ); + } + + return undefined; + } + + private inspectDistinct( + args: unknown[], + collection: Collection + ): InterceptorResult { + const context = getContext(); - if (filterPosition === 0 && args.length > 0 && isPlainObject(args[0])) { - filter = args[0]; - } else if ( - filterPosition === 1 && - args.length > 1 && - isPlainObject(args[1]) - ) { - filter = args[1]; + if (!context) { + return undefined; } - if (filter) { + if (args.length > 1 && isPlainObject(args[1])) { + const filter = args[1]; + return this.inspectFilter( collection.dbName, collection.collectionName, context, filter, - operation + "distinct" ); } @@ -188,11 +200,9 @@ export class MongoDB implements Wrapper { ); }); - OPERATIONS_WITH_FILTER_SECOND_ARGUMENT.forEach((operation) => { - collection.inspect(operation, (args, collection) => - this.inspectOperation(operation, args, collection as Collection, 1) - ); - }); + collection.inspect("distinct", (args, collection) => + this.inspectDistinct(args, collection as Collection) + ); collection.inspect("bulkWrite", (args, collection) => this.inspectBulkWrite(args, collection as Collection) From f878dcd0a3fb4d5f1b3e71209be2f9b23822872e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Tue, 24 Sep 2024 18:40:32 +0200 Subject: [PATCH 42/53] Add happy path test --- library/sinks/MongoDB.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/library/sinks/MongoDB.test.ts b/library/sinks/MongoDB.test.ts index 8f0d849df..ecfa453ce 100644 --- a/library/sinks/MongoDB.test.ts +++ b/library/sinks/MongoDB.test.ts @@ -103,6 +103,10 @@ t.test("it inspects method calls and blocks if needed", async (t) => { t.same(await collection.count({ title: "Yet Another Title" }), 1); + t.same(await collection.distinct("title", { title: { $ne: null } }), [ + "Yet Another Title", + ]); + await collection.deleteOne({ title: "Yet Another Title" }); t.same(await collection.count({ title: "Yet Another Title" }), 0); From 671bfe9137e83bb72970b83bbacd98e2b91d3c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Tue, 24 Sep 2024 18:44:37 +0200 Subject: [PATCH 43/53] Add distinct test with safe context --- library/sinks/MongoDB.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/library/sinks/MongoDB.test.ts b/library/sinks/MongoDB.test.ts index ecfa453ce..06757fa8c 100644 --- a/library/sinks/MongoDB.test.ts +++ b/library/sinks/MongoDB.test.ts @@ -107,6 +107,13 @@ t.test("it inspects method calls and blocks if needed", async (t) => { "Yet Another Title", ]); + // With context + await runWithContext(safeContext, async () => { + t.same(await collection.distinct("title", { title: { $ne: null } }), [ + "Yet Another Title", + ]); + }); + await collection.deleteOne({ title: "Yet Another Title" }); t.same(await collection.count({ title: "Yet Another Title" }), 0); From f49b4bbd146347781fe2f94427e80e7cc300a5cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Tue, 24 Sep 2024 18:50:19 +0200 Subject: [PATCH 44/53] Increase code coverage --- library/sinks/MongoDB.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/library/sinks/MongoDB.test.ts b/library/sinks/MongoDB.test.ts index 06757fa8c..b8feba3b6 100644 --- a/library/sinks/MongoDB.test.ts +++ b/library/sinks/MongoDB.test.ts @@ -106,6 +106,7 @@ t.test("it inspects method calls and blocks if needed", async (t) => { t.same(await collection.distinct("title", { title: { $ne: null } }), [ "Yet Another Title", ]); + t.same(await collection.distinct("title"), ["Yet Another Title"]); // With context await runWithContext(safeContext, async () => { From 59ce3c875be7fb4e166c24ecc87b42ae0052b61d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Wed, 25 Sep 2024 14:31:17 +0200 Subject: [PATCH 45/53] Unhook fs functions that are not dangerous --- library/sinks/FileSystem.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/library/sinks/FileSystem.ts b/library/sinks/FileSystem.ts index 7048f174d..1518e02cb 100644 --- a/library/sinks/FileSystem.ts +++ b/library/sinks/FileSystem.ts @@ -11,17 +11,14 @@ type FileSystemFunction = { }; const functions: Record = { - access: { pathsArgs: 1, sync: true, promise: true }, appendFile: { pathsArgs: 1, sync: true, promise: true }, chmod: { pathsArgs: 1, sync: true, promise: true }, chown: { pathsArgs: 1, sync: true, promise: true }, createReadStream: { pathsArgs: 1, sync: false, promise: false }, createWriteStream: { pathsArgs: 1, sync: false, promise: false }, - exists: { pathsArgs: 1, sync: true, promise: false }, lchmod: { pathsArgs: 1, sync: true, promise: true }, lchown: { pathsArgs: 1, sync: true, promise: true }, lutimes: { pathsArgs: 1, sync: true, promise: true }, - lstat: { pathsArgs: 1, sync: true, promise: true }, mkdir: { pathsArgs: 1, sync: true, promise: true }, open: { pathsArgs: 1, sync: true, promise: true }, openAsBlob: { pathsArgs: 1, sync: false, promise: false }, @@ -35,8 +32,6 @@ const functions: Record = { rmdir: { pathsArgs: 1, sync: true, promise: true }, rm: { pathsArgs: 1, sync: true, promise: true }, symlink: { pathsArgs: 2, sync: true, promise: true }, - stat: { pathsArgs: 1, sync: true, promise: true }, - statfs: { pathsArgs: 1, sync: true, promise: true }, truncate: { pathsArgs: 1, sync: true, promise: true }, utimes: { pathsArgs: 1, sync: true, promise: true }, writeFile: { pathsArgs: 1, sync: true, promise: true }, From 995eb9bf389e5d7808f85f731dee20c3cef43b08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Wed, 25 Sep 2024 14:49:51 +0200 Subject: [PATCH 46/53] Fix ShellJS tests --- library/sinks/Shelljs.test.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/library/sinks/Shelljs.test.ts b/library/sinks/Shelljs.test.ts index 788dcf39b..e8902924f 100644 --- a/library/sinks/Shelljs.test.ts +++ b/library/sinks/Shelljs.test.ts @@ -186,12 +186,16 @@ t.test("it prevents path injections using ls", async () => { const shelljs = require("shelljs"); - // The exception is catched by shelljs and can not directly be caught by the test - runWithContext(dangerousPathContext, () => { - const result = shelljs.ls("/etc/ssh"); - t.same(result.code, 2); - t.ok(getContext()?.attackDetected); + const error = await t.rejects(async () => { + runWithContext(dangerousPathContext, () => { + return shelljs.ls("/etc/ssh"); + }); }); + + t.same( + error.message, + "Zen has blocked a path traversal attack: fs.readdirSync(...) originating from body.myTitle" + ); }); t.test("it prevents path injections using cat", async () => { @@ -207,16 +211,19 @@ t.test("it prevents path injections using cat", async () => { const shelljs = require("shelljs"); const error = await t.rejects(async () => { - runWithContext(dangerousPathContext, () => { - return shelljs.cat("/etc/ssh/*"); - }); + runWithContext( + { ...dangerousPathContext, body: { myTitle: "../package.json" } }, + () => { + return shelljs.cat("../package.json"); + } + ); }); t.ok(error instanceof Error); if (error instanceof Error) { t.same( error.message, - "Zen has blocked a path traversal attack: fs.existsSync(...) originating from body.myTitle" + "Zen has blocked a path traversal attack: fs.readFileSync(...) originating from body.myTitle" ); } }); From 91f3820cb32ce5190cf0ecd827bda0bc89774b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Thu, 26 Sep 2024 16:08:07 +0200 Subject: [PATCH 47/53] Add AIKIDO_DISABLE and envToBool helper --- library/helpers/envToBool.ts | 8 ++++++++ library/helpers/isAikidoCI.ts | 4 +++- library/helpers/isDebugging.ts | 6 +++--- library/helpers/shouldBlock.ts | 8 ++++---- library/helpers/shouldEnableFirewall.test.ts | 7 +++++++ library/helpers/shouldEnableFirewall.ts | 5 +++++ 6 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 library/helpers/envToBool.ts diff --git a/library/helpers/envToBool.ts b/library/helpers/envToBool.ts new file mode 100644 index 000000000..09c474105 --- /dev/null +++ b/library/helpers/envToBool.ts @@ -0,0 +1,8 @@ +const trueValues = ["true", "1", "yes", "y", "on"]; + +export function envToBool(env: string | undefined): boolean { + if (!env) { + return false; + } + return trueValues.includes(env.toLowerCase()); +} diff --git a/library/helpers/isAikidoCI.ts b/library/helpers/isAikidoCI.ts index 0e1208858..b9f9799e7 100644 --- a/library/helpers/isAikidoCI.ts +++ b/library/helpers/isAikidoCI.ts @@ -1,3 +1,5 @@ +import { envToBool } from "./envToBool"; + export function isAikidoCI(): boolean { - return process.env.AIKIDO_CI === "true" || process.env.AIKIDO_CI === "1"; + return envToBool(process.env.AIKIDO_CI); } diff --git a/library/helpers/isDebugging.ts b/library/helpers/isDebugging.ts index 175a13920..45b1e3310 100644 --- a/library/helpers/isDebugging.ts +++ b/library/helpers/isDebugging.ts @@ -1,8 +1,8 @@ +import { envToBool } from "./envToBool"; + /** * Checks if AIKIDO_DEBUG is set to true or 1 */ export function isDebugging() { - return ( - process.env.AIKIDO_DEBUG === "true" || process.env.AIKIDO_DEBUG === "1" - ); + return envToBool(process.env.AIKIDO_DEBUG); } diff --git a/library/helpers/shouldBlock.ts b/library/helpers/shouldBlock.ts index 699df2873..52c4a6865 100644 --- a/library/helpers/shouldBlock.ts +++ b/library/helpers/shouldBlock.ts @@ -1,3 +1,5 @@ +import { envToBool } from "./envToBool"; + /** * Check the environment variables to see if the firewall should block requests if an attack is detected. * - AIKIDO_BLOCKING=true or AIKIDO_BLOCKING=1 @@ -5,9 +7,7 @@ */ export function shouldBlock() { return ( - process.env.AIKIDO_BLOCKING === "true" || - process.env.AIKIDO_BLOCKING === "1" || - process.env.AIKIDO_BLOCK === "true" || - process.env.AIKIDO_BLOCK === "1" + envToBool(process.env.AIKIDO_BLOCKING) || + envToBool(process.env.AIKIDO_BLOCK) ); } diff --git a/library/helpers/shouldEnableFirewall.test.ts b/library/helpers/shouldEnableFirewall.test.ts index b3ca4d239..d39923080 100644 --- a/library/helpers/shouldEnableFirewall.test.ts +++ b/library/helpers/shouldEnableFirewall.test.ts @@ -35,3 +35,10 @@ t.test("it works if multiple are set", async () => { process.env.AIKIDO_BLOCK = "1"; t.same(shouldEnableFirewall(), true); }); + +t.test("it works if AIKIDO_DISABLE is set", async () => { + process.env.AIKIDO_BLOCK = "1"; + t.same(shouldEnableFirewall(), true); + process.env.AIKIDO_DISABLE = "1"; + t.same(shouldEnableFirewall(), false); +}); diff --git a/library/helpers/shouldEnableFirewall.ts b/library/helpers/shouldEnableFirewall.ts index 4a983b660..b4d7985c6 100644 --- a/library/helpers/shouldEnableFirewall.ts +++ b/library/helpers/shouldEnableFirewall.ts @@ -1,3 +1,4 @@ +import { envToBool } from "./envToBool"; import { isAikidoCI } from "./isAikidoCI"; import { isDebugging } from "./isDebugging"; import { shouldBlock } from "./shouldBlock"; @@ -10,6 +11,10 @@ import { shouldBlock } from "./shouldBlock"; * - AIKIDO_DEBUG */ export default function shouldEnableFirewall() { + if (envToBool(process.env.AIKIDO_DISABLE)) { + return false; + } + if (shouldBlock()) { return true; } From 8f980243641505d527fd289da211cc7581065d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Thu, 26 Sep 2024 16:12:37 +0200 Subject: [PATCH 48/53] Improve envToBool --- library/helpers/envToBool.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/library/helpers/envToBool.ts b/library/helpers/envToBool.ts index 09c474105..0371137db 100644 --- a/library/helpers/envToBool.ts +++ b/library/helpers/envToBool.ts @@ -1,8 +1,11 @@ const trueValues = ["true", "1", "yes", "y", "on"]; -export function envToBool(env: string | undefined): boolean { - if (!env) { +/** + * Parses the string value of an environment variable to a boolean. + */ +export function envToBool(envName: string | undefined): boolean { + if (!envName) { return false; } - return trueValues.includes(env.toLowerCase()); + return trueValues.includes(envName.toLowerCase()); } From 2470ad7eab22f75479e599080805ccb38fdb95f3 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Thu, 26 Sep 2024 17:27:45 +0200 Subject: [PATCH 49/53] Update library/helpers/shouldEnableFirewall.ts --- library/helpers/shouldEnableFirewall.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/helpers/shouldEnableFirewall.ts b/library/helpers/shouldEnableFirewall.ts index b4d7985c6..1cf5d6505 100644 --- a/library/helpers/shouldEnableFirewall.ts +++ b/library/helpers/shouldEnableFirewall.ts @@ -30,7 +30,7 @@ export default function shouldEnableFirewall() { if (!isAikidoCI()) { // eslint-disable-next-line no-console console.log( - "AIKIDO: Firewall is disabled. Configure one of the following environment variables to enable it: AIKIDO_BLOCK, AIKIDO_TOKEN, AIKIDO_DEBUG." + "AIKIDO: Zen is disabled. Configure one of the following environment variables to enable it: AIKIDO_BLOCK, AIKIDO_TOKEN, AIKIDO_DEBUG." ); } return false; From 66192f42ecc9d5b484c1670002528f13f750411a Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 27 Sep 2024 11:04:54 +0200 Subject: [PATCH 50/53] Extract type --- library/sinks/undici/getHostInfoFromArgs.ts | 26 ++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/library/sinks/undici/getHostInfoFromArgs.ts b/library/sinks/undici/getHostInfoFromArgs.ts index 36528a578..a3f0cc051 100644 --- a/library/sinks/undici/getHostInfoFromArgs.ts +++ b/library/sinks/undici/getHostInfoFromArgs.ts @@ -2,18 +2,19 @@ import { getPortFromURL } from "../../helpers/getPortFromURL"; import { tryParseURL } from "../../helpers/tryParseURL"; import { isOptionsObject } from "../http-request/isOptionsObject"; +type HostnameAndPort = { + hostname: string; + port: number | undefined; +}; + /** * Extract hostname and port from the arguments of a undici request. * Used for SSRF detection. */ -export function getHostInfoFromArgs(args: unknown[]): - | { - hostname: string; - port: number | undefined; - } - | undefined { +export function getHostInfoFromArgs( + args: unknown[] +): HostnameAndPort | undefined { let url: URL | undefined; - if (args.length > 0) { // URL provided as a string if (typeof args[0] === "string" && args[0].length > 0) { @@ -45,18 +46,14 @@ export function getHostInfoFromArgs(args: unknown[]): return parseOptionsObject(args[0]); } } + return undefined; } /** * Parse a undici request options object to extract hostname and port. */ -function parseOptionsObject(obj: any): - | { - hostname: string; - port: number | undefined; - } - | undefined { +function parseOptionsObject(obj: any): HostnameAndPort | undefined { // Origin is preferred over hostname // See https://github.com/nodejs/undici/blob/c926a43ac5952b8b5a6c7d15529b56599bc1b762/lib/core/util.js#L177 if (obj.origin != null && typeof obj.origin === "string") { @@ -67,6 +64,7 @@ function parseOptionsObject(obj: any): port: getPortFromURL(url), }; } + // Undici should throw an error if the origin is not a valid URL return undefined; } @@ -83,10 +81,12 @@ function parseOptionsObject(obj: any): ) { port = parseInt(obj.port, 10); } + // hostname is required by undici and host is not supported if (typeof obj.hostname !== "string" || obj.hostname.length === 0) { return undefined; } + return { hostname: obj.hostname, port, From 579eb55c64987ac90d849faf13b501e32c37e1e4 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 27 Sep 2024 11:16:50 +0200 Subject: [PATCH 51/53] Cleanup --- library/sinks/Undici.ts | 13 +++++-------- ...s.test.ts => getHostnameAndPortFromArgs.test.ts} | 2 +- ...nfoFromArgs.ts => getHostnameAndPortFromArgs.ts} | 2 +- 3 files changed, 7 insertions(+), 10 deletions(-) rename library/sinks/undici/{getHostInfoFromArgs.test.ts => getHostnameAndPortFromArgs.test.ts} (96%) rename library/sinks/undici/{getHostInfoFromArgs.ts => getHostnameAndPortFromArgs.ts} (98%) diff --git a/library/sinks/Undici.ts b/library/sinks/Undici.ts index 8e735680f..f320ca0da 100644 --- a/library/sinks/Undici.ts +++ b/library/sinks/Undici.ts @@ -8,13 +8,10 @@ import { getMajorNodeVersion, getMinorNodeVersion, } from "../helpers/getNodeVersion"; -import { getPortFromURL } from "../helpers/getPortFromURL"; -import { tryParseURL } from "../helpers/tryParseURL"; import { checkContextForSSRF } from "../vulnerabilities/ssrf/checkContextForSSRF"; import { inspectDNSLookupCalls } from "../vulnerabilities/ssrf/inspectDNSLookupCalls"; import { wrapDispatch } from "./undici/wrapDispatch"; -import { isOptionsObject } from "./http-request/isOptionsObject"; -import { getHostInfoFromArgs } from "./undici/getHostInfoFromArgs"; +import { getHostnameAndPortFromArgs } from "./undici/getHostnameAndPortFromArgs"; const methods = [ "request", @@ -57,12 +54,12 @@ export class Undici implements Wrapper { agent: Agent, method: string ): InterceptorResult { - const hostInfo = getHostInfoFromArgs(args); - if (hostInfo) { + const hostnameAndPort = getHostnameAndPortFromArgs(args); + if (hostnameAndPort) { const attack = this.inspectHostname( agent, - hostInfo.hostname, - hostInfo.port, + hostnameAndPort.hostname, + hostnameAndPort.port, method ); if (attack) { diff --git a/library/sinks/undici/getHostInfoFromArgs.test.ts b/library/sinks/undici/getHostnameAndPortFromArgs.test.ts similarity index 96% rename from library/sinks/undici/getHostInfoFromArgs.test.ts rename to library/sinks/undici/getHostnameAndPortFromArgs.test.ts index 155a55bcc..b5b1bfcde 100644 --- a/library/sinks/undici/getHostInfoFromArgs.test.ts +++ b/library/sinks/undici/getHostnameAndPortFromArgs.test.ts @@ -1,5 +1,5 @@ import * as t from "tap"; -import { getHostInfoFromArgs as get } from "./getHostInfoFromArgs"; +import { getHostnameAndPortFromArgs as get } from "./getHostnameAndPortFromArgs"; import { parse as parseUrl } from "url"; t.test("it works with url string", async (t) => { diff --git a/library/sinks/undici/getHostInfoFromArgs.ts b/library/sinks/undici/getHostnameAndPortFromArgs.ts similarity index 98% rename from library/sinks/undici/getHostInfoFromArgs.ts rename to library/sinks/undici/getHostnameAndPortFromArgs.ts index a3f0cc051..a8e408a50 100644 --- a/library/sinks/undici/getHostInfoFromArgs.ts +++ b/library/sinks/undici/getHostnameAndPortFromArgs.ts @@ -11,7 +11,7 @@ type HostnameAndPort = { * Extract hostname and port from the arguments of a undici request. * Used for SSRF detection. */ -export function getHostInfoFromArgs( +export function getHostnameAndPortFromArgs( args: unknown[] ): HostnameAndPort | undefined { let url: URL | undefined; From fc264632a3fd0921c868cef41de7724f2f2601ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Wed, 2 Oct 2024 11:15:49 +0200 Subject: [PATCH 52/53] Fix not protecting path functions of different os --- library/sinks/Path.test.ts | 18 ++++++++++++++++++ library/sinks/Path.ts | 8 +++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/library/sinks/Path.test.ts b/library/sinks/Path.test.ts index a104df230..24392ba54 100644 --- a/library/sinks/Path.test.ts +++ b/library/sinks/Path.test.ts @@ -104,4 +104,22 @@ t.test("it works", async (t) => { "Zen has blocked a Path traversal: fs.resolve(...) originating from body.file.matches" ); }); + + const { join: joinWin } = require("path/win32"); + + runWithContext(unsafeAbsoluteContext, () => { + t.throws( + () => joinWin("/etc/some_directory", "test.txt"), + "Zen has blocked a Path traversal: fs.resolve(...) originating from body.file.matches" + ); + }); + + const { normalize: normalizePosix } = require("path/posix"); + + runWithContext(unsafeContext, () => { + t.throws( + () => normalizePosix(__dirname, "../test.txt"), + "Zen has blocked a Path traversal: fs.join(...) originating from body.file.matches" + ); + }); }); diff --git a/library/sinks/Path.ts b/library/sinks/Path.ts index 76dff889f..b201aa57b 100644 --- a/library/sinks/Path.ts +++ b/library/sinks/Path.ts @@ -34,7 +34,13 @@ export class Path implements Wrapper { wrap(hooks: Hooks): void { hooks - .addBuiltinModule("path") + .addBuiltinModule("path/posix") + .addSubject((exports) => exports) + .inspect("join", (args) => this.inspectPath(args, "join")) + .inspect("resolve", (args) => this.inspectPath(args, "resolve")) + .inspect("normalize", (args) => this.inspectPath(args, "normalize")); + hooks + .addBuiltinModule("path/win32") .addSubject((exports) => exports) .inspect("join", (args) => this.inspectPath(args, "join")) .inspect("resolve", (args) => this.inspectPath(args, "resolve")) From 348e4145bb851970edc8f682e098496632fd5892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20K=C3=B6ssler?= Date: Mon, 7 Oct 2024 09:18:26 +0200 Subject: [PATCH 53/53] Fix path unit tests --- library/sinks/Path.test.ts | 99 +++++++++++++++++++++++++------------- 1 file changed, 66 insertions(+), 33 deletions(-) diff --git a/library/sinks/Path.test.ts b/library/sinks/Path.test.ts index 24392ba54..f89598486 100644 --- a/library/sinks/Path.test.ts +++ b/library/sinks/Path.test.ts @@ -53,39 +53,56 @@ t.test("it works", async (t) => { }); runWithContext(unsafeContext, () => { - t.throws( - () => join(__dirname, "../test.txt"), - "Zen has blocked a Path traversal: fs.join(...) originating from body.file.matches" + const error = t.throws(() => join(__dirname, "../test.txt")); + t.same( + error instanceof Error ? error.message : null, + "Zen has blocked a path traversal attack: path.join(...) originating from body.file.matches" ); - t.throws( - () => resolve(__dirname, "../test.txt"), - "Zen has blocked a Path traversal: fs.resolve(...) originating from body.file.matches" + const error2 = t.throws(() => resolve(__dirname, "../test.txt")); + t.same( + error2 instanceof Error ? error2.message : null, + "Zen has blocked a path traversal attack: path.resolve(...) originating from body.file.matches" ); - t.throws( - () => join(__dirname, "some_directory", "../test.txt"), - "Zen has blocked a Path traversal: fs.join(...) originating from body.file.matches" + const error3 = t.throws(() => + join(__dirname, "some_directory", "../test.txt") + ); + t.same( + error3 instanceof Error ? error3.message : null, + "Zen has blocked a path traversal attack: path.join(...) originating from body.file.matches" ); - t.throws( - () => resolve(__dirname, "some_directory", "../test.txt"), - "Zen has blocked a Path traversal: fs.resolve(...) originating from body.file.matches" + const error4 = t.throws(() => + resolve(__dirname, "some_directory", "../test.txt") + ); + t.same( + error4 instanceof Error ? error4.message : null, + "Zen has blocked a path traversal attack: path.resolve(...) originating from body.file.matches" ); - t.throws( - () => join(__dirname, "some_directory", "../../test.txt"), - "Zen has blocked a Path traversal: fs.join(...) originating from body.file.matches" + const error5 = t.throws(() => + join(__dirname, "some_directory", "../../test.txt") + ); + t.same( + error5 instanceof Error ? error5.message : null, + "Zen has blocked a path traversal attack: path.join(...) originating from body.file.matches" ); - t.throws( - () => resolve(__dirname, "../test.txt", "some_directory"), - "Zen has blocked a Path traversal: fs.resolve(...) originating from body.file.matches" + const error6 = t.throws(() => + resolve(__dirname, "../test.txt", "some_directory") + ); + t.same( + error6 instanceof Error ? error6.message : null, + "Zen has blocked a path traversal attack: path.resolve(...) originating from body.file.matches" ); - t.throws( - () => join(__dirname, "../test.txt", "some_directory"), - "Zen has blocked a Path traversal: fs.join(...) originating from body.file.matches" + const error7 = t.throws(() => + join(__dirname, "../test.txt", "some_directory") + ); + t.same( + error7 instanceof Error ? error7.message : null, + "Zen has blocked a path traversal attack: path.join(...) originating from body.file.matches" ); }); @@ -94,32 +111,48 @@ t.test("it works", async (t) => { }); runWithContext(unsafeAbsoluteContext, () => { - t.throws( - () => join("/etc/", "test.txt"), - "Zen has blocked a Path traversal: fs.join(...) originating from body.file.matches" + const error = t.throws(() => join("/etc/", "test.txt")); + t.same( + error instanceof Error ? error.message : null, + "Zen has blocked a path traversal attack: path.normalize(...) originating from body.file.matches" ); - t.throws( - () => resolve("/etc/some_directory", "test.txt"), - "Zen has blocked a Path traversal: fs.resolve(...) originating from body.file.matches" + const error2 = t.throws(() => resolve("/etc/some_directory", "test.txt")); + t.same( + error2 instanceof Error ? error2.message : null, + "Zen has blocked a path traversal attack: path.resolve(...) originating from body.file.matches" ); }); const { join: joinWin } = require("path/win32"); runWithContext(unsafeAbsoluteContext, () => { - t.throws( - () => joinWin("/etc/some_directory", "test.txt"), - "Zen has blocked a Path traversal: fs.resolve(...) originating from body.file.matches" + const error = t.throws(() => joinWin("/etc/some_directory", "test.txt")); + t.same( + error instanceof Error ? error.message : null, + "Zen has blocked a path traversal attack: path.join(...) originating from body.file.matches" ); }); const { normalize: normalizePosix } = require("path/posix"); runWithContext(unsafeContext, () => { - t.throws( - () => normalizePosix(__dirname, "../test.txt"), - "Zen has blocked a Path traversal: fs.join(...) originating from body.file.matches" + const error = t.throws(() => normalizePosix(__dirname, "../test.txt")); + t.same( + error instanceof Error ? error.message : null, + "Zen has blocked a path traversal attack: path.normalize(...) originating from body.file.matches" + ); + }); + + const { win32: win32FromPosix } = require("path/posix"); + + runWithContext(unsafeContext, () => { + const error = t.throws(() => + win32FromPosix.normalize(__dirname, "../test.txt") + ); + t.same( + error instanceof Error ? error.message : null, + "Zen has blocked a path traversal attack: path.normalize(...) originating from body.file.matches" ); }); });