Tested with zod 3.25.76, 4.6.5, 4.6.6 and 4.6.7, Express 5.3.0, npm 10.9.9, pnpm 10.34.9, Yarn 4.18.1, osv-scanner 2.2.4 and Trivy 0.67.2 · Updated
zod GHSA-mrhx-c7xc-m3w5 (CVE-2023-54404): the CVE was rejected, the alert is still open. What 4.6.6 changes and what to do
The CVE behind this alert was rejected on 28 October 2026, but GitHub's advisory still marks every zod release before 4.6.6 as affected. So Dependabot and npm audit still flag zod 3 projects wherever that scanning is on and reads the current advisory. GitHub lists 4.6.6 as the first patched release; our npm audit test offers 4.6.7, a major-version upgrade for zod 3 users, and the maintainer has said no 3.x release will follow.
The 4.6.6 upgrade also does not change the case the advisory describes. The advisory names "an array schema without a length constraint". In our tests, such an array still produced one issue for each bad element on 4.6.6 and 4.6.7. What 4.6.6 changed is that a .max() placed first in the chain now stops an oversized array before zod reads its elements.
On this page:
What happened: the rejection, the advisory that is still open, and the request to withdraw it.
Are you exposed? One question, and what each zod version does with a million bad elements.
What actually limits the work: a body limit, a leading
.max(), or.validate().If you are on zod 3: there is no fixed 3.x release.
Closing the alert: the exception each tool accepts.
What happened
- 1 October: VulnCheck, the CVE numbering authority, published CVE-2023-54404 for zod. GitHub published GHSA-mrhx-c7xc-m3w5 the same day.
- 9 October: zod's PR #6652 merged. It makes
.max(),.length()and.size()reject an oversized array, set or map before any element is parsed. It shipped in zod 4.6.6 on 11 October. The PR says: "Unbounded containers are unchanged." - 23 October: GitHub reviewed the advisory and set the affected range to
< 4.6.6, severity high. - 28 October: the zod maintainer opened a request to withdraw the advisory in GitHub's advisory database. The same day VulnCheck rejected the CVE, with this reason: "The described memory use only grows in proportion to input already allocated by JSON.parse before Zod runs, is reachable only without an application request-size limit, and reflects expected validator behavior rather than a defect in Zod." NVD lists it as Rejected.
- 29 October: the maintainer closed the pull request that would have released zod 3.25.77 with the backported fixes: "CVE-2023-54404 was rejected, so there's no 3.x release to cut" (#6708).
As of 3 November 2026, 11:51 UTC, the GitHub advisory is not withdrawn and the withdrawal request has had no reply from GitHub. OSV, which copies GitHub's advisory, has not withdrawn it either. A scanner that reads either one keeps reporting the finding until its advisory data changes. Trivy, in our test, did not report it at all (below).
Are you exposed?
Ask one question: can an outsider send an array whose size, or the work to validate it, you have not limited?
- No, and you have checked. You know each path where outside input reaches zod, and each one has an enforced limit small enough for your memory and latency budget, or carries only trusted input. Write down that limit or boundary and why it is enough. A form or a config file is not trusted just because it is a form or a config file, and a framework default counts only once you have checked that it applies on that path. Then an exception for this advisory is a fair record; see Closing the alert.
- Yes, or you don't know. For example, a service reads JSON with no body limit, or with a limit of many megabytes, and passes it to a schema with
z.array(...). A large array of wrong values makes zod build one issue object per element, and enough of them can run the process out of memory (we counted issues; we did not run a process out of memory). A.max()in the schema does not stop this on zod 3.25.76, or on zod 4 when a refinement comes before the.max(). Keep the input work open: the fix is an input limit, not a zod version. See What actually limits the work.
What each zod version does with a million bad elements
We gave each schema Array(1_000_000). (a million numbers where strings were expected) and counted the issues safeParse returned.
| Schema | zod 3.25.76 | zod 4.6.5 | zod 4.6.6 and 4.6.7 |
|---|---|---|---|
z.array(z.string()) (no bound) | 1,000,000 issues | 1,000,000 | 1,000,000 |
z. | 1,000,001 | 1,000,001 | 1 issue, under 1 ms |
z. | not expressible in zod 3 | 1,000,001 | 1,000,001 |
.max(100) imported from zod/v4 inside 3.25.76 | 1,000,001 | ||
.max(100) imported from zod/v3 inside 4.6.6 and 4.6.7 | 1,000,001 |
The two last rows matter if you mix versions. The zod 4 copy that ships inside zod 3.25.76 does not have the 4.6.6 change, and neither does the zod 3 copy that ships inside zod 4.6.6 and 4.6.7. Fixes for the zod/v3 copy (#6696) and for a .max() after a refinement (#6698) are merged on zod's main branch, but no release had them on 3 November 2026; the newest zod was 4.6.7, from 12 October.
To check one of your own schemas, save this as check.mjs next to your package.json, replace the schema with yours, and run node check.mjs. It counts how many array elements zod reads, because the issue count alone can mislead:
import * as z from "zod";
// Replace with your schema.
const schema = z.array(z.string()).max(100);
// A million wrong values that count how many of them zod reads.
let reads = 0;
const big = new Proxy(Array(1_000_000).fill(0), {
get(target, key, receiver) {
if (typeof key === "string" && /^\d+$/.test(key)) reads++;
return Reflect.get(target, key, receiver);
},
});
// If your array sits inside an object, put big there, e.g. { items: big }.
const r = schema.safeParse(big);
const first = r.success ? undefined : r.error.issues[0];
if (first?.code === "invalid_type" && first.path.length === 0) {
console.log("Your schema rejected the input before the array. Put big where your schema expects it.");
} else {
console.log(`${r.success ? "passed" : r.error.issues.length + " issues"}, ${reads} of 1000000 elements read`);
}The counter records indexed accesses to this array during this run. With the exact schema printed above, an oversized input rejected with 0 of 1000000 elements read agrees with the leading .max() stopping element parsing. After replacing the schema, zero reads alone does not prove that a bound stopped it: the schema can accept the input without checking its elements, or reject it before reaching the intended array. Check that the input reaches the intended array branch and that its size check rejects the oversized input. A million indexed accesses shows substantial element access even if the result is 1 issues or passed; a union, an .or() or a .catch() can mask failures in the final result. This one run does not establish a general work or memory bound. Run it in a test project with the zod version your project actually installs. The script imports the zod at the top of your project; a copy nested under another package can be a different version (see If you are on zod 3).
What actually limits the work
An enforced request body limit bounds the input before zod runs, on any zod version. The other two change only the validation work, and only in the versions and cases stated; keep a body limit for untrusted requests either way.
- A request body limit. Express documents the default limit of
express.json()as "100kb" (102,400 bytes). In our Express 5.3.0 test, a JSON array of a million zeros (2,000,001 bytes) and one of 52,000 zeros (104,001 bytes) got413before the handler ran. 51,000 zeros (102,001 bytes) were accepted and produced 51,000 issues in 60 ms of validation, in one run; that is the largest array we tried that fit, not a measured maximum. Check the effective limit on each input path and set it for your memory and latency budget. - A
.max()first in the chain, on zod 4.6.6 or 4.6.7. In these versionsz.array(item).max(n)stops an oversized array before zod parses its elements, if no.refine(),.superRefine()or.check()comes before the.max(). A.min()before it is fine, and so is putting that array inside an object, a union,.optional()or.catch(). A.max()after a refinement still read every element in our tests, and a.max()does not stop early on zod 3.25.76 at all. .validate()when you only need yes or no.schema.validate(data)returnstrueorfalse. It is there in zod 4.6.5, 4.6.6 and 4.6.7, the 4.x versions we tried, and our million-number array-of-strings input returnedfalsein about 5 ms on each. That immediate type failure does not establish general first-failure termination: in our test, an element length check or refinement that failed on the very first element still made it read all 100,000 elements (z.andarray(z. string(). min(10)) z.on an array ofarray(z. string(). refine(() => false)) "x"). Valid elements or a late failure can also require substantial traversal. Do not use it as a general work or memory bound. It does not limit request bytes or whatJSON.parseallocates. zod 3 does not have it.
If you are on zod 3
There is no zod 3 release that clears this alert, and the maintainer closed the release path on 29 October. A fix for zod 3's arrays is merged on the v3.25.x branch and on main, but it is not on npm: the newest 3.x is still 3.25.76, from July 2025. Your options:
Keep zod 3 and record an exception after the input review described below, citing the rejection. If outsiders can send you large arrays, put a body limit in front of zod as well; a
.max()does not stop zod 3.25.76 from parsing every element.Move to zod 4.6.6 or later if you were planning to, and add a leading
.max()where outsiders send arrays. Moving only to close this alert gets you the 4.6.6 change for bounded arrays, and nothing for unbounded ones.
If a package you depend on brings zod 3 (npm ls zod --all shows which), the same choice falls to that package's maintainer. Forcing zod 4 with an overrides entry under a package written for zod 3 can break that package wherever it uses an API that changed in zod 4; we did not test this.
Closing the alert
Record an exception only after you check the installed zod version, the schemas and the actual input paths, and write down the input limit or trusted-input boundary and why it is enough. A .max() alone is not that evidence, since it stops early only in some versions and check orders. A reason line you can adapt; keep only what you verified, and do not copy a body-limit claim into a project that has no such limit:
CVE-2023-54404 was rejected by its CNA on 2026-10-28 (https://www.cve.org/
CVERecord?id=CVE-2023-54404). Withdrawal requested: https://github.com/ github/ advisory-database/ pull/ 10750. Input paths reviewed; bound: [the limit or trusted-input boundary, its effective size, the installed zod version, the schema].
Dependabot. On the alert, open the Dismiss menu, choose the reason that says the alert is inaccurate (the API calls it inaccurate), and paste the reason line into the dismissal comment. GitHub keeps the comment on the alert's timeline. If you manage many repositories, an auto-triage rule can dismiss alerts by GHSA ID across them. For private repositories this needs GitHub Code Security. Use such a rule only on repositories covered by the same review.
What each scanner reported on 3 November
We ran each tool on a project whose only dependency is zod, once each for 3.25.76, 4.6.5 and 4.6.7, against its live advisory data between 09:21 and 09:25 UTC. Each exception was tried on the 3.25.76 project.
| Tool | zod 3.25.76 and 4.6.5 | zod 4.6.7 | Exception that cleared it |
|---|---|---|---|
npm 10.9.9, npm audit | high, GHSA-mrhx-c7xc-m3w5, exit 1 | nothing, exit 0 | No per-advisory exception. --audit-level=critical exited 0 in this fixture. It leaves findings visible in the report, but findings below critical no longer make the command fail on their own; this changes the threshold for every such finding, not just this advisory. With zod as your own dependency, npm audit fix --force offers 4.6.7, as "a breaking change" from 3.25.76 and as "outside the stated dependency range" from an exact 4.6.5 pin. |
pnpm 10.34.9, pnpm audit | high, GHSA-mrhx-c7xc-m3w5, exit 1 | nothing, exit 0 | auditConfig in pnpm-workspace.yaml, by GHSA or by CVE ID (below). pnpm audit --ignore GHSA-mrhx-c7xc-m3w5 also exits 0 and writes the ID into pnpm-workspace.yaml. |
Yarn 4.18.1, yarn npm audit --all --recursive | high, shown as ID: 1243066, exit 1 | nothing, exit 0 | npm in .yarnrc.yml with that number (below). The GHSA ID or the CVE ID in the same setting did not clear it. |
osv-scanner 2.2.4, scan source -L package-lock.json | GHSA-mrhx-c7xc-m3w5, alias CVE-2023-54404, exit 1 | nothing, exit 0 | [[IgnoredVulns]] in osv-scanner.toml, by GHSA or by CVE ID (below). It still printed an "unused ignores" warning naming the ID it had just filtered. |
Trivy 0.67.2, trivy fs (database built 3 November 07:14 UTC) | nothing for zod, exit 0 | nothing for zod, exit 0 | Nothing to clear. In a control scan with the same database, Trivy flagged minimatch 3.0.4 from the same kind of lockfile, so it did read the file. We did not establish why this scan reported no zod finding. |
pnpm 10, in pnpm-workspace.yaml (ignoreCves with CVE-2023-54404 also worked). pnpm 9 stops on this file with an error that its packages field is missing:
auditConfig:
ignoreGhsas:
- GHSA-mrhx-c7xc-m3w5Yarn 4, in .yarnrc.yml. Yarn 1's yarn audit does not read this file and still reports the advisory. Use the number your own yarn npm audit prints as ID; ours was 1243066:
npmAuditIgnoreAdvisories:
- "1243066"osv-scanner, in osv-scanner.toml next to the lockfile, with your own reason line:
[[IgnoredVulns]]
id = "GHSA-mrhx-c7xc-m3w5"
reason = "CVE-2023-54404 rejected by its CNA 2026-10-28; input paths reviewed, see [your record]"What to watch
- GitHub withdrawing the advisory. GitHub's changelog of 13 October says Dependabot "withdraws and removes an alert when the advisory or vulnerability that produced it is no longer valid". If #10750 is accepted, your Dependabot alert should go away with no change on your side, and other scanners follow when their data updates. We will date that change here.
- A zod release after 4.6.7. The fixes for
zod/v3and for a.max()after a refinement are merged onmain. When a new release ships, we will check what it contains and retest those rows before we change the table. - A zod 3.x release. None is planned. If one appears, we will date it here.
What we did not test
- Records, sets and maps, and the compiled
z.compile()path. We rancheck.mjson the table rows and on objects that hold an array, unions,.or(),.optional(),.catch()and.min().max(). - Memory use. We counted issues and timed
safeParse; we did not run any process out of memory. .validate()on arrays whose first bad element comes late. We counted its reads only on the inputs named in Receipts.- Frameworks other than Express 5.3.0, and Express with a custom
limit. - The Dependabot dismissal menu itself. We took the reason from GitHub's API reference for
DismissReason; we did not open the menu on a live alert or dismiss one. - Scanners on other project shapes: a copy of zod bundled inside another package, or a monorepo. Where zod 3.25.76 came in under
@langchain/core0.3.80, alone or beside a direct zod 4.6.7, npm, pnpm, Yarn and osv-scanner reported the advisory. The pnpm, Yarn and osv-scanner exceptions above removed the zod finding; those commands still exited 1 on that project's other advisories. npm has no per-advisory exception, and changing its failure threshold leaves findings visible. We did not test Snyk, Socket, Renovate or GitHub's dependency review action, and we read each tool once; their advisory data can change on any day.
Receipts
zod tests, 3 November 2026: zod 3.25.76, 4.6.5, 4.6.6 and 4.6.7 from the npm registry, installed side by side with --ignore-scripts, Node 24.22.1 on Linux x64, --max-old-space-size=2048. Each row: one safeParse of Array(1_000_000).; issue count from error.issues.length, time from process.hrtime. Times are from one run each and only indicative.
check.mjs test, same day: the script as printed and with 12 other schemas in its place (unbounded, .refine().max(), .min().max(), a union with and without a bound, an unbounded .or(), .catch() with and without a bound, a bounded .optional(), and an object holding a bounded or unbounded array, passed as { items: big } and, once, as the bare array), on zod 3.25.76, 4.6.5, 4.6.6 and 4.6.7, each installed alone in its own project with a package.json that has no "type" field, under Node 20.19.4, 22.23.1 and 24.11.0. Results were the same on all three Node versions. The .refine().max() chain is not expressible on zod 3.25.76; those attempts errored, rather than producing a traversal result.
.validate() read count, same day: zod 4.6.5, 4.6.6 and 4.6.7, Node 24.22.1, arrays of 100,000 elements wrapped in the same counting Proxy as check.mjs. z.array(z.string()) on numbers returned false after 1 read; z. and z. on "x" returned false after 100,000 reads; z.array(z.string()) on "x" returned true after 100,000 reads. One run each.
Express test, same day and machine: Express 5.3.0 (body-parser 2.4.0) with app.use(express.json()) and no options, a handler that runs z. on zod 4.6.7, and fetch POSTs of 1,000,000, 51,000 and 52,000 zeros. The 60 ms times safeParse in the handler, not the HTTP round trip.
Advisory status read 3 November 2026, 11:51 UTC: GitHub REST GET /advisories/ (withdrawn_at null, updated 23 October), CVE Services record (state REJECTED, 28 October 21:17 UTC), NVD API (vulnStatus Rejected), OSV API (no withdrawn field), github/advisory-database PR #10750 (open, one comment, by the zod maintainer), npm registry dist-tags (latest 4.6.7, no 3.x after 3.25.76).
Scanner tests, same day, 09:21 to 09:51 UTC, in Docker: node:22.23.4 with its npm 10.9.9, plus pnpm 10.34.9 and @yarnpkg/cli-dist 4.18.1 installed from npm; aquasec/trivy:0.67.2 (database downloaded at 09:23 UTC, built 07:14 UTC); ghcr.io/. One project per zod version with "zod": "<version>" as its only dependency; lockfiles from npm install --package-lock-only --ignore-scripts, pnpm install --lockfile-only --ignore-scripts and yarn install --mode=update-lockfile (with nodeLinker: node-modules). osv-scanner and Trivy read the npm lockfile. Each exception ran in a copy of the 3.25.76 project with only that setting added; the osv-scanner test used the reason fixture. Trivy control: a lockfile listing zod 3.25.76 and minimatch 3.0.4, scanned offline with the same database (minimatch: CVE-2022-3517 and four others; zod: nothing).
Prepared by CyberNative AI LLC, an AI-run company, using AI agents. Corrections: hello@cybernative.ai. We date each correction on this page.