Skip to content

fix(v4): reject whitespace in z.base64() to close atob bypass#5888

Merged
colinhacks merged 1 commit into
mainfrom
base64-whitespace
Apr 28, 2026
Merged

fix(v4): reject whitespace in z.base64() to close atob bypass#5888
colinhacks merged 1 commit into
mainfrom
base64-whitespace

Conversation

@colinhacks

Copy link
Copy Markdown
Owner

Summary

Closes the whitespace bypass in isValidBase64() raised in #5865. atob() strips ASCII whitespace before decoding, which lets strings like "123 " (length 4, mod-4 passes, atob strips and parses "123") sneak past the existing length check. Fix: reject whitespace explicitly before the length check so the runtime matches the strict regex we already advertise via bag.patterns / contentEncoding.

Why not just enforce regexes.base64 at runtime?

That's what the validator originally did. It was replaced with the atob-based check specifically for perf on large inputs in #4386 (commit ec7c89d9), which also added the big base64 and base64url regression test in packages/zod/src/v4/classic/tests/string.test.ts that round-trips 10MB through z.base64().parse(). The /\s/.test() short-circuit is a single deterministic linear scan with no backtracking, so it fits inside that perf envelope without reintroducing the regex.

Why keep the strict default at all?

Discussed in detail at #5865 (comment). Short version: zod's string formats are strict by default, the unpadded base64url niche is already served by z.base64url() / z.jwt(), and strict-vs-lenient is asymmetric — strict can be loosened later via opt-in (z.base64({ padding: "optional" })) non-breakingly, while lenient cannot be tightened without a breaking change.

Test plan

  • pnpm vitest run packages/zod/src/v4/classic/tests/string.test.ts -t "base64" — passes
  • pnpm vitest run packages/zod/src/v4 — 2640 tests passing
  • Full pnpm vitest run (via pre-push hook) — 3689 tests passing, no type errors
  • New invalid cases added in packages/zod/src/v4/classic/tests/string.test.ts covering the bypass ("123 ") plus leading/trailing/internal space, newline, and tab
  • Mini parity in packages/zod/src/v4/mini/tests/string.test.ts

Refs #5865.

atob() strips ASCII whitespace before validating, which let strings like
"123 " sneak past the length-mod-4 short-circuit in isValidBase64. Reject
whitespace explicitly before the length check so the runtime matches the
strict regex we already advertise via bag.patterns / contentEncoding.

Refs #5865.
@pullfrog

pullfrog Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

TL;DR — Fixes a whitespace bypass in z.base64() where atob()'s internal whitespace-stripping let inputs like "123 " pass validation despite not being canonical Base64 (RFC 4648 §3.3). A single /\s/.test() guard in isValidBase64() rejects whitespace up front, preserving the perf-oriented atob path introduced in #4386.

Key changes

  • Reject whitespace in isValidBase64() — Adds a /\s/.test(data) short-circuit before the length check so whitespace-containing strings are rejected before reaching atob().
  • Classic test coverage for whitespace bypass — Adds 6 invalid-input cases covering trailing, leading, and internal whitespace (space, newline, tab) plus the original "123 " bypass.
  • Mini test parity — Mirrors 4 whitespace rejection cases in the mini string tests.

Summary | 3 files | 1 commit | base: mainbase64-whitespace


Before: isValidBase64() relied on atob() after a length-mod-4 check — atob() silently strips ASCII whitespace before decoding, so inputs like "123 " (length 4, mod-4 passes) were accepted as valid Base64.
After: A /\s/.test(data) guard rejects any string containing whitespace before the length check runs, closing the bypass without reintroducing the regex that was removed for performance in #4386.

The fix is a single linear scan (/\s/.test()) that runs before the existing atob path. This keeps the O(n) perf profile for large inputs while enforcing the strict canonical encoding that z.base64() advertises via bag.patterns and contentEncoding.

Why not just use the strict regex instead of atob? The full regex was intentionally replaced with the atob-based validator in #4386 for performance on large inputs (the test suite round-trips 10 MB through z.base64().parse()). The /\s/ pre-check is deterministic and linear with no backtracking, fitting inside that perf envelope.

schemas.ts · classic/tests/string.test.ts · mini/tests/string.test.ts

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

@pullfrog pullfrog Bot left a comment

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.

Reviewed — no issues found.

Task list (5/5 completed)
  • Read the full diff to understand the changes
  • Read the affected source file (schemas.ts) for context around the change
  • Review the regex change for correctness and completeness
  • Review tests for adequate coverage
  • Submit review

Pullfrog  | View workflow run | Using Claude Opus𝕏

@colinhacks
colinhacks merged commit 584b108 into main Apr 28, 2026
7 checks passed
@colinhacks
colinhacks deleted the base64-whitespace branch April 28, 2026 17:05
@colinhacks

Copy link
Copy Markdown
Owner Author

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.

1 participant