Skip to content
Merged
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
14 changes: 14 additions & 0 deletions apps/sim/lib/api/contracts/chats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,17 @@ describe('chat deployment password contract', () => {
expect(chatDeploymentPasswordSchema.safeParse('{{PW}}').success).toBe(false)
})
})

describe('deployed chat body size bound', () => {
/**
* The public chat endpoint validates before authenticating. An oversized `files`
* array must fail on its count alone: walking every element first builds an issue
* per malformed entry, which a small body inflates into an out-of-memory process.
*/
it('rejects an oversized files array without validating its entries', () => {
const result = deployedChatPostBodySchema.safeParse({ files: Array(10_000).fill({}) })

expect(result.error?.issues).toHaveLength(1)
expect(result.error?.issues[0]).toMatchObject({ code: 'too_big', path: ['files'] })
})
})
9 changes: 9 additions & 0 deletions apps/sim/lib/knowledge/transfer/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ describe('knowledgeBundleManifestSchema', () => {
).toBe(false)
})

/** The tag cross-checks read each entry, so they must never see an oversized list unparsed. */
it('refuses an oversized tag list on its size before the tag cross-checks read it', () => {
const result = knowledgeBundleManifestSchema.safeParse(manifest({ tags: Array(100).fill({}) }))

expect(result.error?.issues).toEqual([
expect.objectContaining({ code: 'too_big', path: ['tags'] }),
])
})

it('refuses duplicate tag slots and case-insensitively duplicate tag names', () => {
expect(
knowledgeBundleManifestSchema.safeParse(
Expand Down
5 changes: 3 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
"postgres@3.4.9": "patches/postgres@3.4.9.patch",
"oracledb@7.0.1": "patches/oracledb@7.0.1.patch",
"echarts@6.1.0": "patches/echarts@6.1.0.patch",
"next@16.4.1": "patches/next@16.4.1.patch"
"next@16.4.1": "patches/next@16.4.1.patch",
"zod@4.3.6": "patches/zod@4.3.6.patch"
}
}
20 changes: 20 additions & 0 deletions patches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,23 @@ bun run --cwd apps/docs test:negotiation:e2e

Remove the patch when upgrading to a release with equivalent Vary preservation,
and retain the HTTP regression check.

# Zod collection size bounds

`zod@4.3.6` runs an array's `.max()`/`.length()` (and a set's or map's `.max()`/`.size()`)
only after it has parsed every element, so a small body against a bounded schema builds an
issue per malformed element before the bound is reported. An unauthenticated request to the
public chat endpoint can turn under a megabyte of JSON into gigabytes of issues
(GHSA-mrhx-c7xc-m3w5).

The version-pinned patch backports the leading size gate from
[upstream PR #6652](https://github.com/colinhacks/zod/pull/6652), released in 4.6.6, to the
ESM and CommonJS builds. A container larger than its leading upper bound skips its elements
and returns unchanged; its own size check then reports the single `too_big` issue. Any input
within its bounds parses exactly as before. The bound check is skipped, as upstream, for
`skipChecks` and backward (encode) parses.

Zod 4.6 is not a drop-in upgrade: it rejects absent `unknown`/`any` object keys, requires
seconds in ISO datetimes, and emits root `$ref` JSON Schema for schemas with an `id`.
`@trigger.dev/core`, which the root override puts on this zod, relies on the first.
Remove this patch when upgrading zod to 4.6.6 or later.
157 changes: 157 additions & 0 deletions patches/zod@4.3.6.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
diff --git a/node_modules/zod/.bun-tag-82bf292cf1bc8e88 b/.bun-tag-82bf292cf1bc8e88
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/v4/core/schemas.cjs b/v4/core/schemas.cjs
index 8444c1fa122b0502f56b141ffa2939906b49baf9..b1dc056dfe24e7e17927b5b61cbf528fc3830eb8 100644
--- a/v4/core/schemas.cjs
+++ b/v4/core/schemas.cjs
@@ -685,6 +685,48 @@ function handleArrayResult(result, final, index) {
}
final.value[index] = result.value;
}
+// Backport of colinhacks/zod#6652 (zod 4.6.6, GHSA-mrhx-c7xc-m3w5): a container larger than
+// its leading upper size bound fails on that bound before any element is parsed, instead of
+// collecting an issue per element first.
+function sizeCaps(defChecks) {
+ const caps = [];
+ for (const ch of defChecks ?? []) {
+ const d = ch._zod.def;
+ // a later check may rewrite the value
+ if (!/_(length|size)$|_equals$/.test(d.check))
+ break;
+ const max = d.maximum ?? d.length ?? d.size;
+ if (max >= 0)
+ caps.push([max, ch]);
+ }
+ return caps;
+}
+function capSize(inst, size) {
+ const caps = sizeCaps(inst._zod.def.checks);
+ if (!caps.length)
+ return;
+ const cap = Math.min(...caps.map((c) => c[0]));
+ const run = inst._zod.run;
+ // gates `run`, not `parse`: 4.3.6 runs `when`-guarded checks even after an abort, so the
+ // failing bound reports once here and no later check reads the unparsed elements
+ inst._zod.run = (payload, ctx) => {
+ const n = size(payload.value);
+ if (n > cap && !ctx?.skipChecks && ctx?.direction !== "backward") {
+ for (const [max, ch] of caps) {
+ if (n > max && ch._zod.def.when(payload)) {
+ const start = payload.issues.length;
+ ch._zod.check(payload);
+ payload.issues[start].continue = false;
+ return payload;
+ }
+ }
+ }
+ return run(payload, ctx);
+ };
+}
+const arraySize = (input) => (Array.isArray(input) ? input.length : -1);
+const mapSize = (input) => (input instanceof Map ? input.size : -1);
+const setSize = (input) => (input instanceof Set ? input.size : -1);
exports.$ZodArray = core.$constructor("$ZodArray", (inst, def) => {
exports.$ZodType.init(inst, def);
inst._zod.parse = (payload, ctx) => {
@@ -718,6 +760,7 @@ exports.$ZodArray = core.$constructor("$ZodArray", (inst, def) => {
}
return payload; //handleArrayResultsAsync(parseResults, final);
};
+ capSize(inst, arraySize);
});
function handlePropertyResult(result, final, key, input, isOptionalOut) {
if (result.issues.length) {
@@ -1480,6 +1523,7 @@ exports.$ZodMap = core.$constructor("$ZodMap", (inst, def) => {
return Promise.all(proms).then(() => payload);
return payload;
Comment thread
waleedlatif1 marked this conversation as resolved.
};
+ capSize(inst, mapSize);
});
function handleMapResult(keyResult, valueResult, final, key, input, inst, ctx) {
if (keyResult.issues.length) {
@@ -1540,6 +1584,7 @@ exports.$ZodSet = core.$constructor("$ZodSet", (inst, def) => {
return Promise.all(proms).then(() => payload);
return payload;
};
+ capSize(inst, setSize);
});
function handleSetResult(result, final) {
if (result.issues.length) {
diff --git a/v4/core/schemas.js b/v4/core/schemas.js
index 29c61bc5fcf2ca90c2bb0121b7f02e16fdf19f11..1123f503df65ff9993a0e04a4c78dfb3e216b961 100644
--- a/v4/core/schemas.js
+++ b/v4/core/schemas.js
@@ -654,6 +654,48 @@ function handleArrayResult(result, final, index) {
}
final.value[index] = result.value;
}
+// Backport of colinhacks/zod#6652 (zod 4.6.6, GHSA-mrhx-c7xc-m3w5): a container larger than
+// its leading upper size bound fails on that bound before any element is parsed, instead of
+// collecting an issue per element first.
+function sizeCaps(defChecks) {
+ const caps = [];
+ for (const ch of defChecks ?? []) {
+ const d = ch._zod.def;
+ // a later check may rewrite the value
+ if (!/_(length|size)$|_equals$/.test(d.check))
+ break;
+ const max = d.maximum ?? d.length ?? d.size;
+ if (max >= 0)
+ caps.push([max, ch]);
+ }
+ return caps;
+}
+function capSize(inst, size) {
+ const caps = sizeCaps(inst._zod.def.checks);
+ if (!caps.length)
+ return;
+ const cap = Math.min(...caps.map((c) => c[0]));
+ const run = inst._zod.run;
+ // gates `run`, not `parse`: 4.3.6 runs `when`-guarded checks even after an abort, so the
+ // failing bound reports once here and no later check reads the unparsed elements
+ inst._zod.run = (payload, ctx) => {
+ const n = size(payload.value);
+ if (n > cap && !ctx?.skipChecks && ctx?.direction !== "backward") {
+ for (const [max, ch] of caps) {
+ if (n > max && ch._zod.def.when(payload)) {
+ const start = payload.issues.length;
+ ch._zod.check(payload);
+ payload.issues[start].continue = false;
+ return payload;
+ }
+ }
+ }
+ return run(payload, ctx);
+ };
+}
+const arraySize = (input) => (Array.isArray(input) ? input.length : -1);
+const mapSize = (input) => (input instanceof Map ? input.size : -1);
+const setSize = (input) => (input instanceof Set ? input.size : -1);
export const $ZodArray = /*@__PURE__*/ core.$constructor("$ZodArray", (inst, def) => {
$ZodType.init(inst, def);
inst._zod.parse = (payload, ctx) => {
@@ -687,6 +729,7 @@ export const $ZodArray = /*@__PURE__*/ core.$constructor("$ZodArray", (inst, def
}
return payload; //handleArrayResultsAsync(parseResults, final);
};
+ capSize(inst, arraySize);
});
function handlePropertyResult(result, final, key, input, isOptionalOut) {
if (result.issues.length) {
@@ -1449,6 +1492,7 @@ export const $ZodMap = /*@__PURE__*/ core.$constructor("$ZodMap", (inst, def) =>
return Promise.all(proms).then(() => payload);
return payload;
};
+ capSize(inst, mapSize);
});
function handleMapResult(keyResult, valueResult, final, key, input, inst, ctx) {
if (keyResult.issues.length) {
@@ -1509,6 +1553,7 @@ export const $ZodSet = /*@__PURE__*/ core.$constructor("$ZodSet", (inst, def) =>
return Promise.all(proms).then(() => payload);
return payload;
};
+ capSize(inst, setSize);
});
function handleSetResult(result, final) {
if (result.issues.length) {
Loading