diff --git a/end2end/server/src/handlers/updateConfig.js b/end2end/server/src/handlers/updateConfig.js index cf18be78e..d97475942 100644 --- a/end2end/server/src/handlers/updateConfig.js +++ b/end2end/server/src/handlers/updateConfig.js @@ -1,4 +1,5 @@ -const { updateAppConfig, getAppConfig } = require("../zen/config"); +const { updateAppConfig } = require("../zen/config"); + module.exports = function updateConfig(req, res) { if (!req.app) { throw new Error("App is missing"); diff --git a/end2end/server/src/zen/config.js b/end2end/server/src/zen/config.js index 53fe7e9a2..1eda00e82 100644 --- a/end2end/server/src/zen/config.js +++ b/end2end/server/src/zen/config.js @@ -10,6 +10,8 @@ function generateConfig(app) { blockedUserIds: [], allowedIPAddresses: [], receivedAnyStats: false, + blockNewOutgoingRequests: false, + domains: [], }; } diff --git a/end2end/tests-new/hono-pg-esm-outbound.test.mjs b/end2end/tests-new/hono-pg-esm-outbound.test.mjs new file mode 100644 index 000000000..c63ad5556 --- /dev/null +++ b/end2end/tests-new/hono-pg-esm-outbound.test.mjs @@ -0,0 +1,207 @@ +import { spawn } from "child_process"; +import { resolve } from "path"; +import { test } from "node:test"; +import { equal, match, fail } from "node:assert"; +import { getRandomPort } from "./utils/get-port.mjs"; +import { timeout } from "./utils/timeout.mjs"; + +const pathToAppDir = resolve( + import.meta.dirname, + "../../sample-apps/hono-pg-esm" +); +const testServerUrl = "http://localhost:5874"; + +const blockedUrl = "https://ssrf-redirects.testssandbox.com/"; +const allowedUrl = "https://aikido.dev/"; +const unknownUrl = "https://google.com/"; + +test("blockNewOutgoingRequests is false", async () => { + const port = await getRandomPort(); + + const response = await fetch(`${testServerUrl}/api/runtime/apps`, { + method: "POST", + }); + const body = await response.json(); + const token = body.token; + + const config = await fetch(`${testServerUrl}/api/runtime/config`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: token, + }, + body: JSON.stringify({ + allowedIPAddresses: ["1.2.3.4"], + blockNewOutgoingRequests: false, + domains: [ + { hostname: "ssrf-redirects.testssandbox.com", mode: "block" }, + { hostname: "aikido.dev", mode: "allow" }, + // Otherwise we cannot communicate with the mock server + { hostname: "localhost", mode: "allow" }, + ], + }), + }); + equal(config.status, 200); + + const server = spawn( + `node`, + ["--require", "@aikidosec/firewall/instrument", "./app.js", port], + { + cwd: pathToAppDir, + env: { + ...process.env, + AIKIDO_DEBUG: "true", + AIKIDO_BLOCK: "true", + AIKIDO_TOKEN: token, + AIKIDO_ENDPOINT: testServerUrl, + AIKIDO_REALTIME_ENDPOINT: testServerUrl, + }, + } + ); + + server.on("error", (err) => { + fail(err); + }); + + let stderr = ""; + server.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + try { + await timeout(2000); + + // Blocks request to blocked domain + const blocked = await fetch( + `http://127.0.0.1:${port}/fetch?url=${encodeURIComponent(blockedUrl)}`, + { signal: AbortSignal.timeout(5000) } + ); + equal(blocked.status, 500); + match(stderr, /Zen has blocked an outbound connection/); + + // Allows request to allowed domain + const allowed = await fetch( + `http://127.0.0.1:${port}/fetch?url=${encodeURIComponent(allowedUrl)}`, + { signal: AbortSignal.timeout(5000) } + ); + equal(allowed.status, 200); + equal((await allowed.json()).success, true); + + // Allows request to unknown domain + const unknown = await fetch( + `http://127.0.0.1:${port}/fetch?url=${encodeURIComponent(unknownUrl)}`, + { signal: AbortSignal.timeout(5000) } + ); + equal(unknown.status, 200); + equal((await unknown.json()).success, true); + + // Allows blocked domain from bypass IP + stderr = ""; + const bypass = await fetch( + `http://127.0.0.1:${port}/fetch?url=${encodeURIComponent(blockedUrl)}`, + { + headers: { "X-Forwarded-For": "1.2.3.4" }, + signal: AbortSignal.timeout(5000), + } + ); + equal(bypass.status, 200); + equal((await bypass.json()).success, true); + } finally { + server.kill(); + } +}); + +test("blockNewOutgoingRequests is true", async () => { + const port = await getRandomPort(); + + const response = await fetch(`${testServerUrl}/api/runtime/apps`, { + method: "POST", + }); + const body = await response.json(); + const token = body.token; + + const config = await fetch(`${testServerUrl}/api/runtime/config`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: token, + }, + body: JSON.stringify({ + allowedIPAddresses: ["1.2.3.4"], + blockNewOutgoingRequests: true, + domains: [ + { hostname: "ssrf-redirects.testssandbox.com", mode: "block" }, + { hostname: "aikido.dev", mode: "allow" }, + ], + }), + }); + equal(config.status, 200); + + const server = spawn( + `node`, + ["--require", "@aikidosec/firewall/instrument", "./app.js", port], + { + cwd: pathToAppDir, + env: { + ...process.env, + AIKIDO_DEBUG: "true", + AIKIDO_BLOCK: "true", + AIKIDO_TOKEN: token, + AIKIDO_ENDPOINT: testServerUrl, + AIKIDO_REALTIME_ENDPOINT: testServerUrl, + }, + } + ); + + server.on("error", (err) => { + fail(err); + }); + + let stderr = ""; + server.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + try { + await timeout(2000); + + // Blocks request to blocked domain + const blocked = await fetch( + `http://127.0.0.1:${port}/fetch?url=${encodeURIComponent(blockedUrl)}`, + { signal: AbortSignal.timeout(5000) } + ); + equal(blocked.status, 500); + match(stderr, /Zen has blocked an outbound connection/); + + // Allows request to allowed domain + const allowed = await fetch( + `http://127.0.0.1:${port}/fetch?url=${encodeURIComponent(allowedUrl)}`, + { signal: AbortSignal.timeout(5000) } + ); + equal(allowed.status, 200); + equal((await allowed.json()).success, true); + + // Blocks request to unknown domain + stderr = ""; + const unknown = await fetch( + `http://127.0.0.1:${port}/fetch?url=${encodeURIComponent(unknownUrl)}`, + { signal: AbortSignal.timeout(5000) } + ); + equal(unknown.status, 500); + match(stderr, /Zen has blocked an outbound connection/); + + // Allows unknown domain from bypass IP + stderr = ""; + const bypass = await fetch( + `http://127.0.0.1:${port}/fetch?url=${encodeURIComponent(unknownUrl)}`, + { + headers: { "X-Forwarded-For": "1.2.3.4" }, + signal: AbortSignal.timeout(5000), + } + ); + equal(bypass.status, 200); + equal((await bypass.json()).success, true); + } finally { + server.kill(); + } +}); diff --git a/end2end/tests/hono-xml-outbound.test.js b/end2end/tests/hono-xml-outbound.test.js new file mode 100644 index 000000000..400350bc5 --- /dev/null +++ b/end2end/tests/hono-xml-outbound.test.js @@ -0,0 +1,212 @@ +const t = require("tap"); +const { spawn } = require("child_process"); +const { resolve } = require("path"); +const timeout = require("../timeout"); + +const pathToApp = resolve(__dirname, "../../sample-apps/hono-xml", "app.js"); +const testServerUrl = "http://localhost:5874"; + +const blockedUrl = "https://ssrf-redirects.testssandbox.com/"; +const allowedUrl = "https://aikido.dev/"; +const unknownUrl = "https://google.com/"; + +let token; + +t.test("blockNewOutgoingRequests is false", (t) => { + fetch(`${testServerUrl}/api/runtime/apps`, { + method: "POST", + }) + .then((response) => response.json()) + .then((body) => { + token = body.token; + + return fetch(`${testServerUrl}/api/runtime/config`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: token, + }, + body: JSON.stringify({ + allowedIPAddresses: ["1.2.3.4"], + blockNewOutgoingRequests: false, + domains: [ + { hostname: "ssrf-redirects.testssandbox.com", mode: "block" }, + { hostname: "aikido.dev", mode: "allow" }, + // Otherwise we cannot communicate with the mock server + { hostname: "localhost", mode: "allow" }, + ], + }), + }); + }) + .then((config) => { + t.same(config.status, 200); + + const server = spawn(`node`, [pathToApp, "4010"], { + env: { + ...process.env, + AIKIDO_DEBUG: "true", + AIKIDO_BLOCKING: "true", + AIKIDO_TOKEN: token, + AIKIDO_ENDPOINT: testServerUrl, + AIKIDO_REALTIME_ENDPOINT: testServerUrl, + }, + }); + + server.on("close", () => { + t.end(); + }); + + server.on("error", (err) => { + t.fail(err); + }); + + let stderr = ""; + server.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + timeout(2000) + .then(async () => { + // Blocks request to blocked domain + const blocked = await fetch( + `http://127.0.0.1:4010/fetch?url=${encodeURIComponent(blockedUrl)}`, + { signal: AbortSignal.timeout(5000) } + ); + t.same(blocked.status, 500); + t.match(stderr, /Zen has blocked an outbound connection/); + + // Allows request to allowed domain + const allowed = await fetch( + `http://127.0.0.1:4010/fetch?url=${encodeURIComponent(allowedUrl)}`, + { signal: AbortSignal.timeout(5000) } + ); + t.same(allowed.status, 200); + t.same((await allowed.json()).success, true); + + // Allows request to unknown domain + const unknown = await fetch( + `http://127.0.0.1:4010/fetch?url=${encodeURIComponent(unknownUrl)}`, + { signal: AbortSignal.timeout(5000) } + ); + t.same(unknown.status, 200); + t.same((await unknown.json()).success, true); + + // Allows blocked domain from bypass IP + stderr = ""; + const bypass = await fetch( + `http://127.0.0.1:4010/fetch?url=${encodeURIComponent(blockedUrl)}`, + { + headers: { "X-Forwarded-For": "1.2.3.4" }, + signal: AbortSignal.timeout(5000), + } + ); + t.same(bypass.status, 200); + t.same((await bypass.json()).success, true); + }) + .catch((error) => { + t.fail(error); + }) + .finally(() => { + server.kill(); + }); + }); +}); + +t.test("blockNewOutgoingRequests is true", (t) => { + fetch(`${testServerUrl}/api/runtime/apps`, { + method: "POST", + }) + .then((response) => response.json()) + .then((body) => { + token = body.token; + + return fetch(`${testServerUrl}/api/runtime/config`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: token, + }, + body: JSON.stringify({ + allowedIPAddresses: ["1.2.3.4"], + blockNewOutgoingRequests: true, + domains: [ + { hostname: "ssrf-redirects.testssandbox.com", mode: "block" }, + { hostname: "aikido.dev", mode: "allow" }, + ], + }), + }); + }) + .then((config) => { + t.same(config.status, 200); + + const server = spawn(`node`, [pathToApp, "4020"], { + env: { + ...process.env, + AIKIDO_DEBUG: "true", + AIKIDO_BLOCKING: "true", + AIKIDO_TOKEN: token, + AIKIDO_ENDPOINT: testServerUrl, + AIKIDO_REALTIME_ENDPOINT: testServerUrl, + }, + }); + + server.on("close", () => { + t.end(); + }); + + server.on("error", (err) => { + t.fail(err); + }); + + let stderr = ""; + server.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + timeout(2000) + .then(async () => { + // Blocks request to blocked domain + const blocked = await fetch( + `http://127.0.0.1:4020/fetch?url=${encodeURIComponent(blockedUrl)}`, + { signal: AbortSignal.timeout(5000) } + ); + t.same(blocked.status, 500); + t.match(stderr, /Zen has blocked an outbound connection/); + + // Allows request to allowed domain + const allowed = await fetch( + `http://127.0.0.1:4020/fetch?url=${encodeURIComponent(allowedUrl)}`, + { signal: AbortSignal.timeout(5000) } + ); + t.same(allowed.status, 200); + t.same((await allowed.json()).success, true); + + // Blocks request to unknown domain + stderr = ""; + const unknown = await fetch( + `http://127.0.0.1:4020/fetch?url=${encodeURIComponent(unknownUrl)}`, + { signal: AbortSignal.timeout(5000) } + ); + t.same(unknown.status, 500); + t.match(stderr, /Zen has blocked an outbound connection/); + + // Allows unknown domain from bypass IP + stderr = ""; + const bypass = await fetch( + `http://127.0.0.1:4020/fetch?url=${encodeURIComponent(unknownUrl)}`, + { + headers: { "X-Forwarded-For": "1.2.3.4" }, + signal: AbortSignal.timeout(5000), + } + ); + t.same(bypass.status, 200); + t.same((await bypass.json()).success, true); + }) + .catch((error) => { + t.fail(error); + }) + .finally(() => { + server.kill(); + }); + }); +}); diff --git a/library/agent/Agent.test.ts b/library/agent/Agent.test.ts index 446c6f97b..eb4b9eb51 100644 --- a/library/agent/Agent.test.ts +++ b/library/agent/Agent.test.ts @@ -1,7 +1,6 @@ import * as FakeTimers from "@sinonjs/fake-timers"; import { hostname, platform, release } from "os"; import * as t from "tap"; -import * as fetch from "../helpers/fetch"; import { getSemverNodeVersion } from "../helpers/getNodeVersion"; import { ip } from "../helpers/ipAddress"; import { wrap } from "../helpers/wrap"; @@ -19,7 +18,6 @@ import { Context } from "./Context"; import { createTestAgent } from "../helpers/createTestAgent"; import { setTimeout } from "node:timers/promises"; import { FetchListsAPIForTesting } from "./api/FetchListsAPIForTesting"; -import { FetchListsAPINodeHTTP } from "./api/FetchListsAPINodeHTTP"; const mockedFetchListAPI = new FetchListsAPIForTesting({ blockedIPAddresses: [ @@ -422,6 +420,7 @@ t.test( allowedIPAddresses: [], block: true, receivedAnyStats: false, + blockNewOutgoingRequests: false, }); const agent = createTestAgent({ api, @@ -1286,3 +1285,78 @@ t.test("attack wave detected event", async (t) => { }, ]); }); + +t.test("it blocks new outgoing requests if config says so", async () => { + const clock = FakeTimers.install(); + + const logger = new LoggerNoop(); + const api = new ReportingAPIForTesting({ + success: true, + endpoints: [], + configUpdatedAt: 0, + heartbeatIntervalInMS: 10 * 60 * 1000, + blockedUserIds: [], + allowedIPAddresses: [], + block: true, + receivedAnyStats: false, + blockNewOutgoingRequests: true, + domains: [ + { hostname: "example.com", mode: "block" }, + { hostname: "aikido.dev", mode: "allow" }, + ], + }); + const agent = createTestAgent({ + api, + logger, + token: new Token("123"), + suppressConsoleLog: false, + }); + agent.start([]); + + await agent.flushStats(1000); + + t.same(agent.getConfig().shouldBlockOutgoingRequest("foo.bar"), true); + t.same(agent.getConfig().shouldBlockOutgoingRequest("example.com"), true); + t.same(agent.getConfig().shouldBlockOutgoingRequest("aikido.dev"), false); + + clock.uninstall(); +}); + +t.test( + "it does not block new outgoing requests if config says so", + async () => { + const clock = FakeTimers.install(); + + const logger = new LoggerNoop(); + const api = new ReportingAPIForTesting({ + success: true, + endpoints: [], + configUpdatedAt: 0, + heartbeatIntervalInMS: 10 * 60 * 1000, + blockedUserIds: [], + allowedIPAddresses: [], + block: true, + receivedAnyStats: false, + blockNewOutgoingRequests: false, + domains: [ + { hostname: "example.com", mode: "block" }, + { hostname: "aikido.dev", mode: "allow" }, + ], + }); + const agent = createTestAgent({ + api, + logger, + token: new Token("123"), + suppressConsoleLog: false, + }); + agent.start([]); + + await agent.flushStats(1000); + + t.same(agent.getConfig().shouldBlockOutgoingRequest("foo.bar"), false); + t.same(agent.getConfig().shouldBlockOutgoingRequest("example.com"), true); + t.same(agent.getConfig().shouldBlockOutgoingRequest("aikido.dev"), false); + + clock.uninstall(); + } +); diff --git a/library/agent/Agent.ts b/library/agent/Agent.ts index a5165a2a6..f14683596 100644 --- a/library/agent/Agent.ts +++ b/library/agent/Agent.ts @@ -325,6 +325,17 @@ export class Agent { ) { this.sendHeartbeatEveryMS = response.heartbeatIntervalInMS; } + + if ( + typeof response.blockNewOutgoingRequests === "boolean" && + response.domains && + Array.isArray(response.domains) + ) { + this.serviceConfig.setBlockNewOutgoingRequests( + response.blockNewOutgoingRequests + ); + this.serviceConfig.updateDomains(response.domains); + } } } diff --git a/library/agent/Config.ts b/library/agent/Config.ts index 7816ed6c6..e70dbb3d1 100644 --- a/library/agent/Config.ts +++ b/library/agent/Config.ts @@ -20,6 +20,8 @@ export type Endpoint = Omit & { allowedIPAddresses: IPMatcher | undefined; }; +export type Domain = { hostname: string; mode: "allow" | "block" }; + export type Config = { endpoints: EndpointConfig[]; heartbeatIntervalInMS: number; @@ -28,4 +30,6 @@ export type Config = { allowedIPAddresses: string[]; block?: boolean; receivedAnyStats?: boolean; + blockNewOutgoingRequests?: boolean; + domains?: Domain[]; }; diff --git a/library/agent/ServiceConfig.test.ts b/library/agent/ServiceConfig.test.ts index 695f7a492..4a2a0624d 100644 --- a/library/agent/ServiceConfig.test.ts +++ b/library/agent/ServiceConfig.test.ts @@ -406,3 +406,34 @@ t.test( t.same(config.getMatchingUserAgentKeys("googlebot"), []); } ); + +t.test("outbound request blocking", async (t) => { + const config = new ServiceConfig([], 0, [], [], false, [], []); + + t.same(config.shouldBlockOutgoingRequest("example.com"), false); + + config.setBlockNewOutgoingRequests(true); + t.same(config.shouldBlockOutgoingRequest("example.com"), true); + + config.updateDomains([ + { hostname: "example.com", mode: "allow" }, + { hostname: "aikido.dev", mode: "block" }, + ]); + t.same(config.shouldBlockOutgoingRequest("example.com"), false); + t.same(config.shouldBlockOutgoingRequest("aikido.dev"), true); + t.same(config.shouldBlockOutgoingRequest("unknown.com"), true); + + config.updateDomains([ + { hostname: "example.com", mode: "block" }, + { hostname: "aikido.dev", mode: "allow" }, + ]); + t.same(config.shouldBlockOutgoingRequest("example.com"), true); + t.same(config.shouldBlockOutgoingRequest("aikido.dev"), false); + t.same(config.shouldBlockOutgoingRequest("unknown.com"), true); + + config.setBlockNewOutgoingRequests(false); + + t.same(config.shouldBlockOutgoingRequest("example.com"), true); + t.same(config.shouldBlockOutgoingRequest("aikido.dev"), false); + t.same(config.shouldBlockOutgoingRequest("unknown.com"), false); +}); diff --git a/library/agent/ServiceConfig.ts b/library/agent/ServiceConfig.ts index a813af021..ea3125dd7 100644 --- a/library/agent/ServiceConfig.ts +++ b/library/agent/ServiceConfig.ts @@ -1,8 +1,8 @@ import { IPMatcher } from "../helpers/ip-matcher/IPMatcher"; import { LimitedContext, matchEndpoints } from "../helpers/matchEndpoints"; import { isPrivateIP } from "../vulnerabilities/ssrf/isPrivateIP"; -import type { Endpoint, EndpointConfig } from "./Config"; -import { IPList, UserAgentDetails } from "./api/FetchListsAPI"; +import type { Endpoint, EndpointConfig, Domain } from "./Config"; +import type { IPList, UserAgentDetails } from "./api/FetchListsAPI"; import { safeCreateRegExp } from "./safeCreateRegExp"; export class ServiceConfig { @@ -27,6 +27,9 @@ export class ServiceConfig { private monitoredUserAgentRegex: RegExp | undefined; private userAgentDetails: { pattern: RegExp; key: string }[] = []; + private blockNewOutgoingRequests = false; + private domains = new Map(); + constructor( endpoints: EndpointConfig[], private lastUpdatedAt: number, @@ -278,4 +281,25 @@ export class ServiceConfig { hasReceivedAnyStats() { return this.receivedAnyStats; } + + setBlockNewOutgoingRequests(block: boolean) { + this.blockNewOutgoingRequests = block; + } + + updateDomains(domains: Domain[]) { + this.domains = new Map(domains.map((i) => [i.hostname, i.mode])); + } + + shouldBlockOutgoingRequest(hostname: string): boolean { + const mode = this.domains.get(hostname); + + if (this.blockNewOutgoingRequests) { + // Only allow outgoing requests if the mode is "allow" + // mode is undefined for unknown hostnames, so they get blocked + return mode !== "allow"; + } + + // Only block outgoing requests if the mode is "block" + return mode === "block"; + } } diff --git a/library/agent/api/ReportingAPIForTesting.ts b/library/agent/api/ReportingAPIForTesting.ts index 6456cd40d..de1692b85 100644 --- a/library/agent/api/ReportingAPIForTesting.ts +++ b/library/agent/api/ReportingAPIForTesting.ts @@ -14,6 +14,8 @@ export class ReportingAPIForTesting implements ReportingAPI { heartbeatIntervalInMS: 10 * 60 * 1000, blockedUserIds: [], allowedIPAddresses: [], + blockNewOutgoingRequests: false, + domains: [], } ) {} diff --git a/library/agent/api/ReportingAPIRateLimitedClientSide.test.ts b/library/agent/api/ReportingAPIRateLimitedClientSide.test.ts index 73a746571..10935ec5e 100644 --- a/library/agent/api/ReportingAPIRateLimitedClientSide.test.ts +++ b/library/agent/api/ReportingAPIRateLimitedClientSide.test.ts @@ -278,6 +278,8 @@ t.test("it does not blow memory", async () => { heartbeatIntervalInMS: 10 * 60 * 1000, blockedUserIds: [], allowedIPAddresses: [], + blockNewOutgoingRequests: false, + domains: [], }); } diff --git a/library/agent/api/ReportingAPIRateLimitedServerSide.test.ts b/library/agent/api/ReportingAPIRateLimitedServerSide.test.ts index 60f307d82..f9d804460 100644 --- a/library/agent/api/ReportingAPIRateLimitedServerSide.test.ts +++ b/library/agent/api/ReportingAPIRateLimitedServerSide.test.ts @@ -49,6 +49,8 @@ t.test("it stops sending requests if rate limited", async (t) => { configUpdatedAt: 0, blockedUserIds: [], allowedIPAddresses: [], + blockNewOutgoingRequests: false, + domains: [], }); t.match(api.getEvents(), [{ type: "started" }]); diff --git a/library/agent/api/ReportingAPIThatValidatesToken.test.ts b/library/agent/api/ReportingAPIThatValidatesToken.test.ts index c272c86a2..03ea88eb6 100644 --- a/library/agent/api/ReportingAPIThatValidatesToken.test.ts +++ b/library/agent/api/ReportingAPIThatValidatesToken.test.ts @@ -47,6 +47,8 @@ t.test("it ignores valid tokens", async () => { heartbeatIntervalInMS: 10 * 60 * 1000, blockedUserIds: [], allowedIPAddresses: [], + blockNewOutgoingRequests: false, + domains: [], }); t.same(api.getEvents(), [event]); @@ -57,6 +59,8 @@ t.test("it ignores valid tokens", async () => { heartbeatIntervalInMS: 10 * 60 * 1000, blockedUserIds: [], allowedIPAddresses: [], + blockNewOutgoingRequests: false, + domains: [], }); t.same(api.getEvents(), [event, event]); }); diff --git a/library/agent/hooks/InterceptorResult.ts b/library/agent/hooks/InterceptorResult.ts index a5f189f87..c6d4e21ec 100644 --- a/library/agent/hooks/InterceptorResult.ts +++ b/library/agent/hooks/InterceptorResult.ts @@ -1,11 +1,34 @@ +import { isPlainObject } from "../../helpers/isPlainObject"; import { Kind } from "../Attack"; import { Source } from "../Source"; -export type InterceptorResult = { +export type BlockOutboundConnectionResult = { + operation: string; + hostname: string; +}; + +export type AttackResult = { operation: string; kind: Kind; source: Source; pathsToPayload: string[]; metadata: Record; payload: unknown; -} | void; +}; + +export type InterceptorResult = + | AttackResult + | BlockOutboundConnectionResult + | void; + +export function isBlockOutboundConnectionResult( + result: InterceptorResult +): result is BlockOutboundConnectionResult { + return isPlainObject(result) && "hostname" in result; +} + +export function isAttackResult( + result: InterceptorResult +): result is AttackResult { + return isPlainObject(result) && "kind" in result; +} diff --git a/library/agent/hooks/onInspectionInterceptorResult.ts b/library/agent/hooks/onInspectionInterceptorResult.ts index 8908d3a16..608417936 100644 --- a/library/agent/hooks/onInspectionInterceptorResult.ts +++ b/library/agent/hooks/onInspectionInterceptorResult.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines-per-function */ import { resolve } from "path"; import { cleanupStackTrace } from "../../helpers/cleanupStackTrace"; import { escapeHTML } from "../../helpers/escapeHTML"; @@ -5,7 +6,11 @@ import type { Agent } from "../Agent"; import { OperationKind } from "../api/Event"; import { attackKindHumanName } from "../Attack"; import { getContext, updateContext } from "../Context"; -import type { InterceptorResult } from "./InterceptorResult"; +import { + InterceptorResult, + isAttackResult, + isBlockOutboundConnectionResult, +} from "./InterceptorResult"; import type { PartialWrapPackageInfo } from "./WrapPackageInfo"; import { cleanError } from "../../helpers/cleanError"; @@ -39,7 +44,15 @@ export function onInspectionInterceptorResult( context.remoteAddress && agent.getConfig().isBypassedIP(context.remoteAddress); - if (result && context && !isBypassedIP) { + if (isBlockOutboundConnectionResult(result) && !isBypassedIP) { + throw cleanError( + new Error( + `Zen has blocked an outbound connection: ${result.operation}(...) to ${escapeHTML(result.hostname)}` + ) + ); + } + + if (isAttackResult(result) && context && !isBypassedIP) { // Flag request as having an attack detected updateContext(context, "attackDetected", true); diff --git a/library/sinks/Fetch.test.ts b/library/sinks/Fetch.test.ts index 850fd87bd..41963a423 100644 --- a/library/sinks/Fetch.test.ts +++ b/library/sinks/Fetch.test.ts @@ -567,5 +567,48 @@ t.test( } } ); + + agent.getHostnames().clear(); + agent.getConfig().updateDomains([ + { hostname: "aikido.dev", mode: "block" }, + { hostname: "app.aikido.dev", mode: "allow" }, + ]); + + const blockedError1 = await t.rejects(() => + fetch("https://aikido.dev/block") + ); + t.ok(blockedError1 instanceof Error); + if (blockedError1 instanceof Error) { + t.same( + blockedError1.message, + "Zen has blocked an outbound connection: fetch(...) to aikido.dev" + ); + } + + await fetch("https://app.aikido.dev"); + + t.same(agent.getHostnames().asArray(), [ + { hostname: "aikido.dev", port: 443, hits: 1 }, + { hostname: "app.aikido.dev", port: 443, hits: 1 }, + ]); + + agent.getConfig().setBlockNewOutgoingRequests(true); + + const blockedError2 = await t.rejects(() => fetch("https://example.com")); + t.ok(blockedError2 instanceof Error); + if (blockedError2 instanceof Error) { + t.same( + blockedError2.message, + "Zen has blocked an outbound connection: fetch(...) to example.com" + ); + } + + await fetch("https://app.aikido.dev"); + + t.same(agent.getHostnames().asArray(), [ + { hostname: "aikido.dev", port: 443, hits: 1 }, + { hostname: "app.aikido.dev", port: 443, hits: 2 }, + { hostname: "example.com", port: 443, hits: 1 }, + ]); } ); diff --git a/library/sinks/Fetch.ts b/library/sinks/Fetch.ts index 9931f8d28..5b69c89e9 100644 --- a/library/sinks/Fetch.ts +++ b/library/sinks/Fetch.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines-per-function */ import { lookup } from "dns"; import { Agent } from "../agent/Agent"; import { getContext } from "../agent/Context"; @@ -24,6 +23,14 @@ export class Fetch implements Wrapper { if (typeof port === "number" && port > 0) { agent.onConnectHostname(hostname, port); } + + if (agent.getConfig().shouldBlockOutgoingRequest(hostname)) { + return { + operation: "fetch", + hostname: hostname, + }; + } + const context = getContext(); if (!context) { diff --git a/library/sinks/HTTPRequest.test.ts b/library/sinks/HTTPRequest.test.ts index 58572a4b8..549f36115 100644 --- a/library/sinks/HTTPRequest.test.ts +++ b/library/sinks/HTTPRequest.test.ts @@ -376,6 +376,49 @@ t.test("it works", (t) => { } ); + agent.getHostnames().clear(); + agent.getConfig().updateDomains([ + { hostname: "aikido.dev", mode: "block" }, + { hostname: "app.aikido.dev", mode: "allow" }, + ]); + + const blockedError1 = t.throws(() => + https.request("https://aikido.dev/block") + ); + if (blockedError1 instanceof Error) { + t.same( + blockedError1.message, + "Zen has blocked an outbound connection: https.request(...) to aikido.dev" + ); + } + + const notBlocked1 = https.request("https://app.aikido.dev"); + notBlocked1.end(); + + t.same(agent.getHostnames().asArray(), [ + { hostname: "aikido.dev", port: 443, hits: 1 }, + { hostname: "app.aikido.dev", port: 443, hits: 1 }, + ]); + + agent.getConfig().setBlockNewOutgoingRequests(true); + + const blockedError2 = t.throws(() => https.request("https://example.com")); + if (blockedError2 instanceof Error) { + t.same( + blockedError2.message, + "Zen has blocked an outbound connection: https.request(...) to example.com" + ); + } + + const notBlocked2 = https.request("https://app.aikido.dev"); + notBlocked2.end(); + + t.same(agent.getHostnames().asArray(), [ + { hostname: "aikido.dev", port: 443, hits: 1 }, + { hostname: "app.aikido.dev", port: 443, hits: 2 }, + { hostname: "example.com", port: 443, hits: 1 }, + ]); + setTimeout(() => { t.end(); }, 3000); diff --git a/library/sinks/HTTPRequest.ts b/library/sinks/HTTPRequest.ts index 6ac39fe9e..e39b43b72 100644 --- a/library/sinks/HTTPRequest.ts +++ b/library/sinks/HTTPRequest.ts @@ -26,6 +26,14 @@ export class HTTPRequest implements Wrapper { if (typeof port === "number" && port > 0) { agent.onConnectHostname(url.hostname, port); } + + if (agent.getConfig().shouldBlockOutgoingRequest(url.hostname)) { + return { + hostname: url.hostname, + operation: `${module}.request`, + }; + } + const context = getContext(); if (!context) { diff --git a/library/sinks/Undici.tests.ts b/library/sinks/Undici.tests.ts index 4f2acac73..188d2631e 100644 --- a/library/sinks/Undici.tests.ts +++ b/library/sinks/Undici.tests.ts @@ -73,13 +73,21 @@ export async function createUndiciTests(undiciPkgName: string, port: number) { await request("https://ssrf-redirects.testssandbox.com"); t.same(agent.getHostnames().asArray(), [ - { hostname: "ssrf-redirects.testssandbox.com", port: 443, hits: 1 }, + { + hostname: "ssrf-redirects.testssandbox.com", + port: 443, + hits: 1, + }, ]); agent.getHostnames().clear(); await fetch("https://ssrf-redirects.testssandbox.com"); t.same(agent.getHostnames().asArray(), [ - { hostname: "ssrf-redirects.testssandbox.com", port: 443, hits: 1 }, + { + hostname: "ssrf-redirects.testssandbox.com", + port: 443, + hits: 1, + }, ]); agent.getHostnames().clear(); @@ -89,7 +97,11 @@ export async function createUndiciTests(undiciPkgName: string, port: number) { port: 443, }); t.same(agent.getHostnames().asArray(), [ - { hostname: "ssrf-redirects.testssandbox.com", port: 443, hits: 1 }, + { + hostname: "ssrf-redirects.testssandbox.com", + port: 443, + hits: 1, + }, ]); agent.getHostnames().clear(); @@ -99,7 +111,11 @@ export async function createUndiciTests(undiciPkgName: string, port: number) { port: 443, }); t.same(agent.getHostnames().asArray(), [ - { hostname: "ssrf-redirects.testssandbox.com", port: 443, hits: 1 }, + { + hostname: "ssrf-redirects.testssandbox.com", + port: 443, + hits: 1, + }, ]); agent.getHostnames().clear(); @@ -109,7 +125,11 @@ export async function createUndiciTests(undiciPkgName: string, port: number) { port: undefined, }); t.same(agent.getHostnames().asArray(), [ - { hostname: "ssrf-redirects.testssandbox.com", port: 443, hits: 1 }, + { + hostname: "ssrf-redirects.testssandbox.com", + port: 443, + hits: 1, + }, ]); agent.getHostnames().clear(); @@ -119,7 +139,11 @@ export async function createUndiciTests(undiciPkgName: string, port: number) { port: undefined, }); t.same(agent.getHostnames().asArray(), [ - { hostname: "ssrf-redirects.testssandbox.com", port: 80, hits: 1 }, + { + hostname: "ssrf-redirects.testssandbox.com", + port: 80, + hits: 1, + }, ]); agent.getHostnames().clear(); @@ -129,13 +153,21 @@ export async function createUndiciTests(undiciPkgName: string, port: number) { port: 443, }); t.same(agent.getHostnames().asArray(), [ - { hostname: "ssrf-redirects.testssandbox.com", port: 443, hits: 1 }, + { + hostname: "ssrf-redirects.testssandbox.com", + port: 443, + hits: 1, + }, ]); agent.getHostnames().clear(); await request(new URL("https://ssrf-redirects.testssandbox.com")); t.same(agent.getHostnames().asArray(), [ - { hostname: "ssrf-redirects.testssandbox.com", port: 443, hits: 1 }, + { + hostname: "ssrf-redirects.testssandbox.com", + port: 443, + hits: 1, + }, ]); agent.getHostnames().clear(); @@ -143,7 +175,11 @@ export async function createUndiciTests(undiciPkgName: string, port: number) { require("url").parse("https://ssrf-redirects.testssandbox.com") ); t.same(agent.getHostnames().asArray(), [ - { hostname: "ssrf-redirects.testssandbox.com", port: 443, hits: 1 }, + { + hostname: "ssrf-redirects.testssandbox.com", + port: 443, + hits: 1, + }, ]); agent.getHostnames().clear(); @@ -151,7 +187,11 @@ export async function createUndiciTests(undiciPkgName: string, port: number) { origin: "https://ssrf-redirects.testssandbox.com", } as URL); t.same(agent.getHostnames().asArray(), [ - { hostname: "ssrf-redirects.testssandbox.com", port: 443, hits: 1 }, + { + hostname: "ssrf-redirects.testssandbox.com", + port: 443, + hits: 1, + }, ]); agent.getHostnames().clear(); @@ -159,7 +199,11 @@ export async function createUndiciTests(undiciPkgName: string, port: number) { require("url").parse("https://ssrf-redirects.testssandbox.com") ); t.same(agent.getHostnames().asArray(), [ - { hostname: "ssrf-redirects.testssandbox.com", port: 443, hits: 1 }, + { + hostname: "ssrf-redirects.testssandbox.com", + port: 443, + hits: 1, + }, ]); agent.getHostnames().clear(); @@ -167,7 +211,11 @@ export async function createUndiciTests(undiciPkgName: string, port: number) { origin: "https://ssrf-redirects.testssandbox.com", } as URL); t.same(agent.getHostnames().asArray(), [ - { hostname: "ssrf-redirects.testssandbox.com", port: 443, hits: 1 }, + { + hostname: "ssrf-redirects.testssandbox.com", + port: 443, + hits: 1, + }, ]); agent.getHostnames().clear(); diff --git a/library/sinks/Undici.ts b/library/sinks/Undici.ts index 1c9fef3b6..8892297dc 100644 --- a/library/sinks/Undici.ts +++ b/library/sinks/Undici.ts @@ -35,6 +35,14 @@ export class Undici implements Wrapper { if (typeof port === "number" && port > 0) { agent.onConnectHostname(hostname, port); } + + if (agent.getConfig().shouldBlockOutgoingRequest(hostname)) { + return { + operation: `undici.${method}`, + hostname: hostname, + }; + } + const context = getContext(); if (!context) { diff --git a/sample-apps/hono-pg-esm/app.js b/sample-apps/hono-pg-esm/app.js index d9d2ce383..30983c67c 100644 --- a/sample-apps/hono-pg-esm/app.js +++ b/sample-apps/hono-pg-esm/app.js @@ -37,6 +37,16 @@ app.get("/clear", async (c) => { return c.text("Table cleared"); }); +app.get("/fetch", async (c) => { + const url = c.req.query("url"); + if (!url) { + return c.json({ error: "url query param is required" }, 400); + } + const response = await fetch(url); + const text = await response.text(); + return c.json({ success: true, status: response.status, body: text }); +}); + function getPort() { const port = parseInt(process.argv[2], 10) || 4000; diff --git a/sample-apps/hono-xml/app.js b/sample-apps/hono-xml/app.js index 1f4cb369d..a1dc9d5a5 100644 --- a/sample-apps/hono-xml/app.js +++ b/sample-apps/hono-xml/app.js @@ -162,6 +162,16 @@ async function main() { } }); + app.get("/fetch", async (c) => { + const url = c.req.query("url"); + if (!url) { + return c.json({ error: "url query param is required" }, 400); + } + const response = await fetch(url); + const text = await response.text(); + return c.json({ success: true, status: response.status, body: text }); + }); + return app; }