diff --git a/library/agent/Agent.test.ts b/library/agent/Agent.test.ts index f733361c5..5b45d0b98 100644 --- a/library/agent/Agent.test.ts +++ b/library/agent/Agent.test.ts @@ -214,7 +214,7 @@ t.test("when attack detected", async () => { operation: "operation", payload: "payload", stack: "stack", - path: ".nested", + paths: [".nested"], metadata: { db: "app", }, @@ -277,7 +277,7 @@ t.test("it checks if user agent is a string", async () => { payload: "payload", operation: "operation", stack: "stack", - path: ".nested", + paths: [".nested"], metadata: { db: "app", }, @@ -554,7 +554,7 @@ t.test("it logs when failed to report event", async () => { }, operation: "operation", stack: "stack", - path: ".nested", + paths: [".nested"], payload: "payload", metadata: { db: "app", @@ -636,7 +636,7 @@ t.test("when payload is object", async () => { operation: "operation", payload: { $gt: "" }, stack: "stack", - path: ".nested", + paths: [".nested"], metadata: { db: "app", }, @@ -664,7 +664,7 @@ t.test("when payload is object", async () => { operation: "operation", payload: "a".repeat(20000), stack: "stack", - path: ".nested", + paths: [".nested"], metadata: { db: "app", }, diff --git a/library/agent/Agent.ts b/library/agent/Agent.ts index 9640bd81d..bed952ae7 100644 --- a/library/agent/Agent.ts +++ b/library/agent/Agent.ts @@ -142,7 +142,7 @@ export class Agent { source, request, stack, - path, + paths, metadata, payload, }: { @@ -153,7 +153,7 @@ export class Agent { source: Source; request: Context; stack: string; - path: string; + paths: string[]; metadata: Record; payload: unknown; }) { @@ -168,7 +168,7 @@ export class Agent { module: module, operation: operation, blocked: blocked, - path: path, + path: paths.join(), stack: stack, source: source, metadata: limitLengthMetadata(metadata, 4096), diff --git a/library/agent/Context.test.ts b/library/agent/Context.test.ts index d35aecc5c..629fee4d8 100644 --- a/library/agent/Context.test.ts +++ b/library/agent/Context.test.ts @@ -110,24 +110,24 @@ t.test("it clears cache when context is mutated", async (t) => { t.same(extractStringsFromUserInputCached(getContext()!, "body"), undefined); t.same( extractStringsFromUserInputCached(getContext()!, "query"), - new Map(Object.entries({ abc: ".", def: ".abc" })) + new Set(["abc", "def"]) ); updateContext(getContext()!, "query", {}); t.same(extractStringsFromUserInputCached(getContext()!, "body"), undefined); t.same( extractStringsFromUserInputCached(getContext()!, "query"), - new Map(Object.entries({})) + new Set() ); runWithContext({ ...context, body: { a: "z" }, query: { b: "y" } }, () => { t.same( extractStringsFromUserInputCached(getContext()!, "body"), - new Map(Object.entries({ a: ".", z: ".a" })) + new Set(["a", "z"]) ); t.same( extractStringsFromUserInputCached(getContext()!, "query"), - new Map(Object.entries({ b: ".", y: ".b" })) + new Set(["b", "y"]) ); }); }); diff --git a/library/agent/applyHooks.test.ts b/library/agent/applyHooks.test.ts index d4ca8dd44..5c3ca692a 100644 --- a/library/agent/applyHooks.test.ts +++ b/library/agent/applyHooks.test.ts @@ -161,7 +161,7 @@ t.test("it does not report attack if IP is allowed", async (t) => { return { operation: "os.hostname", source: "body", - pathToPayload: "path", + pathsToPayload: ["path"], payload: "payload", metadata: {}, kind: "path_traversal", diff --git a/library/agent/hooks/InterceptorResult.ts b/library/agent/hooks/InterceptorResult.ts index 99ca0f9e6..a5f189f87 100644 --- a/library/agent/hooks/InterceptorResult.ts +++ b/library/agent/hooks/InterceptorResult.ts @@ -5,7 +5,7 @@ export type InterceptorResult = { operation: string; kind: Kind; source: Source; - pathToPayload: string; + pathsToPayload: string[]; metadata: Record; payload: unknown; } | void; diff --git a/library/agent/hooks/wrapExport.ts b/library/agent/hooks/wrapExport.ts index 4c9624975..385d20c3c 100644 --- a/library/agent/hooks/wrapExport.ts +++ b/library/agent/hooks/wrapExport.ts @@ -180,7 +180,7 @@ function inspectArgs( source: result.source, blocked: agent.shouldBlock(), stack: cleanupStackTrace(new Error().stack!, libraryRoot), - path: result.pathToPayload, + paths: result.pathsToPayload, metadata: result.metadata, request: context, payload: result.payload, @@ -188,7 +188,7 @@ function inspectArgs( if (agent.shouldBlock()) { throw new Error( - `Zen has blocked ${attackKindHumanName(result.kind)}: ${result.operation}(...) originating from ${result.source}${escapeHTML(result.pathToPayload)}` + `Zen has blocked ${attackKindHumanName(result.kind)}: ${result.operation}(...) originating from ${result.source}${escapeHTML(result.pathsToPayload.join())}` ); } } diff --git a/library/helpers/attackPath.test.ts b/library/helpers/attackPath.test.ts new file mode 100644 index 000000000..c63d6fb93 --- /dev/null +++ b/library/helpers/attackPath.test.ts @@ -0,0 +1,59 @@ +import * as t from "tap"; +import { getPathsToPayload as get } from "./attackPath"; + +t.test("it gets paths to payload", async (t) => { + const testObj1 = { + a: { + b: { + c: "payload", + }, + }, + d: [12, "test", "payload"], + }; + + t.same(get("payload", testObj1), [".a.b.c", ".d.[2]"]); + t.same(get("test", testObj1), [".d.[1]"]); + t.same(get("notfound", testObj1), []); + + t.same(get("payload", "payload"), ["."]); + t.same(get("test", "payload"), []); + + t.same(get("", undefined), []); + t.same(get("", null), []); + t.same( + get("", () => {}), + [] + ); + + t.same( + get("string", [ + "string", + 1, + true, + null, + undefined, + { test: "test" }, + "string", + ]), + [".[0]", ".[6]"] + ); + + // Concatenates array values + t.same(get("test,test2", ["test", "test2"]), ["."]); + t.same(get("test,test2", { test: { x: ["test", "test2"] } }), [".test.x"]); +}); + +t.test("it works with jwt", async (t) => { + const testObj2 = { + a: { + x: ["test", "notfoundx"], + b: { + c: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.uG3R-hTeSn-DUddaexdXHw4pvXwKdyxqsD2k0BZrbd4", + }, + }, + }; + + t.same(get("John Doe", testObj2), [".a.b.c.name"]); + t.same(get("1234567890", testObj2), [".a.b.c.sub"]); + t.same(get("notfound", testObj2), []); +}); diff --git a/library/helpers/attackPath.ts b/library/helpers/attackPath.ts index a7c303433..736a7a0a1 100644 --- a/library/helpers/attackPath.ts +++ b/library/helpers/attackPath.ts @@ -1,3 +1,6 @@ +import { isPlainObject } from "./isPlainObject"; +import { tryDecodeAsJWT } from "./tryDecodeAsJWT"; + export type PathPart = | { type: "jwt" } | { type: "object"; key: string } @@ -24,3 +27,53 @@ export function buildPathToPayload(pathToPayload: PathPart[]): string { return acc; }, ""); } + +export function getPathsToPayload( + attackPayload: string, + obj: unknown +): string[] { + const matches: string[] = []; + + const attackPayloadLowercase = attackPayload.toLowerCase(); + + const traverse = (value: unknown, path: PathPart[] = []) => { + // Handle strings + if (typeof value === "string") { + if (value.toLowerCase() === attackPayloadLowercase) { + matches.push(buildPathToPayload(path)); + return; + } + + const jwt = tryDecodeAsJWT(value); + if (jwt.jwt) { + traverse(jwt.object, path.concat({ type: "jwt" })); + } + + return; + } + + if (Array.isArray(value)) { + // Handle arrays + value.forEach((item, index) => { + traverse(item, path.concat({ type: "array", index })); + }); + + if (value.join().toLowerCase() === attackPayloadLowercase) { + matches.push(buildPathToPayload(path)); + } + + return; + } + + if (isPlainObject(value)) { + // Handle objects + for (const key in value) { + traverse(value[key], path.concat({ type: "object", key })); + } + } + }; + + traverse(obj); + + return matches; +} diff --git a/library/helpers/extractStringsFromUserInput.test.ts b/library/helpers/extractStringsFromUserInput.test.ts index baa1df19a..7e10004ef 100644 --- a/library/helpers/extractStringsFromUserInput.test.ts +++ b/library/helpers/extractStringsFromUserInput.test.ts @@ -2,59 +2,47 @@ import * as t from "tap"; import { extractStringsFromUserInput } from "./extractStringsFromUserInput"; +function fromArr(arr: string[]): Set { + return new Set(arr); +} + t.test("empty object returns empty array", async () => { - t.same(extractStringsFromUserInput({}), fromObj({})); + t.same(extractStringsFromUserInput({}), fromArr([])); }); t.test("it can extract query objects", async () => { t.same( extractStringsFromUserInput({ age: { $gt: "21" } }), - fromObj({ - age: ".", - $gt: ".age", - "21": ".age.$gt", - }) + fromArr(["age", "$gt", "21"]) ); t.same( extractStringsFromUserInput({ title: { $ne: "null" } }), - fromObj({ - title: ".", - $ne: ".title", - null: ".title.$ne", - }) + fromArr(["title", "$ne", "null"]) ); t.same( extractStringsFromUserInput({ age: "whaat", user_input: ["whaat", "dangerous"], }), - fromObj({ - user_input: ".", - age: ".", - whaat: ".user_input.[0]", - dangerous: ".user_input.[1]", - "whaat,dangerous": ".user_input", - }) + fromArr([ + "age", + "whaat", + "user_input", + "whaat", + "dangerous", + "whaat,dangerous", + ]) ); }); t.test("it can extract cookie objects", async () => { t.same( extractStringsFromUserInput({ session: "ABC", session2: "DEF" }), - fromObj({ - session2: ".", - session: ".", - ABC: ".session", - DEF: ".session2", - }) + fromArr(["session", "ABC", "session2", "DEF"]) ); t.same( extractStringsFromUserInput({ session: "ABC", session2: 1234 }), - fromObj({ - session2: ".", - session: ".", - ABC: ".session", - }) + fromArr(["session", "ABC", "session2"]) ); }); @@ -63,51 +51,32 @@ t.test("it can extract header objects", async () => { extractStringsFromUserInput({ "Content-Type": "application/json", }), - fromObj({ - "Content-Type": ".", - "application/json": ".Content-Type", - }) + fromArr(["Content-Type", "application/json"]) ); t.same( extractStringsFromUserInput({ "Content-Type": 54321, }), - fromObj({ - "Content-Type": ".", - }) + fromArr(["Content-Type"]) ); t.same( extractStringsFromUserInput({ "Content-Type": "application/json", ExtraHeader: "value", }), - fromObj({ - "Content-Type": ".", - "application/json": ".Content-Type", - ExtraHeader: ".", - value: ".ExtraHeader", - }) + fromArr(["Content-Type", "application/json", "ExtraHeader", "value"]) ); }); t.test("it can extract body objects", async () => { t.same( extractStringsFromUserInput({ nested: { nested: { $ne: null } } }), - fromObj({ - nested: ".nested", - $ne: ".nested.nested", - }) + fromArr(["nested", "$ne"]) ); t.same( extractStringsFromUserInput({ age: { $gt: "21", $lt: "100" } }), - fromObj({ - age: ".", - $lt: ".age", - $gt: ".age", - "21": ".age.$gt", - "100": ".age.$lt", - }) + fromArr(["age", "$gt", "21", "$lt", "100"]) ); }); @@ -126,16 +95,17 @@ t.test("it decodes JWTs", async () => { token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcm5hbWUiOnsiJG5lIjpudWxsfSwiaWF0IjoxNTE2MjM5MDIyfQ._jhGJw9WzB6gHKPSozTFHDo9NOHs3CNOlvJ8rWy6VrQ", }), - fromObj({ - token: ".", - iat: ".token", - username: ".token", - sub: ".token", - "1234567890": ".token.sub", - $ne: ".token.username", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcm5hbWUiOnsiJG5lIjpudWxsfSwiaWF0IjoxNTE2MjM5MDIyfQ._jhGJw9WzB6gHKPSozTFHDo9NOHs3CNOlvJ8rWy6VrQ": - ".token", - }) + fromArr([ + "token", + "iat", + "username", + "sub", + "1234567890", + "$ne", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcm5hbWUiOnsiJG5lIjpudWxsfSwiaWF0IjoxNTE2MjM5MDIyfQ._jhGJw9WzB6gHKPSozTFHDo9NOHs3CNOlvJ8rWy6VrQ", + "username", + "iat", + ]) ); }); @@ -153,69 +123,51 @@ t.test("it ignores iss value of jwt", async () => { token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIn0.QLC0vl-A11a1WcUPD6vQR2PlUvRMsqpegddfQzPajQM", }), - fromObj({ - token: ".", - iat: ".token", - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIn0.QLC0vl-A11a1WcUPD6vQR2PlUvRMsqpegddfQzPajQM": - ".token", - sub: ".token", - "1234567890": ".token.sub", - name: ".token", - "John Doe": ".token.name", - }) + fromArr([ + "token", + "iat", + "sub", + "1234567890", + "name", + "John Doe", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJpc3MiOiJodHRwczovL2V4YW1wbGUuY29tIn0.QLC0vl-A11a1WcUPD6vQR2PlUvRMsqpegddfQzPajQM", + "iss", + ]) ); }); -function fromObj(obj: Record): Map { - return new Map(Object.entries(obj)); -} - t.test("it also adds the JWT itself as string", async () => { t.same( extractStringsFromUserInput({ header: "/;ping%20localhost;.e30=." }), - fromObj({ - header: ".", - "/;ping%20localhost;.e30=.": ".header", - }) + fromArr(["header", "/;ping%20localhost;.e30=."]) ); }); t.test("it concatenates array values", async () => { t.same( extractStringsFromUserInput({ arr: ["1", "2", "3"] }), - fromObj({ - arr: ".", - "1,2,3": ".arr", - "1": ".arr.[0]", - "2": ".arr.[1]", - "3": ".arr.[2]", - }) + fromArr(["arr", "1", "2", "3", "1,2,3"]) ); t.same( extractStringsFromUserInput({ arr: ["1", 2, true, null, undefined, { test: "test" }], }), - fromObj({ - arr: ".", - "1": ".arr.[0]", - test: ".arr.[5].test", - "1,2,true,,,[object Object]": ".arr", - }) + fromArr(["arr", "1", "test", "1,2,true,,,[object Object]"]) ); t.same( extractStringsFromUserInput({ arr: ["1", 2, true, null, undefined, { test: ["test123", "test345"] }], }), - fromObj({ - arr: ".", - "1": ".arr.[0]", - test: ".arr.[5]", - test123: ".arr.[5].test.[0]", - test345: ".arr.[5].test.[1]", - "test123,test345": ".arr.[5].test", - "1,2,true,,,[object Object]": ".arr", - }) + fromArr([ + "arr", + "1", + "test", + "test123", + "test345", + "test123,test345", + "1,2,true,,,[object Object]", + ]) ); }); diff --git a/library/helpers/extractStringsFromUserInput.ts b/library/helpers/extractStringsFromUserInput.ts index 97c0d9d49..7ab245a6c 100644 --- a/library/helpers/extractStringsFromUserInput.ts +++ b/library/helpers/extractStringsFromUserInput.ts @@ -1,56 +1,45 @@ -import { buildPathToPayload, PathPart } from "./attackPath"; import { isPlainObject } from "./isPlainObject"; import { tryDecodeAsJWT } from "./tryDecodeAsJWT"; type UserString = string; -type PathToUserString = string; // eslint-disable-next-line max-lines-per-function -export function extractStringsFromUserInput( - obj: unknown, - pathToPayload: PathPart[] = [] -): Map { - const results: Map = new Map(); +export function extractStringsFromUserInput(obj: unknown): Set { + const results: Set = new Set(); if (isPlainObject(obj)) { for (const key in obj) { - results.set(key, buildPathToPayload(pathToPayload)); - extractStringsFromUserInput( - obj[key], - pathToPayload.concat([{ type: "object", key: key }]) - ).forEach((value, key) => { - results.set(key, value); + results.add(key); + extractStringsFromUserInput(obj[key]).forEach((value) => { + results.add(value); }); } } if (Array.isArray(obj)) { for (let i = 0; i < obj.length; i++) { - extractStringsFromUserInput( - obj[i], - pathToPayload.concat([{ type: "array", index: i }]) - ).forEach((value, key) => results.set(key, value)); + extractStringsFromUserInput(obj[i]).forEach((value) => + results.add(value) + ); } // Add array as string to results // This prevents bypassing the firewall by HTTP Parameter Pollution // Example: ?param=value1¶m=value2 will be treated as array by express // If its used inside a string, it will be converted to a comma separated string - results.set(obj.join(), buildPathToPayload(pathToPayload)); + results.add(obj.join()); } if (typeof obj == "string") { - results.set(obj, buildPathToPayload(pathToPayload)); + results.add(obj); + const jwt = tryDecodeAsJWT(obj); if (jwt.jwt) { - extractStringsFromUserInput( - jwt.object, - pathToPayload.concat([{ type: "jwt" }]) - ).forEach((value, key) => { - // Do not add the issuer of the JWT as a string because it can contain a domain / url and produce false positives - if (key === "iss" || value.endsWith(".iss")) { - return; - } - results.set(key, value); + // Do not add the issuer of the JWT as a string because it can contain a domain / url and produce false positives + if (jwt.object && typeof jwt.object === "object" && "iss" in jwt.object) { + jwt.object.iss = undefined; + } + extractStringsFromUserInput(jwt.object).forEach((value) => { + results.add(value); }); } } diff --git a/library/helpers/isPlainObject.test.ts b/library/helpers/isPlainObject.test.ts index 169e6d431..9b51bc306 100644 --- a/library/helpers/isPlainObject.test.ts +++ b/library/helpers/isPlainObject.test.ts @@ -28,6 +28,7 @@ t.test( // @ts-expect-error Test t.notOk(isPlainObject(new Foo())); t.notOk(isPlainObject(null)); + t.notOk(isPlainObject(undefined)); } ); diff --git a/library/sinks/HTTPRequest.ts b/library/sinks/HTTPRequest.ts index 660aca7c1..e6b4e58ba 100644 --- a/library/sinks/HTTPRequest.ts +++ b/library/sinks/HTTPRequest.ts @@ -48,7 +48,7 @@ export class HTTPRequest implements Wrapper { operation: `${module}.request`, kind: "ssrf", source: foundSSRFRedirect.source, - pathToPayload: foundSSRFRedirect.pathToPayload, + pathsToPayload: foundSSRFRedirect.pathsToPayload, metadata: {}, payload: foundSSRFRedirect.payload, }; diff --git a/library/sinks/MongoDB.ts b/library/sinks/MongoDB.ts index 35742df9e..1466286c1 100644 --- a/library/sinks/MongoDB.ts +++ b/library/sinks/MongoDB.ts @@ -54,7 +54,7 @@ export class MongoDB implements Wrapper { operation: `MongoDB.Collection.${operation}`, kind: "nosql_injection", source: result.source, - pathToPayload: result.pathToPayload, + pathsToPayload: result.pathsToPayload, metadata: { db: db, collection: collection, diff --git a/library/sinks/undici/wrapDispatch.ts b/library/sinks/undici/wrapDispatch.ts index 94ac34d5b..b8f2d9715 100644 --- a/library/sinks/undici/wrapDispatch.ts +++ b/library/sinks/undici/wrapDispatch.ts @@ -99,7 +99,7 @@ function blockRedirectToPrivateIP(url: URL, context: Context, agent: Agent) { source: found.source, blocked: agent.shouldBlock(), stack: new Error().stack!, - path: found.pathToPayload, + paths: found.pathsToPayload, metadata: getMetadataForSSRFAttack({ hostname: found.hostname, port: found.port, @@ -110,7 +110,7 @@ function blockRedirectToPrivateIP(url: URL, context: Context, agent: Agent) { if (agent.shouldBlock()) { throw new Error( - `Zen has blocked ${attackKindHumanName("ssrf")}: fetch(...) originating from ${found.source}${escapeHTML(found.pathToPayload)}` + `Zen has blocked ${attackKindHumanName("ssrf")}: fetch(...) originating from ${found.source}${escapeHTML(found.pathsToPayload.join())}` ); } } diff --git a/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts b/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts index 377b8b32f..7b0c2c665 100644 --- a/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts +++ b/library/vulnerabilities/nosql-injection/detectNoSQLInjection.test.ts @@ -102,7 +102,7 @@ t.test("using $gt in query parameter", async (t) => { { injection: true, source: "query", - pathToPayload: ".title", + pathsToPayload: [".title"], payload: { $gt: "" }, } ); @@ -135,7 +135,7 @@ t.test("using $ne in body", async (t) => { { injection: true, source: "body", - pathToPayload: ".title", + pathsToPayload: [".title"], payload: { $ne: null }, } ); @@ -154,7 +154,7 @@ t.test("using $ne in body (different name)", async (t) => { { injection: true, source: "body", - pathToPayload: ".title", + pathsToPayload: [".title"], payload: { $ne: null }, } ); @@ -173,7 +173,7 @@ t.test("using $ne in headers with different name", async (t) => { { injection: true, source: "body", - pathToPayload: ".title", + pathsToPayload: [".title"], payload: { $ne: null }, } ); @@ -199,7 +199,7 @@ t.test("using $ne inside $and", async (t) => { { injection: true, source: "body", - pathToPayload: ".title", + pathsToPayload: [".title"], payload: { $ne: null }, } ); @@ -225,7 +225,7 @@ t.test("using $ne inside $or", async (t) => { { injection: true, source: "body", - pathToPayload: ".title", + pathsToPayload: [".title"], payload: { $ne: null }, } ); @@ -251,7 +251,7 @@ t.test("using $ne inside $nor", async (t) => { { injection: true, source: "body", - pathToPayload: ".title", + pathsToPayload: [".title"], payload: { $ne: null }, } ); @@ -272,7 +272,7 @@ t.test("using $ne inside $not", async (t) => { { injection: true, source: "body", - pathToPayload: ".title", + pathsToPayload: [".title"], payload: { $ne: null }, } ); @@ -293,7 +293,7 @@ t.test("using $ne nested in body", async (t) => { { injection: true, source: "body", - pathToPayload: ".nested.nested", + pathsToPayload: [".nested.nested"], payload: { $ne: null }, } ); @@ -325,7 +325,7 @@ t.test("using $ne in JWT in headers", async (t) => { { injection: true, source: "headers", - pathToPayload: ".Authorization.username", + pathsToPayload: [".Authorization.username"], payload: { $ne: null }, } ); @@ -357,7 +357,7 @@ t.test("using $ne in JWT in bearer header", async (t) => { { injection: true, source: "headers", - pathToPayload: ".Authorization.username", + pathsToPayload: [".Authorization.username"], payload: { $ne: null }, } ); @@ -389,7 +389,7 @@ t.test("using $ne in JWT in cookies", async (t) => { { injection: true, source: "cookies", - pathToPayload: ".session.username", + pathsToPayload: [".session.username"], payload: { $ne: null }, } ); @@ -427,7 +427,7 @@ t.test("using $gt in query parameter", async (t) => { { injection: true, source: "query", - pathToPayload: ".age", + pathsToPayload: [".age"], payload: { $gt: "21" }, } ); @@ -446,7 +446,7 @@ t.test("using $gt and $lt in query parameter", async (t) => { { injection: true, source: "body", - pathToPayload: ".age", + pathsToPayload: [".age"], payload: { $gt: "21", $lt: "100" }, } ); @@ -465,7 +465,7 @@ t.test("using $gt and $lt in query parameter (different name)", async (t) => { { injection: true, source: "body", - pathToPayload: ".age", + pathsToPayload: [".age"], payload: { $gt: "21", $lt: "100" }, } ); @@ -492,7 +492,7 @@ t.test("using $gt and $lt in query parameter (nested)", async (t) => { { injection: true, source: "body", - pathToPayload: ".nested.nested.age", + pathsToPayload: [".nested.nested.age"], payload: { $gt: "21", $lt: "100" }, } ); @@ -521,7 +521,7 @@ t.test("using $gt and $lt in query parameter (root)", async (t) => { { injection: true, source: "body", - pathToPayload: ".", + pathsToPayload: ["."], payload: { $and: [{ someAgeField: { $gt: "21", $lt: "100" } }] }, } ); @@ -550,7 +550,7 @@ t.test("$where", async (t) => { { injection: true, source: "body", - pathToPayload: ".", + pathsToPayload: ["."], payload: { $and: [ { @@ -583,7 +583,7 @@ t.test("array body", async (t) => { { injection: true, source: "body", - pathToPayload: ".[0]", + pathsToPayload: [".[0]"], payload: { $where: "sleep(1000)" }, } ); @@ -656,7 +656,7 @@ t.test("it flags pipeline aggregations", async () => { { injection: true, source: "body", - pathToPayload: ".[0]", + pathsToPayload: [".[0]"], payload: { $lookup: { from: "users", @@ -696,7 +696,7 @@ t.test("it flags pipeline aggregations", async () => { { injection: true, source: "body", - pathToPayload: ".username", + pathsToPayload: [".username"], payload: { $gt: "", }, @@ -746,7 +746,7 @@ t.test("detects root injection", async () => { { injection: true, source: "body", - pathToPayload: ".", + pathsToPayload: ["."], payload: { $where: "test" }, } ); @@ -766,7 +766,7 @@ t.test("detects injection", async () => { { injection: true, source: "body", - pathToPayload: ".test", + pathsToPayload: [".test"], payload: { $ne: "" }, } ); diff --git a/library/vulnerabilities/nosql-injection/detectNoSQLInjection.ts b/library/vulnerabilities/nosql-injection/detectNoSQLInjection.ts index cd44f0768..de526aa08 100644 --- a/library/vulnerabilities/nosql-injection/detectNoSQLInjection.ts +++ b/library/vulnerabilities/nosql-injection/detectNoSQLInjection.ts @@ -120,7 +120,12 @@ function findFilterPartWithOperators( } type DetectionResult = - | { injection: true; source: Source; pathToPayload: string; payload: unknown } + | { + injection: true; + source: Source; + pathsToPayload: string[]; + payload: unknown; + } | { injection: false }; export function detectNoSQLInjection( @@ -139,7 +144,7 @@ export function detectNoSQLInjection( return { injection: true, source: source, - pathToPayload: result.pathToPayload, + pathsToPayload: [result.pathToPayload], payload: result.payload, }; } diff --git a/library/vulnerabilities/path-traversal/checkContextForPathTraversal.test.ts b/library/vulnerabilities/path-traversal/checkContextForPathTraversal.test.ts index 7788bc7db..ec51ca5f8 100644 --- a/library/vulnerabilities/path-traversal/checkContextForPathTraversal.test.ts +++ b/library/vulnerabilities/path-traversal/checkContextForPathTraversal.test.ts @@ -25,7 +25,7 @@ t.test("it detects path traversal from route parameter", async () => { operation: "operation", kind: "path_traversal", source: "routeParams", - pathToPayload: ".path", + pathsToPayload: [".path"], metadata: { filename: "../file/test.txt", }, @@ -90,7 +90,7 @@ t.test("it detects path traversal with URL", async () => { operation: "operation", kind: "path_traversal", source: "routeParams", - pathToPayload: ".path", + pathsToPayload: [".path"], metadata: { filename: "/file/test.txt", }, @@ -109,7 +109,7 @@ t.test("it detects path traversal with Buffer", async () => { operation: "operation", kind: "path_traversal", source: "routeParams", - pathToPayload: ".path", + pathsToPayload: [".path"], metadata: { filename: "../file/test.txt", }, diff --git a/library/vulnerabilities/path-traversal/checkContextForPathTraversal.ts b/library/vulnerabilities/path-traversal/checkContextForPathTraversal.ts index 3fdc67931..7ea5eaa54 100644 --- a/library/vulnerabilities/path-traversal/checkContextForPathTraversal.ts +++ b/library/vulnerabilities/path-traversal/checkContextForPathTraversal.ts @@ -1,6 +1,7 @@ import { Context } from "../../agent/Context"; import { InterceptorResult } from "../../agent/hooks/InterceptorResult"; import { SOURCES } from "../../agent/Source"; +import { getPathsToPayload } from "../../helpers/attackPath"; import { extractStringsFromUserInputCached } from "../../helpers/extractStringsFromUserInputCached"; import { detectPathTraversal } from "./detectPathTraversal"; @@ -31,13 +32,13 @@ export function checkContextForPathTraversal({ continue; } - for (const [str, path] of userInput.entries()) { + for (const str of userInput) { if (detectPathTraversal(pathString, str, checkPathStart, isUrl)) { return { operation: operation, kind: "path_traversal", source: source, - pathToPayload: path, + pathsToPayload: getPathsToPayload(str, context[source]), metadata: { filename: pathString, }, diff --git a/library/vulnerabilities/shell-injection/checkContextForShellInjection.test.ts b/library/vulnerabilities/shell-injection/checkContextForShellInjection.test.ts index cd13d22b1..ea147b864 100644 --- a/library/vulnerabilities/shell-injection/checkContextForShellInjection.test.ts +++ b/library/vulnerabilities/shell-injection/checkContextForShellInjection.test.ts @@ -25,7 +25,7 @@ t.test("it detects shell injection", async () => { operation: "child_process.exec", kind: "shell_injection", source: "body", - pathToPayload: ".domain", + pathsToPayload: [".domain"], metadata: { command: "binary --domain www.example`whoami`.com", }, @@ -58,7 +58,7 @@ t.test("it detects shell injection from route params", async () => { operation: "child_process.exec", kind: "shell_injection", source: "routeParams", - pathToPayload: ".domain", + pathsToPayload: [".domain"], metadata: { command: "binary --domain www.example`whoami`.com", }, diff --git a/library/vulnerabilities/shell-injection/checkContextForShellInjection.ts b/library/vulnerabilities/shell-injection/checkContextForShellInjection.ts index ae38cb137..be3f1b95c 100644 --- a/library/vulnerabilities/shell-injection/checkContextForShellInjection.ts +++ b/library/vulnerabilities/shell-injection/checkContextForShellInjection.ts @@ -1,6 +1,7 @@ import { Context } from "../../agent/Context"; import { InterceptorResult } from "../../agent/hooks/InterceptorResult"; import { SOURCES } from "../../agent/Source"; +import { getPathsToPayload } from "../../helpers/attackPath"; import { extractStringsFromUserInputCached } from "../../helpers/extractStringsFromUserInputCached"; import { detectShellInjection } from "./detectShellInjection"; @@ -23,13 +24,13 @@ export function checkContextForShellInjection({ continue; } - for (const [str, path] of userInput.entries()) { + for (const str of userInput) { if (detectShellInjection(command, str)) { return { operation: operation, kind: "shell_injection", source: source, - pathToPayload: path, + pathsToPayload: getPathsToPayload(str, context[source]), metadata: { command: command, }, diff --git a/library/vulnerabilities/sql-injection/checkContextForSqlInjection.test.ts b/library/vulnerabilities/sql-injection/checkContextForSqlInjection.test.ts index fd536e73d..722849e63 100644 --- a/library/vulnerabilities/sql-injection/checkContextForSqlInjection.test.ts +++ b/library/vulnerabilities/sql-injection/checkContextForSqlInjection.test.ts @@ -27,7 +27,7 @@ t.test("it returns correct path", async () => { operation: "mysql.query", kind: "sql_injection", source: "body", - pathToPayload: ".id", + pathsToPayload: [".id"], metadata: { sql: "SELECT * FROM users WHERE id = '1' OR 1=1; -- '", }, diff --git a/library/vulnerabilities/sql-injection/checkContextForSqlInjection.ts b/library/vulnerabilities/sql-injection/checkContextForSqlInjection.ts index d87b12a5d..b5542c930 100644 --- a/library/vulnerabilities/sql-injection/checkContextForSqlInjection.ts +++ b/library/vulnerabilities/sql-injection/checkContextForSqlInjection.ts @@ -1,6 +1,7 @@ import { Context } from "../../agent/Context"; import { InterceptorResult } from "../../agent/hooks/InterceptorResult"; import { SOURCES } from "../../agent/Source"; +import { getPathsToPayload } from "../../helpers/attackPath"; import { extractStringsFromUserInputCached } from "../../helpers/extractStringsFromUserInputCached"; import { detectSQLInjection } from "./detectSQLInjection"; import { SQLDialect } from "./dialects/SQLDialect"; @@ -26,13 +27,13 @@ export function checkContextForSqlInjection({ continue; } - for (const [str, path] of userInput.entries()) { + for (const str of userInput) { if (detectSQLInjection(sql, str, dialect)) { return { operation: operation, kind: "sql_injection", source: source, - pathToPayload: path, + pathsToPayload: getPathsToPayload(str, context[source]), metadata: { sql: sql, }, diff --git a/library/vulnerabilities/ssrf/checkContextForSSRF.ts b/library/vulnerabilities/ssrf/checkContextForSSRF.ts index 8ab9e8b90..e19c32b9f 100644 --- a/library/vulnerabilities/ssrf/checkContextForSSRF.ts +++ b/library/vulnerabilities/ssrf/checkContextForSSRF.ts @@ -2,6 +2,7 @@ import { Context } from "../../agent/Context"; import { InterceptorResult } from "../../agent/hooks/InterceptorResult"; import { SOURCES } from "../../agent/Source"; +import { getPathsToPayload } from "../../helpers/attackPath"; import { extractStringsFromUserInputCached } from "../../helpers/extractStringsFromUserInputCached"; import { containsPrivateIPAddress } from "./containsPrivateIPAddress"; import { findHostnameInUserInput } from "./findHostnameInUserInput"; @@ -37,15 +38,17 @@ export function checkContextForSSRF({ continue; } - for (const [str, path] of userInput.entries()) { + for (const str of userInput) { const found = findHostnameInUserInput(str, hostname, port); if (found) { + const paths = getPathsToPayload(str, context[source]); + if ( isRequestToItself({ str: str, source: source, port: port, - path: path, + paths: paths, }) ) { // Application might do a request to itself when the hostname is localhost @@ -57,7 +60,7 @@ export function checkContextForSSRF({ operation: operation, kind: "ssrf", source: source, - pathToPayload: path, + pathsToPayload: paths, metadata: getMetadataForSSRFAttack({ hostname, port }), payload: str, }; diff --git a/library/vulnerabilities/ssrf/findHostnameInContext.ts b/library/vulnerabilities/ssrf/findHostnameInContext.ts index 4c4d81486..a4827355c 100644 --- a/library/vulnerabilities/ssrf/findHostnameInContext.ts +++ b/library/vulnerabilities/ssrf/findHostnameInContext.ts @@ -1,12 +1,13 @@ import { Context } from "../../agent/Context"; import { Source, SOURCES } from "../../agent/Source"; +import { getPathsToPayload } from "../../helpers/attackPath"; import { extractStringsFromUserInputCached } from "../../helpers/extractStringsFromUserInputCached"; import { findHostnameInUserInput } from "./findHostnameInUserInput"; import { isRequestToItself } from "./isRequestToItself"; type HostnameLocation = { source: Source; - pathToPayload: string; + pathsToPayload: string[]; payload: string; port: number | undefined; hostname: string; @@ -23,15 +24,17 @@ export function findHostnameInContext( continue; } - for (const [str, path] of userInput.entries()) { + for (const str of userInput) { const found = findHostnameInUserInput(str, hostname, port); if (found) { + const paths = getPathsToPayload(str, context[source]); + if ( isRequestToItself({ str: str, source: source, port: port, - path: path, + paths: paths, }) ) { // Application might do a request to itself when the hostname is localhost @@ -42,7 +45,7 @@ export function findHostnameInContext( return { source: source, - pathToPayload: path, + pathsToPayload: paths, payload: str, port: port, hostname: hostname, diff --git a/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts b/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts index 3c78a5ff9..417f18f3c 100644 --- a/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts +++ b/library/vulnerabilities/ssrf/inspectDNSLookupCalls.ts @@ -202,7 +202,7 @@ function wrapDNSLookupCallback( source: found.source, blocked: agent.shouldBlock(), stack: cleanupStackTrace(stackTraceError.stack!, libraryRoot), - path: found.pathToPayload, + paths: found.pathsToPayload, metadata: getMetadataForSSRFAttack({ hostname, port }), request: context, payload: found.payload, @@ -211,7 +211,7 @@ function wrapDNSLookupCallback( if (agent.shouldBlock()) { return callback( new Error( - `Zen has blocked ${attackKindHumanName("ssrf")}: ${operation}(...) originating from ${found.source}${escapeHTML(found.pathToPayload)}` + `Zen has blocked ${attackKindHumanName("ssrf")}: ${operation}(...) originating from ${found.source}${escapeHTML(found.pathsToPayload.join())}` ) ); } diff --git a/library/vulnerabilities/ssrf/isRequestToItself.test.ts b/library/vulnerabilities/ssrf/isRequestToItself.test.ts index 9263b233a..d83712779 100644 --- a/library/vulnerabilities/ssrf/isRequestToItself.test.ts +++ b/library/vulnerabilities/ssrf/isRequestToItself.test.ts @@ -5,7 +5,7 @@ t.test("it returns true for a request to itself", async (t) => { t.same( isRequestToItself({ source: "headers", - path: ".host", + paths: [".host"], port: 1234, str: "localhost:1234", }), @@ -14,7 +14,16 @@ t.test("it returns true for a request to itself", async (t) => { t.same( isRequestToItself({ source: "headers", - path: ".origin", + paths: [".origin"], + port: 1234, + str: "http://localhost:1234", + }), + true + ); + t.same( + isRequestToItself({ + source: "headers", + paths: [".referer"], port: 1234, str: "http://localhost:1234", }), @@ -23,7 +32,7 @@ t.test("it returns true for a request to itself", async (t) => { t.same( isRequestToItself({ source: "headers", - path: ".referer", + paths: [".referer", ".origin"], port: 1234, str: "http://localhost:1234", }), @@ -35,7 +44,7 @@ t.test("it returns false", async (t) => { t.same( isRequestToItself({ source: "headers", - path: ".host", + paths: [".host"], port: 1234, str: "localhost:1235", }), @@ -44,7 +53,7 @@ t.test("it returns false", async (t) => { t.same( isRequestToItself({ source: "headers", - path: ".host", + paths: [".host"], port: 1234, str: "localhostabc:1234", }), @@ -53,7 +62,7 @@ t.test("it returns false", async (t) => { t.same( isRequestToItself({ source: "headers", - path: ".hostabc", + paths: [".hostabc"], port: 1234, str: "localhost:1234", }), @@ -72,7 +81,7 @@ t.test("it returns false", async (t) => { t.same( isRequestToItself({ source: "headers", - path: ".host", + paths: [".host"], port: 1234, str: "http://localhost:1234", }), @@ -81,10 +90,28 @@ t.test("it returns false", async (t) => { t.same( isRequestToItself({ source: "headers", - path: ".origin", + paths: [".origin"], port: 1234, str: "http%%%://localhost:1234", }), false ); + t.same( + isRequestToItself({ + source: "headers", + paths: [".referer", ".origin", ".x-test"], + port: 1234, + str: "http://localhost:1234", + }), + false + ); + t.same( + isRequestToItself({ + source: "headers", + paths: [".referer", ".host"], + port: 1234, + str: "http://localhost:1234", + }), + false + ); }); diff --git a/library/vulnerabilities/ssrf/isRequestToItself.ts b/library/vulnerabilities/ssrf/isRequestToItself.ts index ab52e5fc0..04f67db37 100644 --- a/library/vulnerabilities/ssrf/isRequestToItself.ts +++ b/library/vulnerabilities/ssrf/isRequestToItself.ts @@ -5,25 +5,32 @@ export function isRequestToItself({ str, source, port, - path, + paths, }: { + str: string; source: Source; - path: string; port: number | undefined; - str: string; + paths: string[]; }): boolean { if (source !== "headers" || typeof port !== "number") { return false; } - if (path === ".host") { - return str === `localhost:${port}`; - } + let ignoredPaths = 0; + + for (const path of paths) { + if (path === ".host" && str === `localhost:${port}`) { + ignoredPaths++; + continue; + } - if (path === ".origin" || path === ".referer") { - const url = tryParseURL(str); - return !!url && url.host === `localhost:${port}`; + if (path === ".origin" || path === ".referer") { + const url = tryParseURL(str); + if (!!url && url.host === `localhost:${port}`) { + ignoredPaths++; + } + } } - return false; + return ignoredPaths === paths.length; }