From ec5cfbad37ed3a2a4944129bf8036ad4979dd9e5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:29:15 -0700 Subject: [PATCH] fix(deps): patch vulnerable Zod collections in mobile and docs (#32941) --- config/dependency-security-exceptions.json | 26 +++ config/patches/zod@3.25.76.patch | 175 +++++++++++++++++++++ docs/site/package.json | 2 +- docs/site/pnpm-lock.yaml | 66 ++++---- docs/site/pnpm-workspace.yaml | 4 + docs/site/tests/zod-security.test.mjs | 52 ++++++ mobile/patches/zod@3.25.76.patch | 175 +++++++++++++++++++++ mobile/pnpm-lock.yaml | 6 +- mobile/pnpm-workspace.yaml | 3 + mobile/scripts/zod-security.test.ts | 106 +++++++++++++ 10 files changed, 578 insertions(+), 37 deletions(-) create mode 100644 config/patches/zod@3.25.76.patch create mode 100644 docs/site/tests/zod-security.test.mjs create mode 100644 mobile/patches/zod@3.25.76.patch create mode 100644 mobile/scripts/zod-security.test.ts diff --git a/config/dependency-security-exceptions.json b/config/dependency-security-exceptions.json index 3ce690c757c..e6eceec5030 100644 --- a/config/dependency-security-exceptions.json +++ b/config/dependency-security-exceptions.json @@ -50,5 +50,31 @@ "path": "patches/node-forge@1.4.0.patch", "sha256": "2798686e4488058ad81c3b8e32eb763c0feff21f4c717ce4d13b6ef2432cd5b8" } + }, + { + "workspace": "mobile", + "package": "zod", + "version": "3.25.76", + "advisory": "GHSA-mrhx-c7xc-m3w5", + "owner": "Orca maintainers", + "reason": "Expo and Vercel require Zod 3, which has no fixed upstream release. Backport the upper collection-size gate from colinhacks/zod#6652 for the v3 and bundled v4 entrypoints; installed-dependency regression tests cover sync and async parsing. Remove when a fixed Zod 3 release or compatible consumer upgrade is available.", + "expires": "2026-11-20", + "patch": { + "path": "patches/zod@3.25.76.patch", + "sha256": "8f57d8e7910df51ec3c40c412b4f8fb3d01fd65e1889c057aae0e4a15483bcec" + } + }, + { + "workspace": "docs/site", + "package": "zod", + "version": "3.25.76", + "advisory": "GHSA-mrhx-c7xc-m3w5", + "owner": "Orca maintainers", + "reason": "Expo and Vercel require Zod 3, which has no fixed upstream release. Backport the upper collection-size gate from colinhacks/zod#6652 for the v3 and bundled v4 entrypoints; installed-dependency regression tests cover sync and async parsing. Remove when a fixed Zod 3 release or compatible consumer upgrade is available.", + "expires": "2026-11-20", + "patch": { + "path": "../../config/patches/zod@3.25.76.patch", + "sha256": "8f57d8e7910df51ec3c40c412b4f8fb3d01fd65e1889c057aae0e4a15483bcec" + } } ] diff --git a/config/patches/zod@3.25.76.patch b/config/patches/zod@3.25.76.patch new file mode 100644 index 00000000000..51b83e79906 --- /dev/null +++ b/config/patches/zod@3.25.76.patch @@ -0,0 +1,175 @@ +diff --git a/src/v3/types.ts b/src/v3/types.ts +--- a/src/v3/types.ts ++++ b/src/v3/types.ts +@@ -2263,6 +2263,7 @@ + exact: true, + message: def.exactLength.message, + }); ++ if (tooBig) return INVALID; + status.dirty(); + } + } +@@ -2291,7 +2292,7 @@ + exact: false, + message: def.maxLength.message, + }); +- status.dirty(); ++ return INVALID; + } + } + +@@ -3723,7 +3724,7 @@ + exact: false, + message: def.maxSize.message, + }); +- status.dirty(); ++ return INVALID; + } + } + +diff --git a/v3/types.js b/v3/types.js +--- a/v3/types.js ++++ b/v3/types.js +@@ -1769,6 +1769,7 @@ + exact: true, + message: def.exactLength.message, + }); ++ if (tooBig) return INVALID; + status.dirty(); + } + } +@@ -1795,7 +1796,7 @@ + exact: false, + message: def.maxLength.message, + }); +- status.dirty(); ++ return INVALID; + } + } + if (ctx.common.async) { +@@ -2814,7 +2815,7 @@ + exact: false, + message: def.maxSize.message, + }); +- status.dirty(); ++ return INVALID; + } + } + const valueType = this._def.valueType; +diff --git a/v3/types.cjs b/v3/types.cjs +--- a/v3/types.cjs ++++ b/v3/types.cjs +@@ -1790,6 +1790,7 @@ + exact: true, + message: def.exactLength.message, + }); ++ if (tooBig) return parseUtil_js_1.INVALID; + status.dirty(); + } + } +@@ -1816,7 +1817,7 @@ + exact: false, + message: def.maxLength.message, + }); +- status.dirty(); ++ return parseUtil_js_1.INVALID; + } + } + if (ctx.common.async) { +@@ -2843,7 +2844,7 @@ + exact: false, + message: def.maxSize.message, + }); +- status.dirty(); ++ return parseUtil_js_1.INVALID; + } + } + const valueType = this._def.valueType; +diff --git a/src/v4/core/schemas.ts b/src/v4/core/schemas.ts +--- a/src/v4/core/schemas.ts ++++ b/src/v4/core/schemas.ts +@@ -242,6 +242,26 @@ + }; + + inst._zod.run = (payload, ctx) => { ++ const value = payload.value; ++ const size = def.type === "array" && Array.isArray(value) ? value.length ++ : (def.type === "map" && value instanceof Map) || (def.type === "set" && value instanceof Set) ? value.size : -1; ++ if (size >= 0) { ++ for (const ch of checks) { ++ const checkDef = ch._zod.def; ++ if (!["min_length", "max_length", "length_equals", "min_size", "max_size", "size_equals"].includes(checkDef.check)) break; ++ const maximum = "maximum" in checkDef ? checkDef.maximum ++ : "length" in checkDef ? checkDef.length : "size" in checkDef ? checkDef.size : undefined; ++ if (typeof maximum === "number" && size > maximum && (!checkDef.when || checkDef.when(payload))) { ++ const start = payload.issues.length; ++ // SAFETY: Built-in size checks run only after the matching collection type guard. ++ ch._zod.check(payload as ParsePayload); ++ for (let i = start; i < payload.issues.length; i++) { ++ payload.issues[i] = { ...payload.issues[i], continue: false }; ++ } ++ return payload; ++ } ++ } ++ } + const result = inst._zod.parse(payload, ctx); + + if (result instanceof Promise) { +diff --git a/v4/core/schemas.js b/v4/core/schemas.js +--- a/v4/core/schemas.js ++++ b/v4/core/schemas.js +@@ -74,6 +74,25 @@ + return payload; + }; + inst._zod.run = (payload, ctx) => { ++ const value = payload.value; ++ const size = def.type === "array" && Array.isArray(value) ? value.length ++ : (def.type === "map" && value instanceof Map) || (def.type === "set" && value instanceof Set) ? value.size : -1; ++ if (size >= 0) { ++ for (const ch of checks) { ++ const checkDef = ch._zod.def; ++ if (!["min_length", "max_length", "length_equals", "min_size", "max_size", "size_equals"].includes(checkDef.check)) break; ++ const maximum = "maximum" in checkDef ? checkDef.maximum ++ : "length" in checkDef ? checkDef.length : "size" in checkDef ? checkDef.size : undefined; ++ if (typeof maximum === "number" && size > maximum && (!checkDef.when || checkDef.when(payload))) { ++ const start = payload.issues.length; ++ ch._zod.check(payload); ++ for (let i = start; i < payload.issues.length; i++) { ++ payload.issues[i] = { ...payload.issues[i], continue: false }; ++ } ++ return payload; ++ } ++ } ++ } + const result = inst._zod.parse(payload, ctx); + if (result instanceof Promise) { + if (ctx.async === false) +diff --git a/v4/core/schemas.cjs b/v4/core/schemas.cjs +--- a/v4/core/schemas.cjs ++++ b/v4/core/schemas.cjs +@@ -104,6 +104,25 @@ + return payload; + }; + inst._zod.run = (payload, ctx) => { ++ const value = payload.value; ++ const size = def.type === "array" && Array.isArray(value) ? value.length ++ : (def.type === "map" && value instanceof Map) || (def.type === "set" && value instanceof Set) ? value.size : -1; ++ if (size >= 0) { ++ for (const ch of checks) { ++ const checkDef = ch._zod.def; ++ if (!["min_length", "max_length", "length_equals", "min_size", "max_size", "size_equals"].includes(checkDef.check)) break; ++ const maximum = "maximum" in checkDef ? checkDef.maximum ++ : "length" in checkDef ? checkDef.length : "size" in checkDef ? checkDef.size : undefined; ++ if (typeof maximum === "number" && size > maximum && (!checkDef.when || checkDef.when(payload))) { ++ const start = payload.issues.length; ++ ch._zod.check(payload); ++ for (let i = start; i < payload.issues.length; i++) { ++ payload.issues[i] = { ...payload.issues[i], continue: false }; ++ } ++ return payload; ++ } ++ } ++ } + const result = inst._zod.parse(payload, ctx); + if (result instanceof Promise) { + if (ctx.async === false) diff --git a/docs/site/package.json b/docs/site/package.json index 99522797599..8a4979d20e8 100644 --- a/docs/site/package.json +++ b/docs/site/package.json @@ -23,7 +23,7 @@ "react-dom": "19.2.8", "tailwind-merge": "^3.7.0", "tw-animate-css": "^1.4.0", - "zod": "^4" + "zod": "~4.6.7" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/docs/site/pnpm-lock.yaml b/docs/site/pnpm-lock.yaml index be6c2e892be..5b64f854b24 100644 --- a/docs/site/pnpm-lock.yaml +++ b/docs/site/pnpm-lock.yaml @@ -163,6 +163,8 @@ settings: excludeLinksFromLockfile: false overrides: + zod@>=3 <4: 3.25.76 + zod@>=4 <4.6.6: 4.6.7 path-to-regexp@>=4.0.0 <6.3.0: 6.3.0 '@vercel/node>undici': 6.28.1 '@vercel/python-analysis>js-yaml': 4.3.2 @@ -180,6 +182,7 @@ overrides: patchedDependencies: braces@3.0.3: c07bb14cec04bc891e1fe529720444cc9729a25117f9aaf24253cfe3132a34fd + zod@3.25.76: 8f57d8e7910df51ec3c40c412b4f8fb3d01fd65e1889c057aae0e4a15483bcec importers: @@ -190,13 +193,13 @@ importers: version: 2.1.1 fumadocs-core: specifier: ^16.8.4 - version: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + version: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.7) fumadocs-mdx: specifier: ^14.3.1 - version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) + version: 14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.7))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) fumadocs-ui: specifier: ^16.8.4 - version: 16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) + version: 16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.7))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) lucide-react: specifier: ^1.6.0 version: 1.26.0(react@19.2.8) @@ -216,8 +219,8 @@ importers: specifier: ^1.4.0 version: 1.4.0 zod: - specifier: ^4 - version: 4.4.3 + specifier: ~4.6.7 + version: 4.6.7 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -2604,7 +2607,7 @@ packages: react-dom: ^19.2.0 react-router: 7.x.x || 8.x.x waku: '*' - zod: 4.x.x + zod: 4.6.7 peerDependenciesMeta: '@mdx-js/mdx': optional: true @@ -3875,14 +3878,11 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - zod@3.22.4: - resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.1.11: - resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} - - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -5166,7 +5166,7 @@ snapshots: srvx: 0.11.16 ts-morph: 12.0.0 tsx: 4.21.0 - zod: 3.22.4 + zod: 3.25.76(patch_hash=8f57d8e7910df51ec3c40c412b4f8fb3d01fd65e1889c057aae0e4a15483bcec) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -5205,17 +5205,17 @@ snapshots: '@vercel/cli-config': 0.3.1 async-listen: 3.0.0 open: 8.4.0 - zod: 4.1.11 + zod: 4.6.7 '@vercel/cli-config@0.2.4': dependencies: xdg-app-paths: 5.1.0 - zod: 4.1.11 + zod: 4.6.7 '@vercel/cli-config@0.3.1': dependencies: xdg-app-paths: 5.1.0 - zod: 4.1.11 + zod: 4.6.7 '@vercel/cli-exec@1.0.1': dependencies: @@ -5253,7 +5253,7 @@ snapshots: fs-extra: 11.1.0 path-to-regexp: 8.4.0 ts-morph: 12.0.0 - zod: 3.22.4 + zod: 3.25.76(patch_hash=8f57d8e7910df51ec3c40c412b4f8fb3d01fd65e1889c057aae0e4a15483bcec) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -5332,7 +5332,7 @@ snapshots: fs-extra: 11.1.0 path-to-regexp: 8.4.0 ts-morph: 12.0.0 - zod: 3.22.4 + zod: 3.25.76(patch_hash=8f57d8e7910df51ec3c40c412b4f8fb3d01fd65e1889c057aae0e4a15483bcec) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -5450,7 +5450,7 @@ snapshots: js-yaml: 4.3.2 minimatch: 10.2.6 smol-toml: 1.9.1 - zod: 3.22.4 + zod: 3.25.76(patch_hash=8f57d8e7910df51ec3c40c412b4f8fb3d01fd65e1889c057aae0e4a15483bcec) '@vercel/python@25.0.0(@vercel/build-utils@14.21.0)': dependencies: @@ -5511,7 +5511,7 @@ snapshots: tar-stream: 3.1.7 undici: 7.29.1 xdg-app-paths: 5.1.0 - zod: 4.4.3 + zod: 4.6.7 transitivePeerDependencies: - bare-abort-controller - react-native-b4a @@ -5950,7 +5950,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3): + fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.7): dependencies: '@orama/orama': 3.1.18 estree-util-value-to-estree: 3.5.0 @@ -5980,18 +5980,18 @@ snapshots: next: 16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - zod: 4.4.3 + zod: 4.6.7 transitivePeerDependencies: - supports-color - fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): + fumadocs-mdx@14.3.2(@types/mdast@4.0.4)(@types/mdx@2.0.14)(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.7))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.1.0 chokidar: 5.0.0 esbuild: 0.28.1 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.7) js-yaml: 4.3.2 mdast-util-mdx: 3.0.0 mdast-util-to-markdown: 2.1.2 @@ -6003,7 +6003,7 @@ snapshots: unist-util-remove-position: 5.0.0 unist-util-visit: 5.1.0 vfile: 6.0.3 - zod: 4.4.3 + zod: 4.6.7 optionalDependencies: '@types/mdast': 4.0.4 '@types/mdx': 2.0.14 @@ -6013,7 +6013,7 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): + fumadocs-ui@16.12.1(@types/mdx@2.0.14)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(fumadocs-core@16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.7))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3): dependencies: '@fuma-translate/react': 1.0.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@fumadocs/tailwind': 0.1.1(tailwindcss@4.3.3) @@ -6029,7 +6029,7 @@ snapshots: '@radix-ui/react-tabs': 1.1.19(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) class-variance-authority: 0.7.1 cnfast: 0.0.8 - fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.4.3) + fumadocs-core: 16.12.1(@mdx-js/mdx@3.1.1)(@types/estree-jsx@1.0.5)(@types/hast@3.0.5)(@types/mdast@4.0.4)(@types/react@19.2.18)(lucide-react@1.26.0(react@19.2.8))(next@16.4.2(@types/node@24.19.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(zod@4.6.7) lucide-react: 1.26.0(react@19.2.8) motion: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) next-themes: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -7259,7 +7259,7 @@ snapshots: async-retry: 1.3.3 debug: 4.4.3 ws: 8.21.3 - zod: 4.4.3 + zod: 4.6.7 transitivePeerDependencies: - bare-abort-controller - bufferutil @@ -7570,7 +7570,7 @@ snapshots: smol-toml: 1.9.1 undici: 6.28.1 uuid: 14.0.1 - zod: 4.1.11 + zod: 4.6.7 optionalDependencies: '@vercel/vc-native-darwin-arm64': 64.0.2 '@vercel/vc-native-darwin-x64': 64.0.2 @@ -7651,10 +7651,8 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 - zod@3.22.4: {} + zod@3.25.76(patch_hash=8f57d8e7910df51ec3c40c412b4f8fb3d01fd65e1889c057aae0e4a15483bcec): {} - zod@4.1.11: {} - - zod@4.4.3: {} + zod@4.6.7: {} zwitch@2.0.4: {} diff --git a/docs/site/pnpm-workspace.yaml b/docs/site/pnpm-workspace.yaml index 881c422fd11..5f2da55f358 100644 --- a/docs/site/pnpm-workspace.yaml +++ b/docs/site/pnpm-workspace.yaml @@ -5,6 +5,9 @@ allowBuilds: sharp: true overrides: + # Vercel still requires Zod 3; pin the version with our security backport. + zod@>=3 <4: 3.25.76 + zod@>=4 <4.6.6: 4.6.7 path-to-regexp@>=4.0.0 <6.3.0: 6.3.0 '@vercel/node>undici': 6.28.1 '@vercel/python-analysis>js-yaml': 4.3.2 @@ -21,4 +24,5 @@ overrides: '@vercel/container>smol-toml': 1.9.1 patchedDependencies: + zod@3.25.76: ../../config/patches/zod@3.25.76.patch braces@3.0.3: ../../config/patches/braces@3.0.3.patch diff --git a/docs/site/tests/zod-security.test.mjs b/docs/site/tests/zod-security.test.mjs new file mode 100644 index 00000000000..8308ff558a4 --- /dev/null +++ b/docs/site/tests/zod-security.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import test from 'node:test' +import { pathToFileURL } from 'node:url' + +const require = createRequire(import.meta.url) +const vercelRequire = createRequire(require.resolve('vercel/package.json')) +const backendsRequire = createRequire(vercelRequire.resolve('@vercel/backends/package.json')) +const zodRoot = dirname(backendsRequire.resolve('zod/package.json')) + +for (const extension of ['cjs', 'js']) { + for (const entry of ['index', 'v3/index', 'v4/index']) { + test(`Vercel Zod rejects oversized collections before parsing elements (${entry}.${extension})`, async () => { + const path = join(zodRoot, `${entry}.${extension}`) + const z = extension === 'cjs' ? require(path) : await import(pathToFileURL(path).href) + let parsed = 0 + const element = z.preprocess((value) => { + parsed += 1 + return value + }, z.string()) + for (const [schema, value, valid] of [ + [z.array(element).max(2), Array(1_000).fill(0), ['a']], + [z.array(element).length(2), Array(1_000).fill(0), ['a', 'b']], + [z.set(element).max(2), new Set([1, 2, 3]), new Set(['a'])], + [z.set(element).size(2), new Set([1, 2, 3]), new Set(['a', 'b'])] + ]) { + parsed = 0 + for (const result of [schema.safeParse(value), await schema.safeParseAsync(value)]) { + assert.equal(result.success, false) + assert.deepEqual( + result.error.issues.map((issue) => issue.code), + ['too_big'] + ) + } + assert.equal(parsed, 0) + assert.deepEqual(schema.parse(valid), valid) + assert.deepEqual(await schema.parseAsync(valid), valid) + } + }) + } +} + +test('docs Zod 4 keeps valid schemas working after the security upgrade', async () => { + const { z } = await import('zod') + const schema = z.array(z.string()).max(2) + assert.deepEqual(schema.parse(['a']), ['a']) + assert.deepEqual( + schema.safeParse([1, 2, 3]).error.issues.map((issue) => issue.code), + ['too_big'] + ) +}) diff --git a/mobile/patches/zod@3.25.76.patch b/mobile/patches/zod@3.25.76.patch new file mode 100644 index 00000000000..51b83e79906 --- /dev/null +++ b/mobile/patches/zod@3.25.76.patch @@ -0,0 +1,175 @@ +diff --git a/src/v3/types.ts b/src/v3/types.ts +--- a/src/v3/types.ts ++++ b/src/v3/types.ts +@@ -2263,6 +2263,7 @@ + exact: true, + message: def.exactLength.message, + }); ++ if (tooBig) return INVALID; + status.dirty(); + } + } +@@ -2291,7 +2292,7 @@ + exact: false, + message: def.maxLength.message, + }); +- status.dirty(); ++ return INVALID; + } + } + +@@ -3723,7 +3724,7 @@ + exact: false, + message: def.maxSize.message, + }); +- status.dirty(); ++ return INVALID; + } + } + +diff --git a/v3/types.js b/v3/types.js +--- a/v3/types.js ++++ b/v3/types.js +@@ -1769,6 +1769,7 @@ + exact: true, + message: def.exactLength.message, + }); ++ if (tooBig) return INVALID; + status.dirty(); + } + } +@@ -1795,7 +1796,7 @@ + exact: false, + message: def.maxLength.message, + }); +- status.dirty(); ++ return INVALID; + } + } + if (ctx.common.async) { +@@ -2814,7 +2815,7 @@ + exact: false, + message: def.maxSize.message, + }); +- status.dirty(); ++ return INVALID; + } + } + const valueType = this._def.valueType; +diff --git a/v3/types.cjs b/v3/types.cjs +--- a/v3/types.cjs ++++ b/v3/types.cjs +@@ -1790,6 +1790,7 @@ + exact: true, + message: def.exactLength.message, + }); ++ if (tooBig) return parseUtil_js_1.INVALID; + status.dirty(); + } + } +@@ -1816,7 +1817,7 @@ + exact: false, + message: def.maxLength.message, + }); +- status.dirty(); ++ return parseUtil_js_1.INVALID; + } + } + if (ctx.common.async) { +@@ -2843,7 +2844,7 @@ + exact: false, + message: def.maxSize.message, + }); +- status.dirty(); ++ return parseUtil_js_1.INVALID; + } + } + const valueType = this._def.valueType; +diff --git a/src/v4/core/schemas.ts b/src/v4/core/schemas.ts +--- a/src/v4/core/schemas.ts ++++ b/src/v4/core/schemas.ts +@@ -242,6 +242,26 @@ + }; + + inst._zod.run = (payload, ctx) => { ++ const value = payload.value; ++ const size = def.type === "array" && Array.isArray(value) ? value.length ++ : (def.type === "map" && value instanceof Map) || (def.type === "set" && value instanceof Set) ? value.size : -1; ++ if (size >= 0) { ++ for (const ch of checks) { ++ const checkDef = ch._zod.def; ++ if (!["min_length", "max_length", "length_equals", "min_size", "max_size", "size_equals"].includes(checkDef.check)) break; ++ const maximum = "maximum" in checkDef ? checkDef.maximum ++ : "length" in checkDef ? checkDef.length : "size" in checkDef ? checkDef.size : undefined; ++ if (typeof maximum === "number" && size > maximum && (!checkDef.when || checkDef.when(payload))) { ++ const start = payload.issues.length; ++ // SAFETY: Built-in size checks run only after the matching collection type guard. ++ ch._zod.check(payload as ParsePayload); ++ for (let i = start; i < payload.issues.length; i++) { ++ payload.issues[i] = { ...payload.issues[i], continue: false }; ++ } ++ return payload; ++ } ++ } ++ } + const result = inst._zod.parse(payload, ctx); + + if (result instanceof Promise) { +diff --git a/v4/core/schemas.js b/v4/core/schemas.js +--- a/v4/core/schemas.js ++++ b/v4/core/schemas.js +@@ -74,6 +74,25 @@ + return payload; + }; + inst._zod.run = (payload, ctx) => { ++ const value = payload.value; ++ const size = def.type === "array" && Array.isArray(value) ? value.length ++ : (def.type === "map" && value instanceof Map) || (def.type === "set" && value instanceof Set) ? value.size : -1; ++ if (size >= 0) { ++ for (const ch of checks) { ++ const checkDef = ch._zod.def; ++ if (!["min_length", "max_length", "length_equals", "min_size", "max_size", "size_equals"].includes(checkDef.check)) break; ++ const maximum = "maximum" in checkDef ? checkDef.maximum ++ : "length" in checkDef ? checkDef.length : "size" in checkDef ? checkDef.size : undefined; ++ if (typeof maximum === "number" && size > maximum && (!checkDef.when || checkDef.when(payload))) { ++ const start = payload.issues.length; ++ ch._zod.check(payload); ++ for (let i = start; i < payload.issues.length; i++) { ++ payload.issues[i] = { ...payload.issues[i], continue: false }; ++ } ++ return payload; ++ } ++ } ++ } + const result = inst._zod.parse(payload, ctx); + if (result instanceof Promise) { + if (ctx.async === false) +diff --git a/v4/core/schemas.cjs b/v4/core/schemas.cjs +--- a/v4/core/schemas.cjs ++++ b/v4/core/schemas.cjs +@@ -104,6 +104,25 @@ + return payload; + }; + inst._zod.run = (payload, ctx) => { ++ const value = payload.value; ++ const size = def.type === "array" && Array.isArray(value) ? value.length ++ : (def.type === "map" && value instanceof Map) || (def.type === "set" && value instanceof Set) ? value.size : -1; ++ if (size >= 0) { ++ for (const ch of checks) { ++ const checkDef = ch._zod.def; ++ if (!["min_length", "max_length", "length_equals", "min_size", "max_size", "size_equals"].includes(checkDef.check)) break; ++ const maximum = "maximum" in checkDef ? checkDef.maximum ++ : "length" in checkDef ? checkDef.length : "size" in checkDef ? checkDef.size : undefined; ++ if (typeof maximum === "number" && size > maximum && (!checkDef.when || checkDef.when(payload))) { ++ const start = payload.issues.length; ++ ch._zod.check(payload); ++ for (let i = start; i < payload.issues.length; i++) { ++ payload.issues[i] = { ...payload.issues[i], continue: false }; ++ } ++ return payload; ++ } ++ } ++ } + const result = inst._zod.parse(payload, ctx); + if (result instanceof Promise) { + if (ctx.async === false) diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 1d8e557c264..bfbb1cfa6b1 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -163,6 +163,7 @@ settings: excludeLinksFromLockfile: false overrides: + zod@>=3 <4: 3.25.76 dompurify@<3.4.17: 3.4.17 sprintf-js@<1.1.3: 1.1.3 markdown-it@>=14.0.0 <14.3.3: 14.3.3 @@ -179,6 +180,7 @@ patchedDependencies: react-native-webview@13.16.2: 55b7bce35c78ee1a50c4b99ad65b52982f3cfa73da6f61d0853c4d03219eec06 react-native@0.83.10: 22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb sprintf-js@1.1.3: 05316a8b01f155fe19a1e0cb4042c81d43d798a76755c407684e1199a18b1e08 + zod@3.25.76: 8f57d8e7910df51ec3c40c412b4f8fb3d01fd65e1889c057aae0e4a15483bcec importers: @@ -8769,7 +8771,7 @@ snapshots: toqr: 0.1.1 wrap-ansi: 7.0.0 ws: 8.22.0 - zod: 3.25.76 + zod: 3.25.76(patch_hash=8f57d8e7910df51ec3c40c412b4f8fb3d01fd65e1889c057aae0e4a15483bcec) optionalDependencies: expo-router: 55.0.18(589555b6d8c5573172d9947cbca71959) react-native: 0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1) @@ -15524,7 +15526,7 @@ snapshots: yocto-queue@0.1.0: {} - zod@3.25.76: {} + zod@3.25.76(patch_hash=8f57d8e7910df51ec3c40c412b4f8fb3d01fd65e1889c057aae0e4a15483bcec): {} zod@4.6.7: {} diff --git a/mobile/pnpm-workspace.yaml b/mobile/pnpm-workspace.yaml index 2e5c1615e5d..94aa80bbbfe 100644 --- a/mobile/pnpm-workspace.yaml +++ b/mobile/pnpm-workspace.yaml @@ -6,6 +6,8 @@ allowBuilds: minimumReleaseAge: 4320 overrides: + # Expo still requires Zod 3; pin the version with our security backport. + zod@>=3 <4: 3.25.76 dompurify@<3.4.17: 3.4.17 sprintf-js@<1.1.3: 1.1.3 markdown-it@>=14.0.0 <14.3.3: 14.3.3 @@ -17,6 +19,7 @@ overrides: query-string: 9.5.1 patchedDependencies: + zod@3.25.76: patches/zod@3.25.76.patch sprintf-js@1.1.3: patches/sprintf-js@1.1.3.patch '@xterm/addon-webgl@0.20.0-beta.300': patches/@xterm__addon-webgl@0.20.0-beta.300.patch expo-notifications@55.0.27: patches/expo-notifications@55.0.27.patch diff --git a/mobile/scripts/zod-security.test.ts b/mobile/scripts/zod-security.test.ts new file mode 100644 index 00000000000..c936a4c7f53 --- /dev/null +++ b/mobile/scripts/zod-security.test.ts @@ -0,0 +1,106 @@ +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const expoRequire = createRequire(require.resolve('expo')) +const cliRequire = createRequire(expoRequire.resolve('@expo/cli')) +const zodRoot = dirname(cliRequire.resolve('zod/package.json')) + +describe.each(['cjs', 'js'])('installed Expo Zod (%s)', (extension) => { + it.each(['index', 'v3/index', 'v4/index'])( + 'rejects oversized collections before parsing elements in %s', + async (entry) => { + const path = join(zodRoot, `${entry}.${extension}`) + const z = extension === 'cjs' ? require(path) : await import(pathToFileURL(path).href) + let parsed = 0 + let refined = 0 + const element = z.preprocess((value: unknown) => { + parsed += 1 + return value + }, z.string()) + const cases = [ + { + schema: z.array(element).max(2, 'too many'), + oversized: Array(1_000).fill(0), + valid: ['a', 'b'] + }, + { + schema: z.array(element).length(2, 'too many'), + oversized: Array(1_000).fill(0), + valid: ['a', 'b'] + }, + { + schema: z.set(element).max(2, 'too many'), + oversized: new Set(Array.from({ length: 1_000 }, (_, i) => i)), + valid: new Set(['a', 'b']) + }, + { + schema: z.set(element).size(2, 'too many'), + oversized: new Set(Array.from({ length: 1_000 }, (_, i) => i)), + valid: new Set(['a', 'b']) + } + ] + for (const { schema, oversized, valid } of cases) { + const parent = z.object({ items: schema }).refine(() => { + refined += 1 + return true + }) + parsed = 0 + refined = 0 + for (const result of [ + parent.safeParse({ items: oversized }), + await parent.safeParseAsync({ items: oversized }) + ]) { + expect(result.success).toBe(false) + expect(result.error.issues).toEqual([ + expect.objectContaining({ code: 'too_big', path: ['items'], message: 'too many' }) + ]) + } + expect([parsed, refined]).toEqual([0, 0]) + expect(parent.parse({ items: valid })).toEqual({ items: valid }) + expect(await parent.parseAsync({ items: valid })).toEqual({ items: valid }) + expect(parsed).toBe(4) + } + expect(z.array(z.string()).max(2).safeParse([1]).success).toBe(false) + expect(z.array(z.string()).length(2).safeParse(['a']).success).toBe(false) + expect( + z + .set(z.string()) + .size(2) + .safeParse(new Set(['a'])).success + ).toBe(false) + if (entry === 'v4/index') { + expect( + z + .array(z.number()) + .overwrite((items: number[]) => items.slice(0, 2)) + .max(2) + .parse([1, 2, 3]) + ).toEqual([1, 2]) + } + } + ) + + it('protects the bundled Zod mini entrypoint', async () => { + const path = join(zodRoot, `v4/mini/index.${extension}`) + const z = extension === 'cjs' ? require(path) : await import(pathToFileURL(path).href) + const schema = z.array(z.string()).check(z.maxLength(2)) + expect(z.safeParse(schema, [1, 2, 3]).error.issues).toEqual([ + expect.objectContaining({ code: 'too_big' }) + ]) + expect((await z.safeParseAsync(schema, [1, 2, 3])).error.issues).toHaveLength(1) + expect(z.parse(schema, ['a'])).toEqual(['a']) + const map = z.map(z.string(), z.string()).check(z.maxSize(1)) + const oversized = new Map([ + [1, 1], + [2, 2] + ]) + expect(z.safeParse(map, oversized).error.issues).toEqual([ + expect.objectContaining({ code: 'too_big' }) + ]) + expect((await z.safeParseAsync(map, oversized)).error.issues).toHaveLength(1) + expect(z.parse(map, new Map([['a', 'b']]))).toEqual(new Map([['a', 'b']])) + }) +})