Skip to content

Commit 87b761d

Browse files
committed
fix(deps): backport zod's leading collection size check so oversized bodies stop validating every element
1 parent cf571cd commit 87b761d

5 files changed

Lines changed: 171 additions & 3 deletions

File tree

‎apps/sim/lib/api/contracts/chats.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,17 @@ describe('chat deployment password contract', () => {
6161
expect(chatDeploymentPasswordSchema.safeParse('{{PW}}').success).toBe(false)
6262
})
6363
})
64+
65+
describe('deployed chat body size bound', () => {
66+
/**
67+
* The public chat endpoint validates before authenticating. An oversized `files`
68+
* array must fail on its count alone: walking every element first builds an issue
69+
* per malformed entry, which a small body inflates into an out-of-memory process.
70+
*/
71+
it('rejects an oversized files array without validating its entries', () => {
72+
const result = deployedChatPostBodySchema.safeParse({ files: Array(10_000).fill({}) })
73+
74+
expect(result.error?.issues).toHaveLength(1)
75+
expect(result.error?.issues[0]).toMatchObject({ code: 'too_big', path: ['files'] })
76+
})
77+
})

‎bun.lock‎

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@
231231
"postgres@3.4.9": "patches/postgres@3.4.9.patch",
232232
"oracledb@7.0.1": "patches/oracledb@7.0.1.patch",
233233
"echarts@6.1.0": "patches/echarts@6.1.0.patch",
234-
"next@16.4.1": "patches/next@16.4.1.patch"
234+
"next@16.4.1": "patches/next@16.4.1.patch",
235+
"zod@4.3.6": "patches/zod@4.3.6.patch"
235236
}
236237
}

‎patches/README.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,23 @@ bun run --cwd apps/docs test:negotiation:e2e
129129

130130
Remove the patch when upgrading to a release with equivalent Vary preservation,
131131
and retain the HTTP regression check.
132+
133+
# Zod collection size bounds
134+
135+
`zod@4.3.6` runs an array's `.max()`/`.length()` (and a set's or map's `.max()`/`.size()`)
136+
only after it has parsed every element, so a small body against a bounded schema builds an
137+
issue per malformed element before the bound is reported. An unauthenticated request to the
138+
public chat endpoint can turn under a megabyte of JSON into gigabytes of issues
139+
(GHSA-mrhx-c7xc-m3w5).
140+
141+
The version-pinned patch backports the leading size gate from
142+
[upstream PR #6652](https://github.com/colinhacks/zod/pull/6652), released in 4.6.6, to the
143+
ESM and CommonJS builds. A container larger than its leading upper bound skips its elements
144+
and returns unchanged; its own size check then reports the single `too_big` issue. Any input
145+
within its bounds parses exactly as before. The bound check is skipped, as upstream, for
146+
`skipChecks` and backward (encode) parses.
147+
148+
Zod 4.6 is not a drop-in upgrade: it rejects absent `unknown`/`any` object keys, requires
149+
seconds in ISO datetimes, and emits root `$ref` JSON Schema for schemas with an `id`.
150+
`@trigger.dev/core`, which the root override puts on this zod, relies on the first.
151+
Remove this patch when upgrading zod to 4.6.6 or later.

‎patches/zod@4.3.6.patch‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
diff --git a/v4/core/schemas.cjs b/v4/core/schemas.cjs
2+
index 8444c1fa122b0502f56b141ffa2939906b49baf9..9a085e4beacdbe1ef84c4c6a30b471b4aa9f9625 100644
3+
--- a/v4/core/schemas.cjs
4+
+++ b/v4/core/schemas.cjs
5+
@@ -685,6 +685,37 @@ function handleArrayResult(result, final, index) {
6+
}
7+
final.value[index] = result.value;
8+
}
9+
+// Backport of colinhacks/zod#6652 (zod 4.6.6, GHSA-mrhx-c7xc-m3w5): a container larger than
10+
+// its leading upper size bound skips its elements, so that bound's check (which has a `when`
11+
+// guard and always runs) reports it without parsing and collecting an issue per element first.
12+
+function sizeCaps(defChecks) {
13+
+ const caps = [];
14+
+ for (const ch of defChecks ?? []) {
15+
+ const d = ch._zod.def;
16+
+ // a later check may rewrite the value
17+
+ if (!/_(length|size)$|_equals$/.test(d.check))
18+
+ break;
19+
+ const max = d.maximum ?? d.length ?? d.size;
20+
+ if (max >= 0)
21+
+ caps.push(max);
22+
+ }
23+
+ return caps;
24+
+}
25+
+function capSize(inst, size) {
26+
+ const caps = sizeCaps(inst._zod.def.checks);
27+
+ if (!caps.length)
28+
+ return;
29+
+ const cap = Math.min(...caps);
30+
+ const parse = inst._zod.parse;
31+
+ inst._zod.parse = (payload, ctx) => {
32+
+ if (size(payload.value) > cap && !ctx?.skipChecks && ctx?.direction !== "backward")
33+
+ return payload;
34+
+ return parse(payload, ctx);
35+
+ };
36+
+}
37+
+const arraySize = (input) => (Array.isArray(input) ? input.length : -1);
38+
+const mapSize = (input) => (input instanceof Map ? input.size : -1);
39+
+const setSize = (input) => (input instanceof Set ? input.size : -1);
40+
exports.$ZodArray = core.$constructor("$ZodArray", (inst, def) => {
41+
exports.$ZodType.init(inst, def);
42+
inst._zod.parse = (payload, ctx) => {
43+
@@ -718,6 +749,7 @@ exports.$ZodArray = core.$constructor("$ZodArray", (inst, def) => {
44+
}
45+
return payload; //handleArrayResultsAsync(parseResults, final);
46+
};
47+
+ capSize(inst, arraySize);
48+
});
49+
function handlePropertyResult(result, final, key, input, isOptionalOut) {
50+
if (result.issues.length) {
51+
@@ -1480,6 +1512,7 @@ exports.$ZodMap = core.$constructor("$ZodMap", (inst, def) => {
52+
return Promise.all(proms).then(() => payload);
53+
return payload;
54+
};
55+
+ capSize(inst, mapSize);
56+
});
57+
function handleMapResult(keyResult, valueResult, final, key, input, inst, ctx) {
58+
if (keyResult.issues.length) {
59+
@@ -1540,6 +1573,7 @@ exports.$ZodSet = core.$constructor("$ZodSet", (inst, def) => {
60+
return Promise.all(proms).then(() => payload);
61+
return payload;
62+
};
63+
+ capSize(inst, setSize);
64+
});
65+
function handleSetResult(result, final) {
66+
if (result.issues.length) {
67+
diff --git a/v4/core/schemas.js b/v4/core/schemas.js
68+
index 29c61bc5fcf2ca90c2bb0121b7f02e16fdf19f11..f87884eea6e301f1c9c46be45953226e850e4795 100644
69+
--- a/v4/core/schemas.js
70+
+++ b/v4/core/schemas.js
71+
@@ -654,6 +654,37 @@ function handleArrayResult(result, final, index) {
72+
}
73+
final.value[index] = result.value;
74+
}
75+
+// Backport of colinhacks/zod#6652 (zod 4.6.6, GHSA-mrhx-c7xc-m3w5): a container larger than
76+
+// its leading upper size bound skips its elements, so that bound's check (which has a `when`
77+
+// guard and always runs) reports it without parsing and collecting an issue per element first.
78+
+function sizeCaps(defChecks) {
79+
+ const caps = [];
80+
+ for (const ch of defChecks ?? []) {
81+
+ const d = ch._zod.def;
82+
+ // a later check may rewrite the value
83+
+ if (!/_(length|size)$|_equals$/.test(d.check))
84+
+ break;
85+
+ const max = d.maximum ?? d.length ?? d.size;
86+
+ if (max >= 0)
87+
+ caps.push(max);
88+
+ }
89+
+ return caps;
90+
+}
91+
+function capSize(inst, size) {
92+
+ const caps = sizeCaps(inst._zod.def.checks);
93+
+ if (!caps.length)
94+
+ return;
95+
+ const cap = Math.min(...caps);
96+
+ const parse = inst._zod.parse;
97+
+ inst._zod.parse = (payload, ctx) => {
98+
+ if (size(payload.value) > cap && !ctx?.skipChecks && ctx?.direction !== "backward")
99+
+ return payload;
100+
+ return parse(payload, ctx);
101+
+ };
102+
+}
103+
+const arraySize = (input) => (Array.isArray(input) ? input.length : -1);
104+
+const mapSize = (input) => (input instanceof Map ? input.size : -1);
105+
+const setSize = (input) => (input instanceof Set ? input.size : -1);
106+
export const $ZodArray = /*@__PURE__*/ core.$constructor("$ZodArray", (inst, def) => {
107+
$ZodType.init(inst, def);
108+
inst._zod.parse = (payload, ctx) => {
109+
@@ -687,6 +718,7 @@ export const $ZodArray = /*@__PURE__*/ core.$constructor("$ZodArray", (inst, def
110+
}
111+
return payload; //handleArrayResultsAsync(parseResults, final);
112+
};
113+
+ capSize(inst, arraySize);
114+
});
115+
function handlePropertyResult(result, final, key, input, isOptionalOut) {
116+
if (result.issues.length) {
117+
@@ -1449,6 +1481,7 @@ export const $ZodMap = /*@__PURE__*/ core.$constructor("$ZodMap", (inst, def) =>
118+
return Promise.all(proms).then(() => payload);
119+
return payload;
120+
};
121+
+ capSize(inst, mapSize);
122+
});
123+
function handleMapResult(keyResult, valueResult, final, key, input, inst, ctx) {
124+
if (keyResult.issues.length) {
125+
@@ -1509,6 +1542,7 @@ export const $ZodSet = /*@__PURE__*/ core.$constructor("$ZodSet", (inst, def) =>
126+
return Promise.all(proms).then(() => payload);
127+
return payload;
128+
};
129+
+ capSize(inst, setSize);
130+
});
131+
function handleSetResult(result, final) {
132+
if (result.issues.length) {

0 commit comments

Comments
 (0)