From c54e1099a28793368ef6302cdcc1f95201b49c69 Mon Sep 17 00:00:00 2001 From: Stefan Terdell <35991746+StefanTerdell@users.noreply.github.com> Date: Wed, 17 Apr 2024 00:17:16 +0000 Subject: [PATCH] Adds base64 string validation (#3047) * Add base64 string validation * Build deno * Update README --------- Co-authored-by: Colin McDonnell --- README.md | 1 + deno/lib/README.md | 1 + deno/lib/ZodError.ts | 1 + deno/lib/__tests__/string.test.ts | 35 +++++++++++++++++++++++++++++++ deno/lib/types.ts | 26 +++++++++++++++++++++-- src/ZodError.ts | 1 + src/__tests__/string.test.ts | 35 +++++++++++++++++++++++++++++++ src/types.ts | 24 ++++++++++++++++++++- 8 files changed, 121 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f3a1d671c..584af36f0 100644 --- a/README.md +++ b/README.md @@ -859,6 +859,7 @@ z.string().datetime(); // ISO 8601; by default only `Z` timezone allowed z.string().date(); // ISO date format (YYYY-MM-DD) z.string().time(); // ISO time format (HH:mm:ss[.SSSSSS]) z.string().ip(); // defaults to allow both IPv4 and IPv6 +z.string().base64(); // transformations z.string().trim(); // trim whitespace diff --git a/deno/lib/README.md b/deno/lib/README.md index f3a1d671c..584af36f0 100644 --- a/deno/lib/README.md +++ b/deno/lib/README.md @@ -859,6 +859,7 @@ z.string().datetime(); // ISO 8601; by default only `Z` timezone allowed z.string().date(); // ISO date format (YYYY-MM-DD) z.string().time(); // ISO time format (HH:mm:ss[.SSSSSS]) z.string().ip(); // defaults to allow both IPv4 and IPv6 +z.string().base64(); // transformations z.string().trim(); // trim whitespace diff --git a/deno/lib/ZodError.ts b/deno/lib/ZodError.ts index 7710d2ed3..3863c3e56 100644 --- a/deno/lib/ZodError.ts +++ b/deno/lib/ZodError.ts @@ -102,6 +102,7 @@ export type StringValidation = | "date" | "time" | "ip" + | "base64" | { includes: string; position?: number } | { startsWith: string } | { endsWith: string }; diff --git a/deno/lib/__tests__/string.test.ts b/deno/lib/__tests__/string.test.ts index 78a4458ef..5391da7f2 100644 --- a/deno/lib/__tests__/string.test.ts +++ b/deno/lib/__tests__/string.test.ts @@ -165,6 +165,41 @@ test("email validations", () => { ).toBe(true); }); +test("base64 validations", () => { + const validBase64Strings = [ + "SGVsbG8gV29ybGQ=", // "Hello World" + "VGhpcyBpcyBhbiBlbmNvZGVkIHN0cmluZw==", // "This is an encoded string" + "TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcms=", // "Many hands make light work" + "UGF0aWVuY2UgaXMgdGhlIGtleSB0byBzdWNjZXNz", // "Patience is the key to success" + "QmFzZTY0IGVuY29kaW5nIGlzIGZ1bg==", // "Base64 encoding is fun" + "MTIzNDU2Nzg5MA==", // "1234567890" + "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo=", // "abcdefghijklmnopqrstuvwxyz" + "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=", // "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "ISIkJSMmJyonKCk=", // "!\"#$%&'()*" + "", // Empty string is technically a valid base64 + ]; + + for (const str of validBase64Strings) { + expect(str + z.string().base64().safeParse(str).success).toBe(str + "true"); + } + + const invalidBase64Strings = [ + "12345", // Not padded correctly, not a multiple of 4 characters + "SGVsbG8gV29ybGQ", // Missing padding + "VGhpcyBpcyBhbiBlbmNvZGVkIHN0cmluZw", // Missing padding + "!UGF0aWVuY2UgaXMgdGhlIGtleSB0byBzdWNjZXNz", // Invalid character '!' + "?QmFzZTY0IGVuY29kaW5nIGlzIGZ1bg==", // Invalid character '?' + ".MTIzND2Nzg5MC4=", // Invalid character '.' + "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo", // Missing padding + ]; + + for (const str of invalidBase64Strings) { + expect(str + z.string().base64().safeParse(str).success).toBe( + str + "false" + ); + } +}); + test("url validations", () => { const url = z.string().url(); url.parse("http://google.com"); diff --git a/deno/lib/types.ts b/deno/lib/types.ts index 97d0a61a2..1a77d82f8 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -563,7 +563,8 @@ export type ZodStringCheck = precision: number | null; message?: string; } - | { kind: "ip"; version?: IpVersion; message?: string }; + | { kind: "ip"; version?: IpVersion; message?: string } + | { kind: "base64"; message?: string }; export interface ZodStringDef extends ZodTypeDef { checks: ZodStringCheck[]; @@ -572,7 +573,7 @@ export interface ZodStringDef extends ZodTypeDef { } const cuidRegex = /^c[^\s-]{8,}$/i; -const cuid2Regex = /^[a-z][a-z0-9]*$/; +const cuid2Regex = /^[0-9a-z]+$/; const ulidRegex = /^[0-9A-HJKMNP-TV-Z]{26}$/; // const uuidRegex = // /^([a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}|00000000-0000-0000-0000-000000000000)$/i; @@ -606,6 +607,10 @@ const ipv4Regex = const ipv6Regex = /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/; +// https://stackoverflow.com/questions/7860392/determine-if-string-is-in-base64-using-javascript +const base64Regex = + /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; + // const dateRegexSource = `\\d{4}-\\d{2}-\\d{2}`; const dateRegexSource = `\\d{4}-((0[13578]|10|12)-31|(0[13-9]|1[0-2])-30|(0[1-9]|1[0-2])-(0[1-9]|1\\d|2\\d))`; const dateRegex = new RegExp(`^${dateRegexSource}$`); @@ -613,6 +618,7 @@ const dateRegex = new RegExp(`^${dateRegexSource}$`); function timeRegexSource(args: { precision?: number | null }) { // let regex = `\\d{2}:\\d{2}:\\d{2}`; let regex = `([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d`; + if (args.precision) { regex = `${regex}\\.\\d{${args.precision}}`; } else if (args.precision == null) { @@ -906,6 +912,16 @@ export class ZodString extends ZodType { }); status.dirty(); } + } else if (check.kind === "base64") { + if (!base64Regex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "base64", + code: ZodIssueCode.invalid_string, + message: check.message, + }); + status.dirty(); + } } else { util.assertNever(check); } @@ -961,6 +977,9 @@ export class ZodString extends ZodType { ulid(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "ulid", ...errorUtil.errToObj(message) }); } + base64(message?: errorUtil.ErrMessage) { + return this._addCheck({ kind: "base64", ...errorUtil.errToObj(message) }); + } ip(options?: string | { version?: "v4" | "v6"; message?: string }) { return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) }); @@ -1152,6 +1171,9 @@ export class ZodString extends ZodType { get isIP() { return !!this._def.checks.find((ch) => ch.kind === "ip"); } + get isBase64() { + return !!this._def.checks.find((ch) => ch.kind === "base64"); + } get minLength() { let min: number | null = null; diff --git a/src/ZodError.ts b/src/ZodError.ts index 202f84804..61e79316d 100644 --- a/src/ZodError.ts +++ b/src/ZodError.ts @@ -102,6 +102,7 @@ export type StringValidation = | "date" | "time" | "ip" + | "base64" | { includes: string; position?: number } | { startsWith: string } | { endsWith: string }; diff --git a/src/__tests__/string.test.ts b/src/__tests__/string.test.ts index 2eae674b1..ea2f1376b 100644 --- a/src/__tests__/string.test.ts +++ b/src/__tests__/string.test.ts @@ -164,6 +164,41 @@ test("email validations", () => { ).toBe(true); }); +test("base64 validations", () => { + const validBase64Strings = [ + "SGVsbG8gV29ybGQ=", // "Hello World" + "VGhpcyBpcyBhbiBlbmNvZGVkIHN0cmluZw==", // "This is an encoded string" + "TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcms=", // "Many hands make light work" + "UGF0aWVuY2UgaXMgdGhlIGtleSB0byBzdWNjZXNz", // "Patience is the key to success" + "QmFzZTY0IGVuY29kaW5nIGlzIGZ1bg==", // "Base64 encoding is fun" + "MTIzNDU2Nzg5MA==", // "1234567890" + "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXo=", // "abcdefghijklmnopqrstuvwxyz" + "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=", // "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "ISIkJSMmJyonKCk=", // "!\"#$%&'()*" + "", // Empty string is technically a valid base64 + ]; + + for (const str of validBase64Strings) { + expect(str + z.string().base64().safeParse(str).success).toBe(str + "true"); + } + + const invalidBase64Strings = [ + "12345", // Not padded correctly, not a multiple of 4 characters + "SGVsbG8gV29ybGQ", // Missing padding + "VGhpcyBpcyBhbiBlbmNvZGVkIHN0cmluZw", // Missing padding + "!UGF0aWVuY2UgaXMgdGhlIGtleSB0byBzdWNjZXNz", // Invalid character '!' + "?QmFzZTY0IGVuY29kaW5nIGlzIGZ1bg==", // Invalid character '?' + ".MTIzND2Nzg5MC4=", // Invalid character '.' + "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo", // Missing padding + ]; + + for (const str of invalidBase64Strings) { + expect(str + z.string().base64().safeParse(str).success).toBe( + str + "false" + ); + } +}); + test("url validations", () => { const url = z.string().url(); url.parse("http://google.com"); diff --git a/src/types.ts b/src/types.ts index d0c5bb204..d82a7df17 100644 --- a/src/types.ts +++ b/src/types.ts @@ -563,7 +563,8 @@ export type ZodStringCheck = precision: number | null; message?: string; } - | { kind: "ip"; version?: IpVersion; message?: string }; + | { kind: "ip"; version?: IpVersion; message?: string } + | { kind: "base64"; message?: string }; export interface ZodStringDef extends ZodTypeDef { checks: ZodStringCheck[]; @@ -606,6 +607,10 @@ const ipv4Regex = const ipv6Regex = /^(([a-f0-9]{1,4}:){7}|::([a-f0-9]{1,4}:){0,6}|([a-f0-9]{1,4}:){1}:([a-f0-9]{1,4}:){0,5}|([a-f0-9]{1,4}:){2}:([a-f0-9]{1,4}:){0,4}|([a-f0-9]{1,4}:){3}:([a-f0-9]{1,4}:){0,3}|([a-f0-9]{1,4}:){4}:([a-f0-9]{1,4}:){0,2}|([a-f0-9]{1,4}:){5}:([a-f0-9]{1,4}:){0,1})([a-f0-9]{1,4}|(((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2}))\.){3}((25[0-5])|(2[0-4][0-9])|(1[0-9]{2})|([0-9]{1,2})))$/; +// https://stackoverflow.com/questions/7860392/determine-if-string-is-in-base64-using-javascript +const base64Regex = + /^([0-9a-zA-Z+/]{4})*(([0-9a-zA-Z+/]{2}==)|([0-9a-zA-Z+/]{3}=))?$/; + // const dateRegexSource = `\\d{4}-\\d{2}-\\d{2}`; const dateRegexSource = `\\d{4}-((0[13578]|10|12)-31|(0[13-9]|1[0-2])-30|(0[1-9]|1[0-2])-(0[1-9]|1\\d|2\\d))`; const dateRegex = new RegExp(`^${dateRegexSource}$`); @@ -613,6 +618,7 @@ const dateRegex = new RegExp(`^${dateRegexSource}$`); function timeRegexSource(args: { precision?: number | null }) { // let regex = `\\d{2}:\\d{2}:\\d{2}`; let regex = `([01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d`; + if (args.precision) { regex = `${regex}\\.\\d{${args.precision}}`; } else if (args.precision == null) { @@ -906,6 +912,16 @@ export class ZodString extends ZodType { }); status.dirty(); } + } else if (check.kind === "base64") { + if (!base64Regex.test(input.data)) { + ctx = this._getOrReturnCtx(input, ctx); + addIssueToContext(ctx, { + validation: "base64", + code: ZodIssueCode.invalid_string, + message: check.message, + }); + status.dirty(); + } } else { util.assertNever(check); } @@ -961,6 +977,9 @@ export class ZodString extends ZodType { ulid(message?: errorUtil.ErrMessage) { return this._addCheck({ kind: "ulid", ...errorUtil.errToObj(message) }); } + base64(message?: errorUtil.ErrMessage) { + return this._addCheck({ kind: "base64", ...errorUtil.errToObj(message) }); + } ip(options?: string | { version?: "v4" | "v6"; message?: string }) { return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) }); @@ -1152,6 +1171,9 @@ export class ZodString extends ZodType { get isIP() { return !!this._def.checks.find((ch) => ch.kind === "ip"); } + get isBase64() { + return !!this._def.checks.find((ch) => ch.kind === "base64"); + } get minLength() { let min: number | null = null;