Skip to content

Commit 11b4c1b

Browse files
authored
Merge pull request #1016 from AikidoSec/fix-call-stack-size
fix: Possible to reach max call stack size
2 parents 7b1331c + bf37d26 commit 11b4c1b

6 files changed

Lines changed: 136 additions & 14 deletions

File tree

library/agent/api-discovery/getDataSchema.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,22 @@ t.test("test max depth", async (t) => {
168168
t.notOk(JSON.stringify(schema2).includes('"type":"string"'));
169169
});
170170

171+
t.test("array nesting counts toward max depth", async (t) => {
172+
const wrapInArrays = (levels: number): unknown => {
173+
let nested: unknown = { value: "leaf" };
174+
for (let i = 0; i < levels; i++) {
175+
nested = [nested];
176+
}
177+
return nested;
178+
};
179+
180+
const shallow = getDataSchema(wrapInArrays(19));
181+
t.ok(JSON.stringify(shallow).includes('"value"'));
182+
183+
const deep = getDataSchema(wrapInArrays(21));
184+
t.notOk(JSON.stringify(deep).includes('"value"'));
185+
});
186+
171187
t.test("test max properties", async (t) => {
172188
const generateObjectWithProperties = (count: number) => {
173189
const obj: any = {};

library/agent/api-discovery/getDataSchema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export function getDataSchema(data: unknown, depth = 0): DataSchema {
5555
return {
5656
type: "array",
5757
// Assume that the array is homogenous (for performance reasons)
58-
items: data.length > 0 ? getDataSchema(data[0]) : null,
58+
items: data.length > 0 ? getDataSchema(data[0], depth + 1) : null,
5959
};
6060
}
6161

library/helpers/attackPath.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,34 @@ t.test("first item in array", async (t) => {
9797
t.same(get("id = 1", ["id = 1"]), [".[0]"]);
9898
});
9999

100+
t.test("deeply nested arrays do not cause a stack overflow", async (t) => {
101+
// Build a deep pure-array structure: [[[...["payload"]...]]]
102+
let nested: unknown = ["payload"];
103+
for (let i = 0; i < 15000; i++) {
104+
nested = [nested];
105+
}
106+
107+
get("payload", nested);
108+
// Payload is beyond MAX_DEPTH so it should not be found
109+
t.same(get("payload", nested), []);
110+
});
111+
112+
t.test(
113+
"array join on deeply nested array does not cause a stack overflow",
114+
async (t) => {
115+
let deepNested: unknown = ["x"];
116+
for (let i = 0; i < 15000; i++) {
117+
deepNested = [deepNested, "y"];
118+
}
119+
120+
// Place the nested array before the attack payload key so join() is called first.
121+
const obj = { nested: deepNested, payload: "SELECT 1" };
122+
123+
get("SELECT 1", obj);
124+
t.same(get("SELECT 1", obj), [".payload"]);
125+
}
126+
);
127+
100128
t.test("it checks max matches when iterating over object props", async (t) => {
101129
const testObj = {
102130
a: ["test"],

library/helpers/attackPath.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -85,21 +85,25 @@ export function getPathsToPayload(
8585
}
8686

8787
if (Array.isArray(value)) {
88-
if (
89-
value.length > 1 &&
90-
value.length < MAX_ARRAY_LENGTH &&
91-
value.join().toLowerCase() === attackPayloadLowercase
92-
) {
93-
matches.add(path);
94-
return;
88+
try {
89+
if (
90+
value.length > 1 &&
91+
value.length < MAX_ARRAY_LENGTH &&
92+
value.join().toLowerCase() === attackPayloadLowercase
93+
) {
94+
matches.add(path);
95+
return;
96+
}
97+
} catch {
98+
// Ignore deeply nested arrays that overflow during native join recursion.
9599
}
96100

97101
for (const [index, item] of value.entries()) {
98102
if (matches.found() || index > MAX_ARRAY_LENGTH) {
99103
break;
100104
}
101105

102-
traverse(item, path.concat({ type: "array", index }), depth);
106+
traverse(item, path.concat({ type: "array", index }), depth + 1);
103107
}
104108
}
105109

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import * as t from "tap";
2+
import { detectNoSQLInjection } from "./detectNoSQLInjection";
3+
import { Context } from "../../agent/Context";
4+
5+
function createContext({
6+
query,
7+
headers,
8+
body,
9+
cookies,
10+
routeParams,
11+
}: {
12+
query?: Context["query"];
13+
body?: Context["body"];
14+
headers?: Context["headers"];
15+
cookies?: Context["cookies"];
16+
routeParams?: Context["routeParams"];
17+
}): Context {
18+
return {
19+
remoteAddress: "::1",
20+
method: "GET",
21+
url: "http://localhost:4000",
22+
query: query ? query : {},
23+
headers: headers ? headers : {},
24+
body: body,
25+
cookies: cookies ? cookies : {},
26+
routeParams: routeParams ? routeParams : {},
27+
source: "express",
28+
route: "/posts/:id",
29+
};
30+
}
31+
32+
t.test(
33+
"deeply nested array in body does not cause a stack overflow",
34+
{ timeout: 30 * 1000 },
35+
async (t) => {
36+
let deepNested: unknown = ["x", "y"];
37+
for (let i = 0; i < 4000; i++) {
38+
deepNested = [deepNested, "z"];
39+
}
40+
41+
const ctx = createContext({
42+
body: { nested: deepNested, username: { $ne: null } },
43+
});
44+
const filter = { username: { $ne: null } };
45+
46+
detectNoSQLInjection(ctx, filter);
47+
t.same(detectNoSQLInjection(ctx, filter), {
48+
injection: true,
49+
source: "body",
50+
pathsToPayload: [".username"],
51+
payload: { $ne: null },
52+
});
53+
}
54+
);

library/vulnerabilities/nosql-injection/detectNoSQLInjection.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ import { isPlainObject } from "../../helpers/isPlainObject";
66
import { tryDecodeAsJWT } from "../../helpers/tryDecodeAsJWT";
77
import { detectDbJsInjection } from "../js-injection/detectDbJsInjection";
88

9+
// Matches the depth limit used by extractStringsFromUserInput
10+
const MAX_DEPTH = 1024;
11+
912
function matchFilterPartInUser(
1013
userInput: unknown,
1114
filterPart: Record<string, unknown>,
12-
pathToPayload: PathPart[] = []
15+
pathToPayload: PathPart[] = [],
16+
depth = 0
1317
): { match: false } | { match: true; pathToPayload: string } {
18+
if (depth > MAX_DEPTH) {
19+
return { match: false };
20+
}
21+
1422
if (typeof userInput === "string") {
1523
// Check for js injection in $where
1624
if (detectDbJsInjection(userInput, filterPart)) {
@@ -25,7 +33,8 @@ function matchFilterPartInUser(
2533
return matchFilterPartInUser(
2634
jwt.object,
2735
filterPart,
28-
pathToPayload.concat([{ type: "jwt" }])
36+
pathToPayload.concat([{ type: "jwt" }]),
37+
depth + 1
2938
);
3039
}
3140
}
@@ -40,7 +49,8 @@ function matchFilterPartInUser(
4049
const match = matchFilterPartInUser(
4150
userInput[key],
4251
filterPart,
43-
pathToPayload.concat([{ type: "object", key: key }])
52+
pathToPayload.concat([{ type: "object", key: key }]),
53+
depth + 1
4454
);
4555

4656
if (match.match) {
@@ -54,15 +64,25 @@ function matchFilterPartInUser(
5464
const match = matchFilterPartInUser(
5565
userInput[index],
5666
filterPart,
57-
pathToPayload.concat([{ type: "array", index: index }])
67+
pathToPayload.concat([{ type: "array", index: index }]),
68+
depth + 1
5869
);
5970

6071
if (match.match) {
6172
return match;
6273
}
6374
}
6475

65-
return matchFilterPartInUser(userInput.join(), filterPart, pathToPayload);
76+
try {
77+
return matchFilterPartInUser(
78+
userInput.join(),
79+
filterPart,
80+
pathToPayload,
81+
depth + 1
82+
);
83+
} catch {
84+
// Ignore deeply nested arrays that overflow during native join recursion.
85+
}
6686
}
6787

6888
return {

0 commit comments

Comments
 (0)