Skip to content

Commit 0306e2b

Browse files
Bound input size to prevent heap OOM from wide (not deep) payloads (#126)
The depth cap from the previous commit only protects against deeply-nested input; a shallow-but-huge array (e.g. a "match" expression with millions of branches) sails past it untouched. Confirmed by reproducing under a constrained heap: unlike the stack-overflow case, exhausting the heap is not a catchable JS error - it aborts the whole process (SIGABRT, "FATAL ERROR: Reached heap limit"), taking down every concurrent MCP session, not just the one request. - Cap the JSON-string form of `expression`/`style` in their Zod schemas, so oversized payloads are rejected before JSON.parse ever runs. For validate_expression_tool this has to be a `.refine()` rather than `.max()` on the union member directly, since the sibling `z.any()` branch would otherwise swallow oversized strings right past the length check. - Add an O(1) array-length/object-key-count guard (checked before iterating or copying) alongside the existing depth guard, so the already-parsed object-input path is covered too, and so a single oversized array/object anywhere in the tree is rejected without visiting its elements. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 2bb884b commit 0306e2b

6 files changed

Lines changed: 168 additions & 15 deletions

File tree

‎src/tools/validate-expression-tool/ValidateExpressionTool.input.schema.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,25 @@
33

44
import { z } from 'zod';
55

6+
// Generous for any realistic hand-written or generated expression (even a
7+
// "match" with thousands of branches is a few KB), while bounding the size of
8+
// the JSON.parse'd/validated structure so a pathological payload can't be used
9+
// to exhaust memory before our own validation logic ever runs.
10+
const MAX_EXPRESSION_JSON_LENGTH = 262_144; // 256 KB
11+
612
export const ValidateExpressionInputSchema = z.object({
13+
// `z.any()` in the union matches everything, including oversized strings, so
14+
// `.max()` on the string branch alone would never trigger - the length check
15+
// has to be a refinement applied after the union instead.
716
expression: z
817
.union([z.string(), z.any()])
18+
.refine(
19+
(value) =>
20+
typeof value !== 'string' || value.length <= MAX_EXPRESSION_JSON_LENGTH,
21+
{
22+
message: `Expression JSON string exceeds maximum length of ${MAX_EXPRESSION_JSON_LENGTH} characters`
23+
}
24+
)
925
.describe(
1026
'Mapbox expression to validate (JSON string or expression array)'
1127
),

‎src/tools/validate-expression-tool/ValidateExpressionTool.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ const INTERPOLATION_TYPE_ARG_INDEX: Record<string, number> = {
2929
// This cap is far beyond any realistic hand-written or generated expression.
3030
const MAX_EXPRESSION_DEPTH = 64;
3131

32+
// The depth cap alone doesn't bound memory: a single huge-but-shallow array
33+
// (e.g. a "match" with millions of branches) sails past it untouched, and
34+
// handing that straight to createPropertyExpression can exhaust the heap -
35+
// which, unlike a stack overflow, is not a catchable JS error and crashes the
36+
// whole process. Checking `.length` is O(1), so oversized arrays/objects are
37+
// rejected before we ever iterate into or copy them.
38+
const MAX_ARRAY_LENGTH = 10_000;
39+
3240
// `StylePropertySpecification` only covers concrete typed properties (color, string,
3341
// number, ...), so a literal like `true` or `"red"` would fail type-checking against
3442
// any of them. Using an unrecognized `type` makes createPropertyExpression skip return
@@ -209,6 +217,14 @@ export class ValidateExpressionTool extends BaseTool<
209217

210218
if (!Array.isArray(expression)) {
211219
if (typeof expression === 'object') {
220+
if (Object.keys(expression).length > MAX_ARRAY_LENGTH) {
221+
errors.push({
222+
severity: 'error',
223+
message: `Expression object exceeds maximum size of ${MAX_ARRAY_LENGTH} properties`,
224+
path: path || 'root'
225+
});
226+
return { depth, blocking: true };
227+
}
212228
return { expressionType: 'literal-object', depth, blocking: false };
213229
}
214230
errors.push({
@@ -219,6 +235,15 @@ export class ValidateExpressionTool extends BaseTool<
219235
return { depth, blocking: true };
220236
}
221237

238+
if (expression.length > MAX_ARRAY_LENGTH) {
239+
errors.push({
240+
severity: 'error',
241+
message: `Expression array exceeds maximum size of ${MAX_ARRAY_LENGTH} elements`,
242+
path: path || 'root'
243+
});
244+
return { depth, blocking: true };
245+
}
246+
222247
if (expression.length === 0) {
223248
errors.push({
224249
severity: 'error',

‎src/tools/validate-style-tool/ValidateStyleTool.input.schema.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,24 @@
33

44
import { z } from 'zod';
55

6+
// Generous for any real-world style (full styles with many layers/sources are
7+
// typically well under 1 MB), while bounding the size of the JSON.parse'd
8+
// structure so a pathological payload can't be used to exhaust memory before
9+
// our own validation logic ever runs. Unlike ValidateExpressionInputSchema,
10+
// `.max()` on the string branch alone is sufficient here since the object
11+
// branch (`z.record`) doesn't also match strings.
12+
const MAX_STYLE_JSON_LENGTH = 10 * 1024 * 1024; // 10 MB
13+
614
/**
715
* Input schema for ValidateStyleTool
816
* Validates Mapbox GL JS style JSON against the style specification
917
*/
1018
export const ValidateStyleInputSchema = z.object({
1119
style: z
12-
.union([z.string(), z.record(z.string(), z.unknown())])
20+
.union([
21+
z.string().max(MAX_STYLE_JSON_LENGTH),
22+
z.record(z.string(), z.unknown())
23+
])
1324
.describe(
1425
'Mapbox style JSON object or JSON string to validate against the Mapbox Style Specification'
1526
)

‎src/tools/validate-style-tool/ValidateStyleTool.ts‎

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,19 +47,49 @@ function splitPathFromMessage(message: string): {
4747
// arbitrarily deep input without risking the same problem.
4848
const MAX_STYLE_DEPTH = 64;
4949

50-
function exceedsMaxDepth(value: unknown, depth = 0): boolean {
50+
// Depth alone doesn't bound memory: a single huge-but-shallow array (e.g. a
51+
// paint expression's "match" with millions of branches) sails past the depth
52+
// cap untouched, and handing that straight to validateMapboxStyle can exhaust
53+
// the heap - which, unlike a stack overflow, is not a catchable JS error and
54+
// crashes the whole process. Checking `.length`/key count is O(1), so
55+
// oversized arrays/objects are rejected before we ever iterate into them.
56+
const MAX_STYLE_COLLECTION_SIZE = 10_000;
57+
58+
type LimitViolation = 'depth' | 'size' | null;
59+
60+
function findLimitViolation(value: unknown, depth = 0): LimitViolation {
5161
if (depth > MAX_STYLE_DEPTH) {
52-
return true;
62+
return 'depth';
5363
}
5464
if (Array.isArray(value)) {
55-
return value.some((item) => exceedsMaxDepth(item, depth + 1));
65+
if (value.length > MAX_STYLE_COLLECTION_SIZE) {
66+
return 'size';
67+
}
68+
for (const item of value) {
69+
const violation = findLimitViolation(item, depth + 1);
70+
if (violation) {
71+
return violation;
72+
}
73+
}
74+
return null;
5675
}
5776
if (value && typeof value === 'object') {
58-
return Object.values(value).some((item) =>
59-
exceedsMaxDepth(item, depth + 1)
60-
);
77+
const keys = Object.keys(value);
78+
if (keys.length > MAX_STYLE_COLLECTION_SIZE) {
79+
return 'size';
80+
}
81+
for (const key of keys) {
82+
const violation = findLimitViolation(
83+
(value as Record<string, unknown>)[key],
84+
depth + 1
85+
);
86+
if (violation) {
87+
return violation;
88+
}
89+
}
90+
return null;
6191
}
62-
return false;
92+
return null;
6393
}
6494

6595
/**
@@ -128,14 +158,14 @@ export class ValidateStyleTool extends BaseTool<
128158
style = input.style as MapboxStyle;
129159
}
130160

131-
if (exceedsMaxDepth(style)) {
161+
const limitViolation = findLimitViolation(style);
162+
if (limitViolation) {
163+
const message =
164+
limitViolation === 'depth'
165+
? `style exceeds maximum nesting depth of ${MAX_STYLE_DEPTH}`
166+
: `style contains an array or object exceeding maximum size of ${MAX_STYLE_COLLECTION_SIZE} elements`;
132167
return {
133-
content: [
134-
{
135-
type: 'text',
136-
text: `Error: style exceeds maximum nesting depth of ${MAX_STYLE_DEPTH}`
137-
}
138-
],
168+
content: [{ type: 'text', text: `Error: ${message}` }],
139169
isError: true
140170
};
141171
}

‎test/tools/validate-expression-tool/ValidateExpressionTool.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,37 @@ describe('ValidateExpressionTool', () => {
339339
)
340340
).toBe(true);
341341
});
342+
343+
it('should reject a wide (shallow but huge) expression instead of exhausting memory', async () => {
344+
// Depth stays ~1 here - this is the vector the depth cap alone does not catch.
345+
const expression: any = ['match', ['get', 'x']];
346+
for (let i = 0; i < 20_000; i++) {
347+
expression.push(i, 'red');
348+
}
349+
expression.push('blue');
350+
351+
const start = Date.now();
352+
const result = await tool.run({ expression });
353+
expect(Date.now() - start).toBeLessThan(1000);
354+
355+
expect(result.isError).toBe(false);
356+
const parsed = JSON.parse(result.content[0].text);
357+
expect(parsed.valid).toBe(false);
358+
expect(
359+
parsed.errors.some((e: any) =>
360+
e.message.includes('exceeds maximum size')
361+
)
362+
).toBe(true);
363+
});
364+
365+
it('should reject a JSON string expression exceeding the maximum length', async () => {
366+
const expression = JSON.stringify(['get', 'x'.repeat(300_000)]);
367+
368+
const result = await tool.run({ expression });
369+
370+
expect(result.isError).toBe(true);
371+
expect(result.content[0].text).toContain('exceeds maximum length');
372+
});
342373
});
343374

344375
describe('nested expressions', () => {

‎test/tools/validate-style-tool/ValidateStyleTool.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,5 +357,45 @@ describe('ValidateStyleTool', () => {
357357
expect(result.isError).toBe(true);
358358
expect(result.content[0].text).toContain('exceeds maximum nesting depth');
359359
});
360+
361+
it('should reject a style with a wide (shallow but huge) expression instead of exhausting memory', async () => {
362+
// Depth stays ~1 here - this is the vector the depth cap alone does not catch.
363+
const expression: any = ['match', ['get', 'x']];
364+
for (let i = 0; i < 20_000; i++) {
365+
expression.push(i, 'red');
366+
}
367+
expression.push('blue');
368+
const style = {
369+
version: 8,
370+
sources: {},
371+
layers: [
372+
{
373+
id: 'l',
374+
type: 'background',
375+
paint: { 'background-opacity': expression }
376+
}
377+
]
378+
};
379+
380+
const start = Date.now();
381+
const result = await tool.run({ style });
382+
expect(Date.now() - start).toBeLessThan(1000);
383+
384+
expect(result.isError).toBe(true);
385+
expect(result.content[0].text).toContain('exceeding maximum size');
386+
});
387+
388+
it('should reject a JSON string style exceeding the maximum length', async () => {
389+
const style = JSON.stringify({
390+
version: 8,
391+
sources: {},
392+
layers: [],
393+
padding: 'x'.repeat(11 * 1024 * 1024)
394+
});
395+
396+
const result = await tool.run({ style });
397+
398+
expect(result.isError).toBe(true);
399+
});
360400
});
361401
});

0 commit comments

Comments
 (0)