diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 9de58e898..77a296461 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -39,6 +39,12 @@ jobs: uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} + - 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 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/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/end2end/server/Dockerfile b/end2end/server/Dockerfile new file mode 100644 index 000000000..865b93032 --- /dev/null +++ b/end2end/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/end2end/server/app.js b/end2end/server/app.js new file mode 100644 index 000000000..e5d438ae8 --- /dev/null +++ b/end2end/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/end2end/server/package.json b/end2end/server/package.json new file mode 100644 index 000000000..3d1bab564 --- /dev/null +++ b/end2end/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/end2end/server/src/handlers/captureEvent.js b/end2end/server/src/handlers/captureEvent.js new file mode 100644 index 000000000..f44312f2b --- /dev/null +++ b/end2end/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/end2end/server/src/handlers/createApp.js b/end2end/server/src/handlers/createApp.js new file mode 100644 index 000000000..21a11b503 --- /dev/null +++ b/end2end/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/end2end/server/src/handlers/getConfig.js b/end2end/server/src/handlers/getConfig.js new file mode 100644 index 000000000..f6fae0a87 --- /dev/null +++ b/end2end/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/end2end/server/src/handlers/listEvents.js b/end2end/server/src/handlers/listEvents.js new file mode 100644 index 000000000..33f703e17 --- /dev/null +++ b/end2end/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/end2end/server/src/middleware/checkToken.js b/end2end/server/src/middleware/checkToken.js new file mode 100644 index 000000000..2e35d891f --- /dev/null +++ b/end2end/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/end2end/server/src/zen/apps.js b/end2end/server/src/zen/apps.js new file mode 100644 index 000000000..a3a102277 --- /dev/null +++ b/end2end/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/end2end/server/src/zen/config.js b/end2end/server/src/zen/config.js new file mode 100644 index 000000000..bd2aa4e71 --- /dev/null +++ b/end2end/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/end2end/server/src/zen/events.js b/end2end/server/src/zen/events.js new file mode 100644 index 000000000..c40f30a88 --- /dev/null +++ b/end2end/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, +}; diff --git a/end2end/tests/express-mongodb.ssrf.test.js b/end2end/tests/express-mongodb.ssrf.test.js new file mode 100644 index 000000000..2255875b6 --- /dev/null +++ b/end2end/tests/express-mongodb.ssrf.test.js @@ -0,0 +1,186 @@ +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"); + +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:5875/favicon.png"; + +t.setTimeout(60000); + +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", + }); + 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(); + }); +}); + +t.after(async () => { + server.close(); +}); diff --git a/end2end/tests/fixtures/favicon.png b/end2end/tests/fixtures/favicon.png new file mode 100644 index 000000000..974acbf15 Binary files /dev/null and b/end2end/tests/fixtures/favicon.png differ diff --git a/library/agent/Routes.test.ts b/library/agent/Routes.test.ts index 1064498dc..19816cb35 100644 --- a/library/agent/Routes.test.ts +++ b/library/agent/Routes.test.ts @@ -548,3 +548,52 @@ 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: {}, + }, + ]); +}); + +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 b609666b3..2169acb7e 100644 --- a/library/agent/api-discovery/getApiInfo.ts +++ b/library/agent/api-discovery/getApiInfo.ts @@ -27,7 +27,12 @@ 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 && + !context.graphql + ) { bodyInfo = { type: getBodyDataType(context.headers), schema: getDataSchema(context.body), 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/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/helpers/envToBool.ts b/library/helpers/envToBool.ts new file mode 100644 index 000000000..0371137db --- /dev/null +++ b/library/helpers/envToBool.ts @@ -0,0 +1,11 @@ +const trueValues = ["true", "1", "yes", "y", "on"]; + +/** + * 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(envName.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 new file mode 100644 index 000000000..45b1e3310 --- /dev/null +++ b/library/helpers/isDebugging.ts @@ -0,0 +1,8 @@ +import { envToBool } from "./envToBool"; + +/** + * Checks if AIKIDO_DEBUG is set to true or 1 + */ +export function isDebugging() { + return envToBool(process.env.AIKIDO_DEBUG); +} diff --git a/library/helpers/shouldBlock.ts b/library/helpers/shouldBlock.ts new file mode 100644 index 000000000..52c4a6865 --- /dev/null +++ b/library/helpers/shouldBlock.ts @@ -0,0 +1,13 @@ +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 + * - AIKIDO_BLOCK=true or AIKIDO_BLOCK=1 + */ +export function shouldBlock() { + return ( + envToBool(process.env.AIKIDO_BLOCKING) || + envToBool(process.env.AIKIDO_BLOCK) + ); +} diff --git a/library/helpers/shouldEnableFirewall.test.ts b/library/helpers/shouldEnableFirewall.test.ts new file mode 100644 index 000000000..d39923080 --- /dev/null +++ b/library/helpers/shouldEnableFirewall.test.ts @@ -0,0 +1,44 @@ +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); +}); + +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 new file mode 100644 index 000000000..1cf5d6505 --- /dev/null +++ b/library/helpers/shouldEnableFirewall.ts @@ -0,0 +1,37 @@ +import { envToBool } from "./envToBool"; +import { isAikidoCI } from "./isAikidoCI"; +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 (envToBool(process.env.AIKIDO_DISABLE)) { + return false; + } + + if (shouldBlock()) { + return true; + } + + if (process.env.AIKIDO_TOKEN) { + return true; + } + + if (isDebugging()) { + return true; + } + + if (!isAikidoCI()) { + // eslint-disable-next-line no-console + console.log( + "AIKIDO: Zen is disabled. Configure one of the following environment variables to enable it: AIKIDO_BLOCK, AIKIDO_TOKEN, AIKIDO_DEBUG." + ); + } + return false; +} 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..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, @@ -47,3 +51,16 @@ function defineProperty(obj: unknown, name: string, value: unknown) { value: value, }); } + +/** + * Check if a function is wrapped + */ +export function isWrapped(fn: T): fn is WrappedFunction { + return ( + fn instanceof Function && + "__wrapped" in fn && + fn.__wrapped === true && + "__original" in fn && + fn.__original instanceof Function + ); +} 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(); } 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", diff --git a/library/sinks/Fetch.test.ts b/library/sinks/Fetch.test.ts index a4a02b07f..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"; @@ -52,7 +53,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 = { @@ -68,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()]); @@ -121,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")) ); @@ -291,16 +303,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 +376,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/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 }, 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); diff --git a/library/sinks/HTTPRequest.ts b/library/sinks/HTTPRequest.ts index 371d69412..a428c63a5 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 ), }; @@ -123,24 +125,21 @@ export class HTTPRequest implements Wrapper { return args.concat(newOpts); } - if (optionObj.lookup) { - optionObj.lookup = inspectDNSLookupCalls( - optionObj.lookup, - agent, - module, - `${module}.request`, - url - ) as RequestOptions["lookup"]; - } else { - optionObj.lookup = inspectDNSLookupCalls( - lookup, - agent, - module, - `${module}.request`, - url - ) as RequestOptions["lookup"]; + 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; } + optionObj.lookup = inspectDNSLookupCalls( + nativeLookup, + agent, + module, + `${module}.request`, + url, + stackTraceCallingLocation + ) as NonNullable; + return args; } diff --git a/library/sinks/MongoDB.test.ts b/library/sinks/MongoDB.test.ts index 77b55ab78..b8feba3b6 100644 --- a/library/sinks/MongoDB.test.ts +++ b/library/sinks/MongoDB.test.ts @@ -103,6 +103,18 @@ 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", + ]); + t.same(await collection.distinct("title"), ["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); @@ -185,6 +197,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..9179843b4 100644 --- a/library/sinks/MongoDB.ts +++ b/library/sinks/MongoDB.ts @@ -160,6 +160,31 @@ export class MongoDB implements Wrapper { return undefined; } + private inspectDistinct( + args: unknown[], + collection: Collection + ): InterceptorResult { + const context = getContext(); + + if (!context) { + return undefined; + } + + if (args.length > 1 && isPlainObject(args[1])) { + const filter = args[1]; + + return this.inspectFilter( + collection.dbName, + collection.collectionName, + context, + filter, + "distinct" + ); + } + + return undefined; + } + wrap(hooks: Hooks) { const mongodb = hooks .addPackage("mongodb") @@ -175,6 +200,10 @@ export class MongoDB implements Wrapper { ); }); + collection.inspect("distinct", (args, collection) => + this.inspectDistinct(args, collection as Collection) + ); + collection.inspect("bulkWrite", (args, collection) => this.inspectBulkWrite(args, collection as Collection) ); diff --git a/library/sinks/Path.test.ts b/library/sinks/Path.test.ts index a104df230..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,14 +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, () => { + 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, () => { + 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" ); }); }); 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")) 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" ); } }); diff --git a/library/sinks/Undici.test.ts b/library/sinks/Undici.test.ts index fbfb7ca18..4fc8ba2a9 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()]); @@ -139,6 +134,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: "" })); @@ -160,6 +169,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.ts b/library/sinks/Undici.ts index 319efcd3f..f320ca0da 100644 --- a/library/sinks/Undici.ts +++ b/library/sinks/Undici.ts @@ -8,12 +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 { getHostnameAndPortFromArgs } from "./undici/getHostnameAndPortFromArgs"; const methods = [ "request", @@ -56,80 +54,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 hostnameAndPort = getHostnameAndPortFromArgs(args); + if (hostnameAndPort) { + const attack = this.inspectHostname( + agent, + hostnameAndPort.hostname, + hostnameAndPort.port, + method + ); + if (attack) { + return attack; } } diff --git a/library/sinks/undici/getHostnameAndPortFromArgs.test.ts b/library/sinks/undici/getHostnameAndPortFromArgs.test.ts new file mode 100644 index 000000000..b5b1bfcde --- /dev/null +++ b/library/sinks/undici/getHostnameAndPortFromArgs.test.ts @@ -0,0 +1,106 @@ +import * as t from "tap"; +import { getHostnameAndPortFromArgs as get } from "./getHostnameAndPortFromArgs"; +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/getHostnameAndPortFromArgs.ts b/library/sinks/undici/getHostnameAndPortFromArgs.ts new file mode 100644 index 000000000..a8e408a50 --- /dev/null +++ b/library/sinks/undici/getHostnameAndPortFromArgs.ts @@ -0,0 +1,94 @@ +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 getHostnameAndPortFromArgs( + 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) { + 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): 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") { + 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, + }; +} 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/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); } } 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; } diff --git a/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts b/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts index 807bd4e29..377b8b32f 100644 --- a/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts +++ b/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts @@ -731,3 +731,60 @@ 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: "" }, + } + ); +}); + +t.test("it does not detect", async () => { + t.same( + detectNoSQLInjection( + createContext({ + body: { + username: "admin", + password: "test", + }, + }), + { username: "admin", password: "test" } + ), + { + injection: false, + } + ); +}); 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], diff --git a/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts b/library/vulnerabilities/path-traversal/detectPathTraversal.test.ts index eb6c62363..1fe0acfff 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); @@ -64,6 +65,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); }); @@ -76,8 +81,16 @@ 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); +}); + +t.test("another bypass", async () => { + t.same( + detectPathTraversal("/./././root/test.txt", "/./././root/test.txt"), + true + ); + t.same(detectPathTraversal("/./././root/test.txt", "/./././root"), true); }); t.test("no path traversal", async () => { @@ -109,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); + } +); diff --git a/library/vulnerabilities/path-traversal/unsafePathStart.ts b/library/vulnerabilities/path-traversal/unsafePathStart.ts index 5049e5e89..da0ac68a6 100644 --- a/library/vulnerabilities/path-traversal/unsafePathStart.ts +++ b/library/vulnerabilities/path-traversal/unsafePathStart.ts @@ -1,3 +1,6 @@ +import { isAbsolute, resolve } from "path"; +import { isWrapped } from "../../helpers/wrap"; + const linuxRootFolders = [ "/bin/", "/boot/", @@ -22,12 +25,23 @@ const linuxRootFolders = [ const dangerousPathStarts = [...linuxRootFolders, "c:/", "c:\\"]; export function startsWithUnsafePath(filePath: string, userInput: string) { - const lowerCasePath = filePath.toLowerCase(); - const lowerCaseUserInput = userInput.toLowerCase(); + // Check if path is relative (not absolute or drive letter path) + // Required because resolve will build absolute paths from relative paths + if (!isAbsolute(filePath) || !isAbsolute(userInput)) { + return false; + } + + let origResolve = resolve; + if (isWrapped(resolve)) { + origResolve = resolve.__original; + } + + const normalizedPath = origResolve(filePath).toLowerCase(); + const normalizedUserInput = origResolve(userInput).toLowerCase(); for (const dangerousStart of dangerousPathStarts) { if ( - lowerCasePath.startsWith(dangerousStart) && - lowerCasePath.startsWith(lowerCaseUserInput) + normalizedPath.startsWith(dangerousStart) && + normalizedPath.startsWith(normalizedUserInput) ) { return true; } 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 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/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/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..f83ff32e6 100644 --- a/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts +++ b/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts @@ -1,10 +1,13 @@ 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"; import { isPrivateIP } from "./isPrivateIP"; import { isIMDSIPAddress, isTrustedHostname } from "./imds"; import { RequestContextStorage } from "../../sinks/undici/RequestContextStorage"; @@ -17,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 = @@ -43,7 +47,8 @@ export function inspectDNSLookupCalls( module, agent, operation, - url + url, + stackTraceCallingLocation ), ] : [ @@ -54,7 +59,8 @@ export function inspectDNSLookupCalls( module, agent, operation, - url + url, + stackTraceCallingLocation ), ]; @@ -69,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( @@ -171,17 +178,21 @@ 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: { - hostname: hostname, - }, + metadata: getMetadataForSSRFAttack({ hostname, port }), request: context, payload: found.payload, }); 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; } 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/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();