Skip to content

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

Merged
colinhacks merged 2 commits into
colinhacks:mainfrom
dokson:fix/jitless-skips-allows-eval-probe
Apr 28, 2026
Merged

fix(core): honour jitless config in allowsEval probe#5864
colinhacks merged 2 commits into
colinhacks:mainfrom
dokson:fix/jitless-skips-allows-eval-probe

Conversation

@dokson
Copy link
Copy Markdown
Contributor

@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.

@dokson
Copy link
Copy Markdown
Contributor 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.

@colinhacks
Copy link
Copy Markdown
Owner

@pullfrog review

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 28, 2026

Reviewed PR #5864. The fix correctly short-circuits the new Function("") probe in allowsEval when globalConfig.jitless is true, preventing CSP violation reports in strict-CSP environments. No circular dependency issues — the new runtime import from core.js is safe. Left one minor style suggestion to trim the inline comment to match the codebase's minimal comment style.

Task list (5/5 completed)
  • Read the diff and understand the changes
  • Read the changed source files for full context
  • Verify correctness: check cached() semantics and globalConfig import
  • Check for security concerns (external PR)
  • Self-critique drafted comments and submit review

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct fix — the early return before the new Function("") probe is the right approach. No circular dependency introduced (core.tsutil.ts is type-only at runtime), and the cached() memoization semantics are properly accounted for.

Task list (5/5 completed)
  • Read the diff and understand the changes
  • Read the changed source files for full context
  • Verify correctness: check cached() semantics and globalConfig import
  • Check for security concerns (external PR)
  • Self-critique drafted comments and submit review

Pullfrog  | View workflow run | Using Claude Opus𝕏

Comment thread packages/zod/src/v4/core/util.ts Outdated
Comment on lines +373 to +379
// Users on strict Content-Security-Policy environments (no `unsafe-eval`)
// can opt out of the JIT fast-path with `z.config({ jitless: true })`.
// Honouring it here — before the `new Function("")` probe — prevents a
// one-shot `securitypolicyviolation` report at the probe site, which
// Chrome's DevTools surfaces as an Issue even though the error is caught.
// Callers must set `jitless` at application entry, before any schema is
// parsed, since this getter is memoised via `cached()`.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the rest of util.ts is nearly comment-free. Consider trimming this to 1–2 lines — the what is clear from the code; only the why (CSP violation fires even inside try/catch) needs explaining.

Suggested change
// Users on strict Content-Security-Policy environments (no `unsafe-eval`)
// can opt out of the JIT fast-path with `z.config({ jitless: true })`.
// Honouring it here — before the `new Function("")` probe — prevents a
// one-shot `securitypolicyviolation` report at the probe site, which
// Chrome's DevTools surfaces as an Issue even though the error is caught.
// Callers must set `jitless` at application entry, before any schema is
// parsed, since this getter is memoised via `cached()`.
// When jitless is set, skip the `new Function("")` probe entirely —
// even a caught attempt fires a `securitypolicyviolation` report (#4461).

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no issues found. The fix is correct, minimal, and well-tested.

Task list (5/5 completed)
  • Read the diff and understand the changes
  • Read the changed source files for full context
  • Verify correctness: check cached() semantics and globalConfig import
  • Check for security concerns (external PR)
  • Self-critique drafted comments and submit review

Pullfrog  | View workflow run | Using Claude Opus𝕏

dokson added 2 commits April 28, 2026 10:06
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 dokson force-pushed the fix/jitless-skips-allows-eval-probe branch from c09540f to 13201be Compare April 28, 2026 08:09
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 28, 2026

TL;DR — When z.config({ jitless: true }) is set, the allowsEval feature probe now skips the new Function("") call entirely, preventing the securitypolicyviolation event that browsers log on strict-CSP pages even though the exception is caught. Fixes #4461 and #5414.

Key changes

  • Skip new Function probe when jitless is enabledallowsEval in util.ts now checks globalConfig.jitless before attempting the eval probe, returning false immediately and avoiding any CSP violation report.
  • Add regression test for jitless + allowsEval interaction — A dedicated test file stubs globalThis.Function and verifies the probe is never invoked when jitless: true is configured.

Summary | 2 files | 2 commits | base: mainfix/jitless-skips-allows-eval-probe


CSP-safe allowsEval short-circuit

Before: allowsEval always ran new Function("") on first access, triggering a securitypolicyviolation event on strict-CSP pages — even when the user had set z.config({ jitless: true }).
After: When globalConfig.jitless is true, allowsEval returns false without invoking the probe. No CSP violation is generated.

The jitless config flag already gates the JIT fast-path in schemas.ts, so the eval probe was dead weight for users who opted in. This change makes allowsEval aware of that flag by importing globalConfig from core.js and checking it as the very first step inside the cached() callback. For users who never touch config (jitless === undefined), the probe runs exactly as before — one extra falsy-branch cost on first access.

The test lives in its own file because allowsEval.value is memoised via cached() on first access, and vitest's per-file ESM isolation guarantees a fresh module graph. The test sets jitless: true, swaps globalThis.Function with a throwing stub, then asserts both that allowsEval.value === false and that the stub was never called.

packages/zod/src/v4/core/util.ts · packages/zod/src/v4/classic/tests/jitless-allows-eval.test.ts

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

@colinhacks colinhacks merged commit b9b62c6 into colinhacks:main Apr 28, 2026
6 checks passed
colinhacks added a commit that referenced this pull request Apr 28, 2026
…s` (#5889)

Mirror the dual-package hazard fix already applied to `globalRegistry`
in #5452: `globalConfig` (packages/zod/src/v4/core/core.ts) is now
read from and written to `globalThis.__zod_globalConfig` instead of
being a fresh module-scope object on each load.

Why this matters
----------------
Without this, every loaded copy of Zod has its OWN `globalConfig`.
In a monorepo where some packages resolve `zod` as ESM and others as
CJS — or where multiple bundles each include their own copy — a single
`z.config({ jitless: true })` only updates one of them. #5789 is
exactly this footgun in production: the user had to call `z.config`
twice (once per instance) to suppress the CSP eval probe everywhere.

Combined with #5864 (`allowsEval` already honours `globalConfig.jitless`),
this also gives strict-CSP users a clean pre-import opt-out:

    <script>globalThis.__zod_globalConfig = { jitless: true };</script>
    <script type="module" src="app.js"></script>

…which is strictly more general than the bespoke `window.ZOD_NO_EVAL`
flag proposed in #5862, and falls out of an existing pattern instead of
introducing a new one.

Notes
-----
- No default-behaviour change: `globalThis.__zod_globalConfig ??= {}`
  preserves any pre-populated object, and `Object.assign(globalConfig, …)`
  keeps mutating the same identity that `z.config()` and external
  pre-set scripts both observe.
- Symmetric with the registry pattern (`__zod_globalRegistry`): no
  versioned key, so cross-major loads share state. That's the same
  trade-off #5452 already accepted; if it's wrong for config it's also
  wrong for the registry.
- No public API change. `z.config()` returns the same object identity
  for a given realm; only the underlying storage moved.

Tests: packages/zod/src/v4/classic/tests/global-config.test.ts asserts
the singleton is on `globalThis`, that `z.config(...)` writes are
observed via `globalThis.__zod_globalConfig`, and that pre-populating
the global before import is preserved.
@colinhacks
Copy link
Copy Markdown
Owner

Landed in Zod 4.4

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

2 participants