Repository: colinhacks/zod (ts)

Issue:
v4 & v3: base64 check causes "Maximum call stack size exceeded"
So currently base64 check runs with a regexp in both zod versions, but with a quite long string it could cause an "Maximum call stack size exceeded" error. I have faced it myself having 4mb of base64 data, but i believe the boundary could be specific for the environment.

Also, the issue [was raised on the stackoverflow](https://stackoverflow.com/questions/77464085/validate-base64-regex-causes-rangeerror-maximum-call-stack-size-exceeded), where a proposed solution was to use `atob` instead.


Pull request description:
Improve base64 perf. Disallow padding in base64url.
Closes #4283

Patch:
diff --git a/packages/docs/content/v4/changelog.mdx b/packages/docs/content/v4/changelog.mdx
index 73bae402..d2723b53 100644
--- a/packages/docs/content/v4/changelog.mdx
+++ b/packages/docs/content/v4/changelog.mdx
@@ -253,6 +253,7 @@ In Zod 3, `z.number().safe()` is deprecated. It now behaves identically to `.int
 ### `.int()` accepts safe integers only
 
 The `z.number().int()` API no longer accepts unsafe integers (outside the range of `Number.MIN_SAFE_INTEGER` and `Number.MAX_SAFE_INTEGER`). Using integers out of this range causes spontaneous rounding errors. (Also: You should switch to `z.int()`.)
+
 ## `z.string()` updates
 
 ### deprecates `.email()` etc
@@ -287,6 +288,11 @@ z.string().email(); // ❌ deprecated
 z.email(); // ✅ 
 ```
 
+
+### no padding in `.base64url()` 
+
+Padding is no longer allowed in `z.base64url()` (formerly `z.string().base64url()`). Generally it's desirable for base64url strings to be unpadded and URL-safe. 
+
 ### drops `z.string().ip()` 
 
 This has been replaced with separate `.ipv4()` and `.ipv6()` methods. Use `z.union()` to combine them if you need to accept both.
diff --git a/packages/zod/src/v4/core/regexes.ts b/packages/zod/src/v4/core/regexes.ts
index e4a25296..e6a37994 100644
--- a/packages/zod/src/v4/core/regexes.ts
+++ b/packages/zod/src/v4/core/regexes.ts
@@ -67,8 +67,8 @@ export const cidrv6: RegExp =
 export const ip: RegExp = new RegExp(`(${ipv4.source})|(${ipv6.source})`);
 
 // https://stackoverflow.com/questions/7860392/determine-if-string-is-in-base64-using-javascript
-export const base64: RegExp = /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/;
-export const base64url: RegExp = /^([0-9a-zA-Z-_]{4})*(([0-9a-zA-Z-_]{2}(==)?)|([0-9a-zA-Z-_]{3}(=)?))?$/;
+export const base64: RegExp = /^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$/;
+export const base64url: RegExp = /^[A-Za-z0-9_-]*$/;
 
 // based on https://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address
 // export const hostname: RegExp =
diff --git a/packages/zod/src/v4/core/schemas.ts b/packages/zod/src/v4/core/schemas.ts
index 8a5d0870..a7c1bfc9 100644
--- a/packages/zod/src/v4/core/schemas.ts
+++ b/packages/zod/src/v4/core/schemas.ts
@@ -810,6 +810,16 @@ export const $ZodCIDRv6: core.$constructor<$ZodCIDRv6> = /*@__PURE__*/ core.$con
 // });
 
 //////////////////////////////   ZodBase64   //////////////////////////////
+export function isValidBase64(data: string): boolean {
+  if (data === "") return true;
+  if (data.length % 4 !== 0) return false;
+  try {
+    atob(data);
+    return true;
+  } catch {
+    return false;
+  }
+}
 
 export interface $ZodBase64Def extends $ZodStringFormatDef<"base64"> {}
 export interface $ZodBase64Internals extends $ZodStringFormatInternals<"base64"> {}
@@ -827,10 +837,27 @@ export const $ZodBase64: core.$constructor<$ZodBase64> = /*@__PURE__*/ core.$con
     inst._zod.onattach.push((inst) => {
       inst._zod.computed.contentEncoding = "base64";
     });
+
+    inst._zod.check = (payload) => {
+      if (isValidBase64(payload.value)) return;
+
+      payload.issues.push({
+        code: "invalid_format",
+        format: "base64",
+        input: payload.value,
+        inst,
+      });
+    };
   }
 );
 
 //////////////////////////////   ZodBase64   //////////////////////////////
+export function isValidBase64URL(data: string): boolean {
+  if (!regexes.base64url.test(data)) return false;
+  const base64 = data.replace(/[-_]/g, (c) => (c === "-" ? "+" : "/"));
+  const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
+  return isValidBase64(padded);
+}
 
 export interface $ZodBase64URLDef extends $ZodStringFormatDef<"base64url"> {}
 export interface $ZodBase64URLInternals extends $ZodStringFormatInternals<"base64url"> {}
@@ -848,6 +875,17 @@ export const $ZodBase64URL: core.$constructor<$ZodBase64URL> = /*@__PURE__*/ cor
     inst._zod.onattach.push((inst) => {
       inst._zod.computed.contentEncoding = "base64url";
     });
+
+    inst._zod.check = (payload) => {
+      if (isValidBase64URL(payload.value)) return;
+
+      payload.issues.push({
+        code: "invalid_format",
+        format: "base64url",
+        input: payload.value,
+        inst,
+      });
+    };
   }
 );
 
diff --git a/play.ts b/play.ts
index 0f43b643..41eb006b 100644
--- a/play.ts
+++ b/play.ts
@@ -1,14 +1,3 @@
 import { z } from "zod/v4";
 
-const schemas = [z.number().int(), z.int(), z.int().positive(), z.int().nonnegative(), z.int().gt(0), z.int().gte(0)];
-
-for (const schema of schemas) {
-  console.log(`Accepts ${Number.MAX_SAFE_INTEGER}`, schema.safeParse(Number.MAX_SAFE_INTEGER).success ? "YES" : "NO");
-  console.log("JSON Schema", z.toJSONSchema(schema));
-}
-
-const myEnum = z.enum(["left", "right", "center", "justify"]);
-const myObject = z.object({ myEnum: myEnum });
-
-type MyObject = z.infer<typeof myObject>;
-type MyObjectInput = z.input<typeof myObject>;
+z;


Test patch:
diff --git a/packages/zod/src/v4/classic/tests/json-schema.test.ts b/packages/zod/src/v4/classic/tests/json-schema.test.ts
index f2776129..9786d948 100644
--- a/packages/zod/src/v4/classic/tests/json-schema.test.ts
+++ b/packages/zod/src/v4/classic/tests/json-schema.test.ts
@@ -109,7 +109,7 @@ describe("toJSONSchema", () => {
       {
         "contentEncoding": "base64",
         "format": "base64",
-        "pattern": "^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$",
+        "pattern": "^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$",
         "type": "string",
       }
     `);
@@ -293,7 +293,7 @@ describe("toJSONSchema", () => {
       {
         "contentEncoding": "base64",
         "format": "base64",
-        "pattern": "^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$",
+        "pattern": "^$|^(?:[0-9a-zA-Z+/]{4})*(?:(?:[0-9a-zA-Z+/]{2}==)|(?:[0-9a-zA-Z+/]{3}=))?$",
         "type": "string",
       }
     `);
diff --git a/packages/zod/src/v4/classic/tests/string.test.ts b/packages/zod/src/v4/classic/tests/string.test.ts
index 1facb0d6..9d7fb775 100644
--- a/packages/zod/src/v4/classic/tests/string.test.ts
+++ b/packages/zod/src/v4/classic/tests/string.test.ts
@@ -1,3 +1,4 @@
+import { randomBytes } from "node:crypto";
 import { expect, test } from "vitest";
 
 import * as z from "zod/v4";
@@ -207,22 +208,22 @@ test("base64url validations", () => {
 
   const validBase64URLStrings = [
     "SGVsbG8gV29ybGQ", // "Hello World"
-    "SGVsbG8gV29ybGQ=", // "Hello World" with padding
+
     "VGhpcyBpcyBhbiBlbmNvZGVkIHN0cmluZw", // "This is an encoded string"
-    "VGhpcyBpcyBhbiBlbmNvZGVkIHN0cmluZw==", // "This is an encoded string" with padding
+
     "TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcms", // "Many hands make light work"
-    "TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcms=", // "Many hands make light work" with padding
+
     "UGF0aWVuY2UgaXMgdGhlIGtleSB0byBzdWNjZXNz", // "Patience is the key to success"
     "QmFzZTY0IGVuY29kaW5nIGlzIGZ1bg", // "Base64 encoding is fun"
-    "QmFzZTY0IGVuY29kaW5nIGlzIGZ1bg==", // "Base64 encoding is fun" with padding
+
     "MTIzNDU2Nzg5MA", // "1234567890"
-    "MTIzNDU2Nzg5MA==", // "1234567890" with padding
+
     "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo", // "abcdefghijklmnopqrstuvwxyz"
-    "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo=", // "abcdefghijklmnopqrstuvwxyz with padding"
+
     "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo", // "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
-    "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=", // "ABCDEFGHIJKLMNOPQRSTUVWXYZ" with padding
+
     "ISIkJSMmJyonKCk", // "!\"#$%&'()*"
-    "ISIkJSMmJyonKCk=", // "!\"#$%&'()*" with padding
+
     "", // Empty string is technically valid base64url
     "w7_Dv8O-w74K", // ÿÿþþ
     "123456",
@@ -239,6 +240,16 @@ test("base64url validations", () => {
     "!UGF0aWVuY2UgaXMgdGhlIGtleSB0byBzdWNjZXNz", // Invalid character '!'
     "?QmFzZTY0IGVuY29kaW5nIGlzIGZ1bg==", // Invalid character '?'
     ".MTIzND2Nzg5MC4=", // Invalid character '.'
+
+    // disallow valid padding
+    "SGVsbG8gV29ybGQ=", // "Hello World" with padding
+    "VGhpcyBpcyBhbiBlbmNvZGVkIHN0cmluZw==", // "This is an encoded string" with padding
+    "TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcms=", // "Many hands make light work" with padding
+    "QmFzZTY0IGVuY29kaW5nIGlzIGZ1bg==", // "Base64 encoding is fun" with padding
+    "MTIzNDU2Nzg5MA==", // "1234567890" with padding
+    "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo=", // "abcdefghijklmnopqrstuvwxyz with padding"
+    "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=", // "ABCDEFGHIJKLMNOPQRSTUVWXYZ" with padding
+    "ISIkJSMmJyonKCk=", // "!\"#$%&'()*" with padding
   ];
 
   for (const str of invalidBase64URLStrings) {
@@ -246,6 +257,13 @@ test("base64url validations", () => {
   }
 });
 
+test("big base64 and base64url", () => {
+  const bigbase64 = randomBytes(1024 * 1024 * 10).toString("base64");
+  z.base64().parse(bigbase64);
+  const bigbase64url = randomBytes(1024 * 1024 * 10).toString("base64url");
+  z.base64url().parse(bigbase64url);
+});
+
 test("jwt token", () => {
   const ONE_PART = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
   const NOT_BASE64 =