Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
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
26 changes: 25 additions & 1 deletion library/agent/hooks/instrumentation/codeTransformation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,20 @@ export function transformCode(
pkgLoadFormat: PackageLoadFormat,
fileInstructions: PackageFileInstrumentationInstructionJSON
): string {
// Path can be a RegExp pattern but JSON.stringify will throw an error
// We want the actual file path anyway, not the matching pattern
const instructionsWasm: JSONSerializable<PackageFileInstrumentationInstructionJSON> =
{
...fileInstructions,
path: path,
};

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

Expand All @@ -43,3 +51,19 @@ export function transformCode(
throw error;
}
}

// There's no nice way to create types like these
// Since we stringify the JSON instructions, we need to ensure that they are serializable
// e.g. cannot contain RegExp
type JSONPrimitive = string | number | boolean | null;
type JSONSerializable<T> = T extends JSONPrimitive
? T
: T extends (infer U)[]
? JSONSerializable<U>[]
: T extends Record<string, unknown>
? {
[K in keyof T as T[K] extends Function | symbol | undefined
? never
: K]: JSONSerializable<T[K]>;
}
: never;
25 changes: 22 additions & 3 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 @@ -141,7 +160,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
31 changes: 31 additions & 0 deletions library/sources/ReactRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Hooks } from "../agent/hooks/Hooks";
import { Wrapper } from "../agent/Wrapper";
import { wrapRequestBodyParsing } from "./react-router/wrapRequestBodyParsing";

export class ReactRouter implements Wrapper {
wrap(hooks: Hooks) {
hooks
.addPackage("react-router")
.withVersion("^7.0.0")
.addFileInstrumentation({
path: /^dist\/(production|development)\/chunk-[A-Z0-9]+\.mjs$/,
functions: [
{
// We cannot patch the `Request` global (as Request is also used by fetch calls)
// We're interested in the Request object that gets passed to the server actions
// See https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/server-runtime/data.ts#L26
nodeType: "FunctionDeclaration",
name: "stripRoutesParam",
operationKind: undefined,
modifyReturnValue: (_, returnValue) => {
if (returnValue instanceof Request) {
wrapRequestBodyParsing(returnValue);
}

return returnValue;
},
},
],
});
}
}
37 changes: 37 additions & 0 deletions library/sources/react-router/wrapRequestBodyParsing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { getContext, updateContext } from "../../agent/Context";
import { createWrappedFunction, isWrapped } from "../../helpers/wrap";

/**
* Wrap the Request object's body parsing methods to update the context with the parsed body
* when any of the methods are called.
* This is needed because React Router uses the Fetch API Request object which has a stream
* body that can only be consumed once. We wrap the parsing methods to capture the result.
*/
export function wrapRequestBodyParsing(request: Request) {
request.formData = wrapBodyParsingFunction(request.formData);

Choose a reason for hiding this comment

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

Wraps request.formData without verifying it's a function, which can cause runtime errors if the property is missing or not callable.

Details

✨ AI Reasoning
​​1) The new code assigns wrapped versions of request.formData/json/text without checking that those properties are actually functions; 2) If any of those properties is undefined or not callable at runtime, isWrapped(func) or createWrappedFunction(func, ...) may throw or behave incorrectly, breaking behavior; 3) This harms robustness because the wrapper assumes functions exist on the Request object but doesn't guard against missing/non-function properties; therefore it's a logic bug introduced by these changes (previous code did not perform these assignments);

🔧 How do I fix it?
Trace execution paths carefully. Ensure precondition checks happen before using values, validate ranges before checking impossible conditions, and don't check for states that the code has already ruled out.

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

request.json = wrapBodyParsingFunction(request.json);
request.text = wrapBodyParsingFunction(request.text);
}

function wrapBodyParsingFunction<T extends Function>(func: T) {
if (isWrapped(func)) {
return func;
}

return createWrappedFunction(func, function parse(parser) {
return async function wrap() {
// @ts-expect-error No type for arguments
// eslint-disable-next-line prefer-rest-params
const returnValue = await parser.apply(this, arguments);

if (returnValue) {
const context = getContext();
if (context) {
updateContext(context, "body", returnValue);
}
}

return returnValue;
};
}) as T;
}
Loading