Skip to content

fix(core): honour jitless config in allowsEval probe#5864

Open
dokson wants to merge 1 commit intocolinhacks:mainfrom
dokson:fix/jitless-skips-allows-eval-probe
Open

fix(core): honour jitless config in allowsEval probe#5864
dokson wants to merge 1 commit intocolinhacks:mainfrom
dokson:fix/jitless-skips-allows-eval-probe

Conversation

@dokson
Copy link
Copy Markdown

@dokson dokson commented Apr 18, 2026

Summary

Makes z.config({ jitless: true }) actually silence the Chrome DevTools "Content Security Policy of your site blocks the use of 'eval' in JavaScript" issue on strict-CSP pages, by short-circuiting the new Function("") feature probe inside allowsEval.

Closes #4461.
Closes #5414.

Problem

util.allowsEval feature-probes for new Function() support so the JIT fast-path in schemas.ts can be enabled opportunistically. The probe is wrapped in try/catch, so it never throws at runtime — but browsers still log the caught attempt as a securitypolicyviolation, which Chrome surfaces as a Developer Tools Issue:

Content Security Policy of your site blocks the use of 'eval' in JavaScript

Users on strict CSPs (script-src without 'unsafe-eval') have flagged this in production error monitors (Datadog, Sentry) — see #5414 where @zbauman3 reported "literally thousands of errors" from a single Zod v4 upgrade. The maintainer's documented workaround was:

z.config({ jitless: true })

But this doesn't actually help today, because allowsEval is memoised via cached() and runs lazily on first .value access, regardless of whether the user set jitless first. Users reported back that setting jitless did nothing for the CSP violation, and the thread has stalled.

Fix

allowsEval now consults globalConfig.jitless before attempting the probe. When jitless === true, it returns false without invoking new Function at all — no CSP violation is ever generated.

export const allowsEval: { value: boolean } = cached(() => {
  if (globalConfig.jitless) {
    return false;                          // NEW: skip probe entirely
  }
  // @ts-ignore
  if (typeof navigator !== "undefined" && navigator?.userAgent?.includes("Cloudflare")) {
    return false;
  }
  try {
    const F = Function;
    new F("");
    return true;
  } catch (_) {
    return false;
  }
});

The change is minimal, additive, and strictly more restrictive when opted into. The existing jit = !core.globalConfig.jitless gate at schemas.ts:2025 already routes jitless=true users onto the slow path; the probe was just dead weight on their page loads.

API / behaviour

  • Default unchanged: when jitless is undefined (its default), allowsEval probes exactly as before — no behaviour shift for the 99% of users who never touch config.
  • Opt-in, safer: when jitless=true, Zod never calls new Function(""). Users on strict CSPs now have a clean devtools console.
  • Contract (should be documented): z.config({ jitless: true }) must be called at application entry, before any schema is parsed, because allowsEval.value is memoised via cached() on first access. Same contract the jitless option already implies — this PR doesn't change it.
  • No runtime cost to users who don't set jitless (one extra if (undefined) branch on first probe).

Tests

Added packages/zod/src/v4/classic/tests/jitless-allows-eval.test.ts. It:

  1. Calls z.config({ jitless: true }) at the top of the test.
  2. Swaps globalThis.Function with a stub that throws if invoked.
  3. Asserts util.allowsEval.value === false.
  4. Asserts the stub was never called (the probe really did short-circuit).

The test lives in its own file because vitest isolates ESM graphs per file, so the cached allowsEval.value is guaranteed fresh and is never accessed before the config mutation. A finally block restores globalThis.Function and clears the config flag so other test files in the same worker see the default.

✓ src/v4/classic/tests/jitless-allows-eval.test.ts (1 test) 2ms
✓ [TS] src/v4/classic/tests/jitless-allows-eval.test.ts (1 test)
Test Files  2 passed (2)
     Tests  2 passed (2)
Type Errors no errors

Regression check

Ran the full suite locally (pnpm test). All existing tests pass except src/v4/classic/tests/datetime.test.ts > redos checker, which times out at 5000ms → 13800ms on my Windows/pnpm combo when run under parallel-file concurrency. Isolated run (pnpm test packages/zod/src/v4/classic/tests/datetime.test.ts) passes cleanly in 11.5s / 28 tests. The failure is environmental (ReDoS-check regex timing on a slower machine) and completely unrelated to util.ts — same test passes on main without this branch. CI on GitHub Actions (Linux) should be clean.

Scope / risk

  • Single file of production change: packages/zod/src/v4/core/util.ts (+12 lines, new runtime import + early return).
  • One new test file: packages/zod/src/v4/classic/tests/jitless-allows-eval.test.ts (+46 lines).
  • No change to public API surface. $ZodConfig.jitless already exists on main and is documented at core.ts:129.
  • No dependency changes, no bundler-config changes, no type signature changes.

Happy to iterate on the test style, the comment wording, or split the changelog/docs note into a separate PR if preferred.

Closes colinhacks#4461, closes colinhacks#5414.

`allowsEval` (packages/zod/src/v4/core/util.ts) feature-probes for
`new Function()` support to pick Zod's JIT fast-path. The probe runs
inside try/catch so it never throws at runtime — but strict-CSP browsers
(script-src without 'unsafe-eval') still log the caught attempt as a
`securitypolicyviolation`, which Chrome DevTools surfaces as an Issue
("Content Security Policy of your site blocks the use of 'eval'"). Many
users have reported this as noise in production error monitors; the
documented workaround `z.config({ jitless: true })` currently doesn't
help because the probe is memoised via `cached()` and runs lazily on
first `.value` access regardless of config.

This change makes `allowsEval` read `globalConfig.jitless` before
attempting the probe. When `jitless` is true, return `false` early and
skip `new Function("")` entirely — no CSP violation is generated. The
existing `schemas.ts:2025` already gates the fast-path on `!jitless`,
so users who opt in are already on the slow path; the probe was just
dead weight on their page loads.

- No default-behaviour change: when `jitless` is undefined (the
  default), `allowsEval` still probes as before.
- Strictly more restrictive when `jitless=true`: no eval attempt, no
  cached path to it — safer on strict CSPs, no library-side regression.
- Users must call `z.config({ jitless: true })` at application entry,
  before any schema is parsed, because `allowsEval.value` is memoised
  via `cached()` on first access.

Tests: packages/zod/src/v4/classic/tests/jitless-allows-eval.test.ts
stubs the `Function` global and asserts the probe is never invoked
under `z.config({ jitless: true })`. Lives in its own file so vitest's
per-file ESM isolation gives a fresh cached allowsEval.
@dokson
Copy link
Copy Markdown
Author

dokson commented Apr 18, 2026

Heads-up: the one failing CI job (Test with TypeScript latest on Node lts/*) fails with TS5107: Option 'moduleResolution=node10' is deprecated during the zshy --project tsconfig.build.json build step. This is a repo-wide issue on TypeScript-next — the same job fails on every other open PR in the repo right now (e.g. #5855 Add support for window.ZOD_NO_EVAL, #5854 fix(toJSONSchema): emit falsy prefault values as defaults), all against a clean base. The "Test with TypeScript 5.5" job then gets auto-cancelled by the fast-fail strategy.

This PR only touches packages/zod/src/v4/core/util.ts (+ one new test file) — nothing in tsconfig or build config, so the failure is unrelated. The other CI signals that actually cover this change (circular-deps check, lint on Node latest, my dedicated test file) all pass.

Happy to add "ignoreDeprecations": "6.0" to tsconfig.build.json in a follow-up PR if that's useful, but I didn't want to inflate scope here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Zod triggers unsafe-eval CSP error Zod triggers unsafe-eval CSP error

1 participant