Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions end2end/tests-new/react-router-pg.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { getRandomPort } from "./utils/get-port.mjs";
import { spawnSync, spawn } from "node:child_process";
import { resolve } from "node:path";
import { timeout } from "./utils/timeout.mjs";
import { test, before } from "node:test";
import { equal, fail, match, doesNotMatch } from "node:assert";

const pathToAppDir = resolve(
import.meta.dirname,
"../../sample-apps/react-router-pg"
);

const port = await getRandomPort();
const port2 = await getRandomPort();

before(() => {
const { stderr, status } = spawnSync("npm", ["run", "build"], {
cwd: pathToAppDir,
});

if (status !== 0) {
throw new Error(`Failed to build: ${stderr.toString()}`);
}
});

test("it blocks request in blocking mode", async () => {
const server = spawn(
`node_modules/.bin/react-router-serve`,
["./build/server/index.js"],
{
cwd: pathToAppDir,
env: {
...process.env,
AIKIDO_DEBUG: "true",
AIKIDO_BLOCK: "true",
NODE_OPTIONS: "-r @aikidosec/firewall/instrument",
PORT: port,
},
}
);

try {
server.on("error", (err) => {
fail(err);
});

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
await timeout(2000);

const formData1 = new FormData();
formData1.append("catname", "Kitty'); DELETE FROM cats_5;-- H");

const formData2 = new FormData();
formData2.append("catname", "Miau");

const [sqlInjection, normalAdd] = await Promise.all([
fetch(`http://127.0.0.1:${port}/add-cat`, {
method: "POST",
body: formData1,
redirect: "manual",
signal: AbortSignal.timeout(5000),
}),
fetch(`http://127.0.0.1:${port}/add-cat`, {
method: "POST",
body: formData2,
redirect: "manual",
signal: AbortSignal.timeout(5000),
}),
]);

equal(sqlInjection.status, 500);
equal(normalAdd.status, 302); // Redirect after successful add
match(stdout, /Starting agent/);
match(stderr, /Zen has blocked an SQL injection/);
} finally {
server.kill();
}
});

test("it does not block request in monitoring mode", async () => {
const server = spawn(
`node_modules/.bin/react-router-serve`,
["./build/server/index.js"],
{
cwd: pathToAppDir,
env: {
...process.env,
AIKIDO_DEBUG: "true",
AIKIDO_BLOCK: "false",
NODE_OPTIONS: "-r @aikidosec/firewall/instrument",
PORT: port2,
},
}
);

try {
server.on("error", (err) => {
fail(err);
});

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
await timeout(2000);

const formData1 = new FormData();
formData1.append("catname", "Kitty'); DELETE FROM cats_5;-- H");

const formData2 = new FormData();
formData2.append("catname", "Miau");

const [sqlInjection, normalAdd] = await Promise.all([
fetch(`http://127.0.0.1:${port2}/add-cat`, {
method: "POST",
body: formData1,
redirect: "manual",
signal: AbortSignal.timeout(5000),
}),
fetch(`http://127.0.0.1:${port2}/add-cat`, {
method: "POST",
body: formData2,
redirect: "manual",
signal: AbortSignal.timeout(5000),
}),
]);

equal(sqlInjection.status, 302); // Redirect even with SQL injection
equal(normalAdd.status, 302);
match(stdout, /Starting agent/);
doesNotMatch(stderr, /Zen has blocked an SQL injection/);
} finally {
server.kill();
}
});
6 changes: 6 additions & 0 deletions library/agent/hooks/VersionedPackage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export class VersionedPackage {
* The path is relative to the package root.
*/
addFileInstrumentation(instruction: PackageFileInstrumentationInstruction) {
if (instruction.path instanceof RegExp) {
// Just accept RegExp paths as-is

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment 'Just accept RegExp paths as-is' explains what the code does; replace it with why RegExp paths are allowed (e.g., to support bundled chunk filenames like 'chunk-XXXX.mjs').

Details

✨ AI Reasoning
​​1) The added comment on line 69 simply restates what the immediately following code does (accept RegExp paths) rather than explaining why this special-case exists or its intended effect (e.g., supporting bundled chunk filenames).
​2) This harms maintainability because such "what" comments add little value and can become stale; a "why" comment would guide future maintainers.
​3) The issue is limited in scope to a small, recent change and is appropriate to fix within this PR by replacing the comment.
​4) Fixing it improves long-term clarity without requiring refactor.
​5) The change is a single new comment, so it's straightforward to improve.

🔧 How do I fix it?
Write comments that explain the purpose, reasoning, or business logic behind the code using words like 'because', 'so that', or 'in order to'.

More info - Comment @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.

this.fileInstrumentationInstructions.push(instruction);
return this;
}

if (instruction.path.length === 0) {
throw new Error("Path must not be empty");
}
Expand Down
14 changes: 13 additions & 1 deletion library/agent/hooks/instrumentation/codeTransformation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import { join } from "path";
import { isNewInstrumentationUnitTest } from "../../../helpers/isNewInstrumentationUnitTest";
import { isEsmUnitTest } from "../../../helpers/isEsmUnitTest";

// path can also be a RegExp, this results in an empty object when serialized to JSON
// serde will try to parse it and crash, so we need to set it to the actual file path
type PackageFileInstrumentationInstructionWASM = Omit<
PackageFileInstrumentationInstructionJSON,
"path"
> & { path: string };

export function transformCode(
pkgName: string,
pkgVersion: string,
Expand All @@ -14,12 +21,17 @@ export function transformCode(
pkgLoadFormat: PackageLoadFormat,
fileInstructions: PackageFileInstrumentationInstructionJSON
): string {
let wasmInstructions: PackageFileInstrumentationInstructionWASM = {
...fileInstructions,
path: path,
};

try {
const result = wasm_transform_code_str(
pkgName,
pkgVersion,
code,
JSON.stringify(fileInstructions),
JSON.stringify(wasmInstructions),
getSourceType(path, pkgLoadFormat)
);

Expand Down
28 changes: 24 additions & 4 deletions library/agent/hooks/instrumentation/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function setPackagesToInstrument(_packages: Package[]) {
return versionedPackage
.getFileInstrumentationInstructions()
.map((file) => {
const fileIdentifier = `${pkg.getName()}.${file.path}.${versionedPackage.getRange()}`;
const fileIdentifier = `${pkg.getName()}.${getIdentifier(file.path)}.${versionedPackage.getRange()}`;
if (file.accessLocalVariables?.cb) {
fileCallbackInfo.set(fileIdentifier, {
pkgName: pkg.getName(),
Expand All @@ -57,7 +57,7 @@ export function setPackagesToInstrument(_packages: Package[]) {
versionRange: versionedPackage.getRange(),
identifier: fileIdentifier,
functions: file.functions.map((func) => {
const identifier = `${pkg.getName()}.${file.path}.${func.name}.${func.nodeType}.${versionedPackage.getRange()}`;
const identifier = `${pkg.getName()}.${getIdentifier(file.path)}.${func.name}.${func.nodeType}.${versionedPackage.getRange()}`;

// If bindContext is set to true, but no modifyArgs is defined, modifyArgs will be set to a stub function
// The reason for this is that the bindContext logic needs to modify the arguments
Expand Down Expand Up @@ -117,6 +117,25 @@ export function shouldPatchPackage(name: string): boolean {
return packages.has(name);
}

function matchesPath(
pathOrPattern: string | RegExp,
filePath: string
): boolean {
if (typeof pathOrPattern === "string") {
return pathOrPattern === filePath;
}

return pathOrPattern.test(filePath);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling RegExp.test on potentially untrusted/complex patterns can introduce ReDoS risk (no validation or safeguards on provided RegExp).

Details

✨ AI Reasoning
​​1) The changes add support for RegExp matching via matchesPath which calls pathOrPattern.test(filePath).
​2) This introduces the use of arbitrary regular expressions where previously only string equality was used, which can expose the code to ReDoS if untrusted or complex regexes are used.
​3) This harms safety because a crafted pattern (or an overly complex pattern from configuration) can cause catastrophic backtracking when .test() is executed on long inputs.
​4) Guarding or validating patterns or restricting allowed regex constructs would mitigate the risk; the change introduced the raw .test() invocation without such safeguards. Therefore this is a true issue introduced by the PR at the point where .test() is invoked.

🔧 How do I fix it?
Avoid nested quantifiers like (x+)+ and ambiguous patterns. Use atomic groups, possessive quantifiers, or rewrite complex regex patterns as simpler alternatives.

More info - Comment @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.

}

function getIdentifier(pathOrPattern: string | RegExp) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does JS not call .toString() automatically?

const regex: RegExp = /abcd/i;
console.log(`test${regex}A42`);
// Prints 'test/abcd/iA42'

if (typeof pathOrPattern === "string") {
return pathOrPattern;
}

return pathOrPattern.toString();
}

export function getPackageFileInstrumentationInstructions(
packageName: string,
version: string,
Expand All @@ -128,7 +147,8 @@ export function getPackageFileInstrumentationInstructions(
}

return instructions.find(
(f) => f.path === filePath && satisfiesVersion(f.versionRange, version)
(f) =>
matchesPath(f.path, filePath) && satisfiesVersion(f.versionRange, version)
);
}

Expand All @@ -141,7 +161,7 @@ export function shouldPatchFile(
return false;
}

return instructions.some((f) => f.path === filePath);
return instructions.some((f) => matchesPath(f.path, filePath));
}

export function getFunctionCallbackInfo(
Expand Down
8 changes: 6 additions & 2 deletions library/agent/hooks/instrumentation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@ export type PackageFunctionInstrumentationInstruction = {
};

export type PackageFileInstrumentationInstruction = {
path: string; // Relative path to required file inside the package folder
// Relative path to required file inside the package folder
// You can use a regex in cases where the filename is not known in advance (e.g. chunks generated by a bundler)
path: string | RegExp;
functions: PackageFunctionInstrumentationInstruction[];
/**
* Access module local variables
Expand All @@ -132,7 +134,9 @@ export type PackageFileInstrumentationInstruction = {
};

export type PackageFileInstrumentationInstructionJSON = {
path: string; // Relative path to required file inside the package folder
// Relative path to required file inside the package folder
// You can use a regex in cases where the filename is not known in advance (e.g. chunks generated by a bundler)
path: string | RegExp;
versionRange: string;
identifier: string;
accessLocalVariables: string[];
Expand Down
2 changes: 2 additions & 0 deletions library/agent/protect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { Postgresjs } from "../sinks/Postgresjs";
import { Fastify } from "../sources/Fastify";
import { Koa } from "../sources/Koa";
import { Restify } from "../sources/Restify";
import { ReactRouter } from "../sources/ReactRouter";
import { ClickHouse } from "../sinks/ClickHouse";
import { Prisma } from "../sinks/Prisma";
import { AwsSDKVersion2 } from "../sinks/AwsSDKVersion2";
Expand Down Expand Up @@ -165,6 +166,7 @@ export function getWrappers() {
new Fastify(),
new Koa(),
new Restify(),
new ReactRouter(),
new ClickHouse(),
new Prisma(),
new AwsSDKVersion3(),
Expand Down
69 changes: 69 additions & 0 deletions library/helpers/formDataToPlainObject.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import * as t from "tap";
import { formDataToPlainObject } from "./formDataToPlainObject";

t.test(
"simple",
{
skip: !globalThis.FormData
? "This Node.js version does not support FormData yet"
: false,
},
async (t) => {
const formData = new FormData();
formData.append("abc", "123");
formData.append("another", "42");
formData.append("hello", "world");

t.same(formDataToPlainObject(formData), {
abc: "123",
another: "42",
hello: "world",
});
}
);

t.test(
"with arrays",
{
skip: !globalThis.FormData
? "This Node.js version does not support FormData yet"
: false,
},
async (t) => {
const formData = new FormData();
formData.append("abc", "123");
formData.append("arr", "1");
formData.append("arr", "2");
formData.append("arr", "3");

t.same(formDataToPlainObject(formData), {
abc: "123",
arr: ["1", "2", "3"],
});
}
);

t.test(
"binary data",
{
skip:
!globalThis.FormData || !globalThis.File
? "This Node.js version does not support FormData or File yet"
: false,
},
async (t) => {
const formData = new FormData();
formData.append("abc", "123");
formData.append("arr", "2");
formData.append("arr", "3");
formData.append(
"file",
new File(["hello"], "hello.txt", { type: "text/plain" })
);

t.same(formDataToPlainObject(formData), {
abc: "123",
arr: ["2", "3"],
});
}
);
27 changes: 27 additions & 0 deletions library/helpers/formDataToPlainObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export function formDataToPlainObject(formData: FormData) {
const object: Map<string, unknown> = new Map();
formData.forEach((value, key) => {
if (typeof value !== "string") {
return;
}

if (object.has(key)) {
// If the key already exists, treat it as an array
const entry = object.get(key);

if (Array.isArray(entry)) {
// If it's already an array, just push the new value
entry.push(value);
return;
}

// Convert it to an array
object.set(key, [object.get(key), value]);
return;
}

object.set(key, value);
});

return Object.fromEntries(object);
}
Loading
Loading