Skip to content

Tighten cuid() regex and deprecate CUID v1#5880

Merged
colinhacks merged 1 commit intomainfrom
tighten-cuid-regex
Apr 29, 2026
Merged

Tighten cuid() regex and deprecate CUID v1#5880
colinhacks merged 1 commit intomainfrom
tighten-cuid-regex

Conversation

@colinhacks
Copy link
Copy Markdown
Owner

Summary

  • Changes the CUID v1 regex from /^[cC][^\s-]{8,}$/ to /^[cC][0-9a-z]{6,}$/ in both v3 and v4. The body is now restricted to base36 — the actual CUID v1 charset — and the length floor is aligned with Eric Elliott's documented guidance ("starts with c, contains at least 7 chars"). Brings the regex stylistically in line with the other ID validators (ulid, xid, ksuid, nanoid, cuid2), all of which use allowlist character classes.
  • Marks every CUID v1 surface (z.cuid(), .cuid() chain method, ZodCUID / ZodMiniCUID, $ZodCUID*, _cuid, the cuid regex export) as @deprecated, pointing at z.cuid2(). The upstream paralleldrive/cuid repo itself reads: "Deprecated collision-resistant id spec. Insecure because it leaks timestamps. Use cuid2 instead."

Why

The {8,} length floor was introduced in #438 (May 2021) without documented rationale and has been frozen since. The original PR made an arbitrary choice that diverged from the spec author's "≥7 chars" guidance by accident.

The denylist body ([^\s-]) was also unprincipled — it allowed quotes, angle brackets, curly braces, semicolons, and arbitrary Unicode in a string nominally identifying itself as a CUID. Real CUID v1 output is always c followed by 24 base36 characters; the validator should reflect that. This change rejects the punctuation-shaped false positives reported in #3621 (cly63t164000245zw008pggon';select1; and similar) while remaining compatible with every real CUID v1 ever generated.

The change is strictly more permissive in length ({8,}{6,}) and strictly stricter in charset ([^\s-][0-9a-z]). No real CUID is rejected; some non-CUID strings that previously passed now correctly fail.

This is not a security fix and should not be framed as one. CUID format validation is not — and cannot be — a substitute for output encoding (XSS prevention) or parameterized queries (SQLi prevention). It's a routine correctness/consistency improvement.

Known limitation (not addressed)

#3053 (corrigendum validates as a cuid) is not fully fixed. Any all-lowercase English word starting with c will still pass — that's an inherent limitation of regex-based CUID validation without a fixed length, not a bug we can solve here. Deprecating z.cuid() in favor of z.cuid2() is the proper long-term resolution.

Test plan

  • pnpm vitest run — 3575 tests pass
  • No lint errors (pnpm lint clean via lint-staged on commit)
  • No type errors (Type Errors no errors in vitest output)
  • Existing cuid tests in v3 and v4 still pass with updated snapshots
  • Added regression tests asserting previously-accepted non-base36 payloads (SQLi-shaped, XSS-shaped, bracket/curly chars, underscores, uppercase body) are now rejected
  • Updated one template-literal test that was implicitly relying on the old {8,} length floor

@colinhacks
Copy link
Copy Markdown
Owner Author

@pullfrog review

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 27, 2026

Reviewed PR #5880. One inline comment: the v3 cuidRegex retains the /i flag which silently makes [0-9a-z] match uppercase, defeating the base36 restriction. Suggested aligning with the v4 approach (/^[cC][0-9a-z]{6,}$/ without /i). Otherwise the change is solid — regex tightening, deprecation annotations, and test coverage all look correct.

Task list (5/5 completed)
  • Read the full diff and understand the changes
  • Review regex changes in v3 and v4 for correctness
  • Review deprecation annotations
  • Review test changes and coverage
  • 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.

Clean, well-motivated change. The regex tightening and deprecation are both correct in v4. One issue in v3: the /i flag on the new regex silently defeats the base36 restriction for the body characters.

Task list (5/5 completed)
  • Read the full diff and understand the changes
  • Review regex changes in v3 and v4 for correctness
  • Review deprecation annotations
  • Review test changes and coverage
  • Submit review

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread packages/zod/src/v3/types.ts Outdated
}

const cuidRegex = /^c[^\s-]{8,}$/i;
const cuidRegex = /^c[0-9a-z]{6,}$/i;
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.

The /i flag makes [0-9a-z] match uppercase A-Z too, so "cAAAAAAAAA" still passes in v3 even though the intent is to restrict the body to base36 (lowercase). The old regex [^\s-] was inherently case-agnostic so /i was a no-op, but with the new allowlist character class it has real semantic impact.

The v4 regex in regexes.ts gets this right — it drops /i and uses [cC] explicitly. The v3 regex should match:

Suggested change
const cuidRegex = /^c[0-9a-z]{6,}$/i;
const cuidRegex = /^[cC][0-9a-z]{6,}$/;

- Change cuid regex from `/^[cC][^\s-]{8,}$/` to `/^[cC][0-9a-z]{6,}$/`
  in v4. Restricts the body to base36 (the actual CUID v1 charset) and
  aligns the length floor with Eric Elliott's documented guidance
  ("starts with c, contains at least 7 chars", paralleldrive/cuid#88).
  Brings the regex stylistically in line with the other ID validators
  (ulid, xid, ksuid, nanoid, cuid2), all of which use allowlist
  character classes.

  The change is backwards-compatible for any real CUID v1 output
  (always lowercase base36) and additionally accepts shorter cuid-shaped
  values that the prior `{8,}` floor incorrectly rejected. It rejects
  the punctuation-shaped false positives reported in #3621 and similar.

- Mark CUID v1 surfaces `@deprecated` (function, chain method, schema
  class, internals, regex export) pointing at z.cuid2(). Per the
  upstream repo, "Deprecated collision-resistant id spec. Insecure
  because it leaks timestamps. Use cuid2 instead."

- Add regression tests asserting that previously-accepted non-base36
  payloads are now rejected. Update one template-literal test that was
  relying on the old {8,} floor.

Zod 3 is in maintenance mode and is intentionally left untouched.
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Apr 29, 2026

TL;DR — Replaces the overly permissive CUID v1 regex (/^[cC][^\s-]{8,}$/) with a strict base-36 allowlist (/^[cC][0-9a-z]{6,}$/) and marks every CUID v1 API surface as @deprecated, pointing users to z.cuid2().

Key changes

  • Tighten cuid regex to base-36 allowlist — The old denylist pattern accepted non-CUID strings (including SQL injection and XSS-shaped payloads). The new pattern restricts the body to [0-9a-z] and lowers the minimum length from 8 to 6 to match the actual CUID v1 spec.
  • Deprecate all CUID v1 exports@deprecated JSDoc tags added to every interface, type alias, constructor, and factory function related to CUID v1 across core, classic, and mini packages, directing users to z.cuid2().
  • Add regression tests for previously accepted non-CUIDs — New test cases cover SQLi-shaped strings, XSS-shaped strings, underscores, and uppercase body characters that the old regex incorrectly accepted.

Summary | 9 files | 1 commit | base: maintighten-cuid-regex


Stricter CUID v1 regex

Before: /^[cC][^\s-]{8,}$/ — a denylist that only excluded whitespace and hyphens, accepting any other characters in the body.
After: /^[cC][0-9a-z]{6,}$/ — an allowlist restricted to base-36 characters (0-9a-z), matching what the CUID v1 library actually produces.

The old regex was a denylist: it accepted anything that wasn't whitespace or a hyphen. This meant strings like c<script>alert(1)</script>aaaaaa or cly63t164000245zw008pggon';select1; passed validation. The new regex is a strict allowlist matching the actual base-36 output format of the cuid library. The minimum body length also drops from 8 to 6, aligning with the spec.

Why is the minimum length 6 instead of 8?

CUID v1 strings consist of a c prefix followed by a timestamp, counter, fingerprint, and random block — all base-36 encoded. The shortest valid CUID is shorter than the old regex required; lowering to 6 avoids false negatives on legitimate short CUIDs.

regexes.ts · string.test.ts


CUID v1 deprecation

Before: cuid() and related types had no deprecation markers (or a generic one pointing at the z.cuid() top-level).
After: Every CUID v1 export — interfaces, type aliases, constructors, and factory functions — carries a @deprecated tag explaining that CUID v1 leaks timestamps and linking to the upstream deprecation notice.

The deprecation message is consistent across all three packages (core, classic, mini) and reads: "CUID v1 is deprecated by its authors due to information leakage (timestamps embedded in the id). Use z.cuid2() instead."

core/schemas.ts · core/api.ts · classic/schemas.ts · mini/schemas.ts

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.

No new issues. Reviewed the following changes:

  • Dropped v3 regex and test changes, scoping the PR to v4 only (commit message now notes "Zod 3 is in maintenance mode and is intentionally left untouched")
  • All v4 changes (regex tightening, deprecation annotations, tests) are unchanged from the previous version

Prior review feedback:

  • /i flag on v3 regex defeats base36 restriction — addressed by removing v3 changes entirely

Pullfrog  | View workflow run | Using Claude Opus𝕏

@colinhacks colinhacks merged commit 476ae24 into main Apr 29, 2026
7 checks passed
@colinhacks colinhacks deleted the tighten-cuid-regex branch April 29, 2026 02:32
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