diff --git a/README.md b/README.md index 1a2da4f26..0c5b42834 100644 --- a/README.md +++ b/README.md @@ -811,7 +811,6 @@ z.string().datetime({ message: "Invalid datetime string! Must be UTC." }); z.string().date({ message: "Invalid date string!" }); z.string().time({ message: "Invalid time string!" }); z.string().ip({ message: "Invalid IP address" }); -z.string().ipRange({ message: "Invalid IP address range" }); ``` ### Datetimes @@ -919,31 +918,19 @@ ipv6.parse("192.168.1.1"); // fail ### IP addresses range -The `z.string().ipRange()` method by default validate IPv4 and IPv6. +The `z.string().ipRange()` method checks if a given IP address falls within a specified CIDR range. ```ts -const ipRange = z.string().ipRange(); +const ipv4Range = z.string().ipRange({ cidr: "192.168.1.0/24", version: "v4" }); -ipRange.parse("192.168.1.1/32"); // pass -ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/128"); // pass -ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:192.168.1.1/64"); // pass +ipv4Range.parse("192.168.1.10"); // pass +ipv4Range.parse("192.168.1.20"); // pass +ipv4Range.parse("192.168.2.10"); // fail -ipRange.parse("256.1.1.1/32"); // fail -ipRange.parse("192.168.1.1/33"); // fail -ipRange.parse("84d5:51a0:9114:gggg:4cfa:f2d7:1f12:7003/128"); // fail -ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/129"); // fail -``` - -You can additionally set the IP `version`. - -```ts -const ipv4Range = z.string().ipRange({ version: "v4" }); -ipv4Range.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/128"); // fail -ipv4Range.parse("192.168.1.1/128"); // fail +const ipv6Range = z.string().ipRange({ cidr: "2001:db8::/32", version: "v6" }); -const ipv6Range = z.string().ipRange({ version: "v6" }); -ipv6Range.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/32"); // fail -ipv6Range.parse("192.168.1.1/32"); // fail +ipv6Range.parse("2001:db8::1"); // pass +ipv6Range.parse("2001:db9::1"); // fail ``` ## Numbers diff --git a/deno/lib/README.md b/deno/lib/README.md index 1a2da4f26..0c5b42834 100644 --- a/deno/lib/README.md +++ b/deno/lib/README.md @@ -811,7 +811,6 @@ z.string().datetime({ message: "Invalid datetime string! Must be UTC." }); z.string().date({ message: "Invalid date string!" }); z.string().time({ message: "Invalid time string!" }); z.string().ip({ message: "Invalid IP address" }); -z.string().ipRange({ message: "Invalid IP address range" }); ``` ### Datetimes @@ -919,31 +918,19 @@ ipv6.parse("192.168.1.1"); // fail ### IP addresses range -The `z.string().ipRange()` method by default validate IPv4 and IPv6. +The `z.string().ipRange()` method checks if a given IP address falls within a specified CIDR range. ```ts -const ipRange = z.string().ipRange(); +const ipv4Range = z.string().ipRange({ cidr: "192.168.1.0/24", version: "v4" }); -ipRange.parse("192.168.1.1/32"); // pass -ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/128"); // pass -ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:192.168.1.1/64"); // pass +ipv4Range.parse("192.168.1.10"); // pass +ipv4Range.parse("192.168.1.20"); // pass +ipv4Range.parse("192.168.2.10"); // fail -ipRange.parse("256.1.1.1/32"); // fail -ipRange.parse("192.168.1.1/33"); // fail -ipRange.parse("84d5:51a0:9114:gggg:4cfa:f2d7:1f12:7003/128"); // fail -ipRange.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/129"); // fail -``` - -You can additionally set the IP `version`. - -```ts -const ipv4Range = z.string().ipRange({ version: "v4" }); -ipv4Range.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/128"); // fail -ipv4Range.parse("192.168.1.1/128"); // fail +const ipv6Range = z.string().ipRange({ cidr: "2001:db8::/32", version: "v6" }); -const ipv6Range = z.string().ipRange({ version: "v6" }); -ipv6Range.parse("84d5:51a0:9114:1855:4cfa:f2d7:1f12:7003/32"); // fail -ipv6Range.parse("192.168.1.1/32"); // fail +ipv6Range.parse("2001:db8::1"); // pass +ipv6Range.parse("2001:db9::1"); // fail ``` ## Numbers diff --git a/deno/lib/__tests__/string.test.ts b/deno/lib/__tests__/string.test.ts index 6e0b2a05a..c1dd0c9d9 100644 --- a/deno/lib/__tests__/string.test.ts +++ b/deno/lib/__tests__/string.test.ts @@ -436,16 +436,6 @@ test("checks getters", () => { expect(z.string().ip().isIPRange).toEqual(false); expect(z.string().ip().isULID).toEqual(false); - expect(z.string().ipRange().isEmail).toEqual(false); - expect(z.string().ipRange().isURL).toEqual(false); - expect(z.string().ipRange().isCUID).toEqual(false); - expect(z.string().ipRange().isCUID2).toEqual(false); - expect(z.string().ipRange().isUUID).toEqual(false); - expect(z.string().ipRange().isNANOID).toEqual(false); - expect(z.string().ipRange().isIP).toEqual(false); - expect(z.string().ipRange().isIPRange).toEqual(true); - expect(z.string().ipRange().isULID).toEqual(false); - expect(z.string().ulid().isEmail).toEqual(false); expect(z.string().ulid().isURL).toEqual(false); expect(z.string().ulid().isCUID).toEqual(false); @@ -789,56 +779,83 @@ test("IP validation", () => { }); test("IP Range validation", () => { - const ipRange = z.string().ipRange(); - expect(ipRange.safeParse("122.122.122.122/0").success).toBe(true); - expect(ipRange.safeParse("122.122.122.122/32").success).toBe(true); - expect(ipRange.safeParse("122.122.122.122/-1").success).toBe(false); - expect(ipRange.safeParse("122.122.122.122/33").success).toBe(false); + const ipv4Range = z + .string() + .ipRange({ cidr: "192.168.1.0/33", version: "v4" }); + expect(() => ipv4Range.parse("192.168.1.1")).toThrow(); - const ipv4Range = z.string().ipRange({ version: "v4" }); - expect(() => - ipv4Range.parse("6097:adfa:6f0b:220d:db08:5021:6191:7990/128") - ).toThrow(); + const ipv6Range = z + .string() + .ipRange({ cidr: "2001:db8::/129", version: "v6" }); + expect(() => ipv6Range.parse("2001:db9::1")).toThrow(); + + const validIPv4Ranges = [ + { ip: "192.168.1.10", cidr: "192.168.1.0/24" }, + { ip: "192.168.1.20", cidr: "192.168.1.0/24" }, + { ip: "172.16.0.5", cidr: "172.16.0.0/12" }, + { ip: "10.0.0.1", cidr: "10.0.0.0/8" }, + { ip: "2001:db8::1", cidr: "2001:db8::/32" }, + { ip: "2001:db8:1234:5678:abcd:ef01:2345:6789", cidr: "2001:db8::/32" }, + { ip: "2001:db8:85a3::8a2e:370:7334", cidr: "2001:db8::/32" }, + { ip: "0.0.0.0", cidr: "0.0.0.0/0" }, + { ip: "37.85.236.115", cidr: "37.85.236.0/24" }, + { ip: "114.71.82.94", cidr: "114.71.82.0/24" }, + ]; - const ipv6Range = z.string().ipRange({ version: "v6" }); - expect(() => ipv6Range.parse("254.164.77.1/32")).toThrow(); - - const validIPRanges = [ - "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c/0", - "9d4:c956:420f:5788:4339:9b3b:2418:75c3/128", - "474f:4c83::4e40:a47:ff95:0cda/16", - "d329:0:25b4:db47:a9d1:0:4926:0000/64", - "e48:10fb:1499:3e28:e4b6:dea5:4692:912c/8", - "114.71.82.94/0", - "0.0.0.0/0", - "37.85.236.115/32", + const invalidIPv4Ranges = [ + { ip: "192.168.2.10", cidr: "192.168.1.0/24" }, + { ip: "192.168.2.5", cidr: "192.168.1.0/24" }, + { ip: "10.1.1.1", cidr: "11.0.0.0/8" }, + { ip: "37.85.237.115", cidr: "37.85.236.0/24" }, + { ip: "114.71.83.94", cidr: "114.71.82.0/24" }, + { ip: "172.32.0.1", cidr: "172.16.0.0/12" }, ]; - const invalidIPRanges = [ - "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c/129", - "d329:1be4:25b4:db47:a9d1:dc71:4926:992c:14af/128", - "474f:4c83::4e40:a47:ff95:0cda/129", - "8f69::c757:395e:976e::3441/64", - "54cb::473f:d516:0.255.256.22/64", - "54cb::473f:d516:192.168.1/64", - "474f:4c83::4e40:a47:ff95:0cda/129", - "d329:0:25b4:db47:a9d1:0:4926:0000/129", - "e48:10fb:1499:3e28:e4b6:dea5:4692:912c/129", - "256.0.4.4/16", - "-1.0.555.4/32", - "0.0.0.0.0/0", - "1.1.1/16", - "114.71.82.94/128", - "0.0.0.0/33", + expect( + validIPv4Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v4" }); + + return ipRangeSchema.safeParse(ip).success; + }) + ).toBe(true); + expect( + invalidIPv4Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v4" }); + + return ipRangeSchema.safeParse(ip).success === false; + }) + ).toBe(true); + + const validIPv6Ranges = [ + { ip: "2001:db8::1", cidr: "2001:db8::/32" }, + { ip: "2001:db8:1234:5678:abcd:ef01:2345:6789", cidr: "2001:db8::/32" }, + { ip: "2001:db8:85a3::8a2e:370:7334", cidr: "2001:db8::/32" }, + { ip: "2001:db8:abcd:ef01:2345:6789:abcd:ef01", cidr: "2001:db8::/32" }, + { ip: "2001:db8:0:1:0:0:0:1", cidr: "2001:db8:0:1::/64" }, + { ip: "fe80::1", cidr: "fe80::/10" }, + { ip: "2001:4888:50:ff00:500:d::", cidr: "2001:4888:50:ff00::/64" }, ]; - // no parameters check IPv4 or IPv6 - const ipRangeSchema = z.string().ipRange(); + + const invalidIPv6Ranges = [ + { ip: "::1", cidr: "::/128" }, + { ip: "2001:db9::1", cidr: "2001:db8::/32" }, + { ip: "fe80::1", cidr: "2001:db8::/32" }, + { ip: "ff00::1", cidr: "2001:db8::/32" }, + { ip: "2001:db8:abcd:1234::1", cidr: "2001:db8:abcd:5678::/64" }, + ]; + expect( - validIPRanges.every((ipRange) => ipRangeSchema.safeParse(ipRange).success) + validIPv6Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v6" }); + + return ipRangeSchema.safeParse(ip).success; + }) ).toBe(true); expect( - invalidIPRanges.every( - (ipRange) => ipRangeSchema.safeParse(ipRange).success === false - ) + invalidIPv6Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v6" }); + + return ipRangeSchema.safeParse(ip).success === false; + }) ).toBe(true); }); diff --git a/deno/lib/types.ts b/deno/lib/types.ts index 94b78ec68..841ea6231 100644 --- a/deno/lib/types.ts +++ b/deno/lib/types.ts @@ -565,7 +565,7 @@ export type ZodStringCheck = } | { kind: "duration"; message?: string } | { kind: "ip"; version?: IpVersion; message?: string } - | { kind: "ipRange"; version?: IpVersion; message?: string } + | { kind: "ipRange"; cidr: string; version: IpVersion; message?: string } | { kind: "base64"; message?: string }; export interface ZodStringDef extends ZodTypeDef { @@ -609,11 +609,9 @@ let emojiRegex: RegExp; // faster, simpler, safer const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; -const ipv4CidrRegex = /^(3[0-2]|[12]?[0-9])$/; const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; -const ipv6CidrRegex = /^(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; // https://stackoverflow.com/questions/7860392/determine-if-string-is-in-base64-using-javascript const base64Regex = @@ -672,34 +670,55 @@ function isValidIP(ip: string, version?: IpVersion) { return false; } -function isValidIPRange(ip: string, version?: IpVersion) { - const [ipAddress, mask] = ip.split("/"); - if (ipAddress === undefined || mask === undefined) { - return false; - } - if ( - !version && - ((ipv4Regex.test(ipAddress) && ipv4CidrRegex.test(mask)) || - (ipv6Regex.test(ipAddress) && ipv6CidrRegex.test(mask))) - ) { - return true; - } - if ( - version === "v4" && - ipv4Regex.test(ipAddress) && - ipv4CidrRegex.test(mask) - ) { - return true; +const ipToBinary = (ip: string, version: IpVersion): string => { + if (version === "v4") { + return ip + .split(".") + .map((octet) => parseInt(octet, 10).toString(2).padStart(8, "0")) + .join(""); + } else if (version === "v6") { + const segments = ip.split(":"); + const fullSegments: string[] = []; + let zeroFillCount = 0; + + for (const segment of segments) { + if (segment === "") { + zeroFillCount = 8 - (segments.length - 1); + continue; + } + fullSegments.push(segment.padStart(4, "0")); + } + + for (let i = 0; i < zeroFillCount; i++) { + fullSegments.splice(segments.indexOf("") + i, 0, "0000"); + } + + return fullSegments + .map((segment) => parseInt(segment, 16).toString(2).padStart(16, "0")) + .join(""); + } else { + throw new Error("Invalid IP version"); } +}; + +function isValidIPRange(ip: string, cidr: string, version: IpVersion) { + const [rangeIp, prefixLengthStr] = cidr.split("/"); + const prefixLength = parseInt(prefixLengthStr, 10); + if ( - version === "v6" && - ipv6Regex.test(ipAddress) && - ipv6CidrRegex.test(mask) + (version === "v4" && (prefixLength < 0 || prefixLength > 32)) || + (version === "v6" && (prefixLength < 0 || prefixLength > 128)) ) { - return true; + throw new Error("Invalid prefix length"); } - return false; + const ipBinary = ipToBinary(ip, version); + const rangeBinary = ipToBinary(rangeIp, version); + + return ( + ipBinary.substring(0, prefixLength) === + rangeBinary.substring(0, prefixLength) + ); } export class ZodString extends ZodType { @@ -965,7 +984,7 @@ export class ZodString extends ZodType { status.dirty(); } } else if (check.kind === "ipRange") { - if (!isValidIPRange(input.data, check.version)) { + if (!isValidIPRange(input.data, check.cidr, check.version)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "ipRange", @@ -1047,9 +1066,14 @@ export class ZodString extends ZodType { return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) }); } - ipRange(options?: string | { version?: IpVersion; message?: string }) { + ipRange( + args: { cidr: string; version: IpVersion }, + options?: string | { message?: string } + ) { return this._addCheck({ kind: "ipRange", + cidr: args.cidr, + version: args.version, ...errorUtil.errToObj(options), }); } diff --git a/src/__tests__/string.test.ts b/src/__tests__/string.test.ts index 5d59969de..dec5c6bf7 100644 --- a/src/__tests__/string.test.ts +++ b/src/__tests__/string.test.ts @@ -435,16 +435,6 @@ test("checks getters", () => { expect(z.string().ip().isIPRange).toEqual(false); expect(z.string().ip().isULID).toEqual(false); - expect(z.string().ipRange().isEmail).toEqual(false); - expect(z.string().ipRange().isURL).toEqual(false); - expect(z.string().ipRange().isCUID).toEqual(false); - expect(z.string().ipRange().isCUID2).toEqual(false); - expect(z.string().ipRange().isUUID).toEqual(false); - expect(z.string().ipRange().isNANOID).toEqual(false); - expect(z.string().ipRange().isIP).toEqual(false); - expect(z.string().ipRange().isIPRange).toEqual(true); - expect(z.string().ipRange().isULID).toEqual(false); - expect(z.string().ulid().isEmail).toEqual(false); expect(z.string().ulid().isURL).toEqual(false); expect(z.string().ulid().isCUID).toEqual(false); @@ -788,56 +778,83 @@ test("IP validation", () => { }); test("IP Range validation", () => { - const ipRange = z.string().ipRange(); - expect(ipRange.safeParse("122.122.122.122/0").success).toBe(true); - expect(ipRange.safeParse("122.122.122.122/32").success).toBe(true); - expect(ipRange.safeParse("122.122.122.122/-1").success).toBe(false); - expect(ipRange.safeParse("122.122.122.122/33").success).toBe(false); + const ipv4Range = z + .string() + .ipRange({ cidr: "192.168.1.0/33", version: "v4" }); + expect(() => ipv4Range.parse("192.168.1.1")).toThrow(); - const ipv4Range = z.string().ipRange({ version: "v4" }); - expect(() => - ipv4Range.parse("6097:adfa:6f0b:220d:db08:5021:6191:7990/128") - ).toThrow(); + const ipv6Range = z + .string() + .ipRange({ cidr: "2001:db8::/129", version: "v6" }); + expect(() => ipv6Range.parse("2001:db9::1")).toThrow(); + + const validIPv4Ranges = [ + { ip: "192.168.1.10", cidr: "192.168.1.0/24" }, + { ip: "192.168.1.20", cidr: "192.168.1.0/24" }, + { ip: "172.16.0.5", cidr: "172.16.0.0/12" }, + { ip: "10.0.0.1", cidr: "10.0.0.0/8" }, + { ip: "2001:db8::1", cidr: "2001:db8::/32" }, + { ip: "2001:db8:1234:5678:abcd:ef01:2345:6789", cidr: "2001:db8::/32" }, + { ip: "2001:db8:85a3::8a2e:370:7334", cidr: "2001:db8::/32" }, + { ip: "0.0.0.0", cidr: "0.0.0.0/0" }, + { ip: "37.85.236.115", cidr: "37.85.236.0/24" }, + { ip: "114.71.82.94", cidr: "114.71.82.0/24" }, + ]; - const ipv6Range = z.string().ipRange({ version: "v6" }); - expect(() => ipv6Range.parse("254.164.77.1/32")).toThrow(); - - const validIPRanges = [ - "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c/0", - "9d4:c956:420f:5788:4339:9b3b:2418:75c3/128", - "474f:4c83::4e40:a47:ff95:0cda/16", - "d329:0:25b4:db47:a9d1:0:4926:0000/64", - "e48:10fb:1499:3e28:e4b6:dea5:4692:912c/8", - "114.71.82.94/0", - "0.0.0.0/0", - "37.85.236.115/32", + const invalidIPv4Ranges = [ + { ip: "192.168.2.10", cidr: "192.168.1.0/24" }, + { ip: "192.168.2.5", cidr: "192.168.1.0/24" }, + { ip: "10.1.1.1", cidr: "11.0.0.0/8" }, + { ip: "37.85.237.115", cidr: "37.85.236.0/24" }, + { ip: "114.71.83.94", cidr: "114.71.82.0/24" }, + { ip: "172.32.0.1", cidr: "172.16.0.0/12" }, ]; - const invalidIPRanges = [ - "1e5e:e6c8:daac:514b:114b:e360:d8c0:682c/129", - "d329:1be4:25b4:db47:a9d1:dc71:4926:992c:14af/128", - "474f:4c83::4e40:a47:ff95:0cda/129", - "8f69::c757:395e:976e::3441/64", - "54cb::473f:d516:0.255.256.22/64", - "54cb::473f:d516:192.168.1/64", - "474f:4c83::4e40:a47:ff95:0cda/129", - "d329:0:25b4:db47:a9d1:0:4926:0000/129", - "e48:10fb:1499:3e28:e4b6:dea5:4692:912c/129", - "256.0.4.4/16", - "-1.0.555.4/32", - "0.0.0.0.0/0", - "1.1.1/16", - "114.71.82.94/128", - "0.0.0.0/33", + expect( + validIPv4Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v4" }); + + return ipRangeSchema.safeParse(ip).success; + }) + ).toBe(true); + expect( + invalidIPv4Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v4" }); + + return ipRangeSchema.safeParse(ip).success === false; + }) + ).toBe(true); + + const validIPv6Ranges = [ + { ip: "2001:db8::1", cidr: "2001:db8::/32" }, + { ip: "2001:db8:1234:5678:abcd:ef01:2345:6789", cidr: "2001:db8::/32" }, + { ip: "2001:db8:85a3::8a2e:370:7334", cidr: "2001:db8::/32" }, + { ip: "2001:db8:abcd:ef01:2345:6789:abcd:ef01", cidr: "2001:db8::/32" }, + { ip: "2001:db8:0:1:0:0:0:1", cidr: "2001:db8:0:1::/64" }, + { ip: "fe80::1", cidr: "fe80::/10" }, + { ip: "2001:4888:50:ff00:500:d::", cidr: "2001:4888:50:ff00::/64" }, ]; - // no parameters check IPv4 or IPv6 - const ipRangeSchema = z.string().ipRange(); + + const invalidIPv6Ranges = [ + { ip: "::1", cidr: "::/128" }, + { ip: "2001:db9::1", cidr: "2001:db8::/32" }, + { ip: "fe80::1", cidr: "2001:db8::/32" }, + { ip: "ff00::1", cidr: "2001:db8::/32" }, + { ip: "2001:db8:abcd:1234::1", cidr: "2001:db8:abcd:5678::/64" }, + ]; + expect( - validIPRanges.every((ipRange) => ipRangeSchema.safeParse(ipRange).success) + validIPv6Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v6" }); + + return ipRangeSchema.safeParse(ip).success; + }) ).toBe(true); expect( - invalidIPRanges.every( - (ipRange) => ipRangeSchema.safeParse(ipRange).success === false - ) + invalidIPv6Ranges.every(({ ip, cidr }) => { + const ipRangeSchema = z.string().ipRange({ cidr, version: "v6" }); + + return ipRangeSchema.safeParse(ip).success === false; + }) ).toBe(true); }); diff --git a/src/types.ts b/src/types.ts index 50676e1c3..00c2882a7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -565,7 +565,7 @@ export type ZodStringCheck = } | { kind: "duration"; message?: string } | { kind: "ip"; version?: IpVersion; message?: string } - | { kind: "ipRange"; version?: IpVersion; message?: string } + | { kind: "ipRange"; cidr: string; version: IpVersion; message?: string } | { kind: "base64"; message?: string }; export interface ZodStringDef extends ZodTypeDef { @@ -609,11 +609,9 @@ let emojiRegex: RegExp; // faster, simpler, safer const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/; -const ipv4CidrRegex = /^(3[0-2]|[12]?[0-9])$/; const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/; -const ipv6CidrRegex = /^(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; // https://stackoverflow.com/questions/7860392/determine-if-string-is-in-base64-using-javascript const base64Regex = @@ -672,34 +670,55 @@ function isValidIP(ip: string, version?: IpVersion) { return false; } -function isValidIPRange(ip: string, version?: IpVersion) { - const [ipAddress, mask] = ip.split("/"); - if (ipAddress === undefined || mask === undefined) { - return false; - } - if ( - !version && - ((ipv4Regex.test(ipAddress) && ipv4CidrRegex.test(mask)) || - (ipv6Regex.test(ipAddress) && ipv6CidrRegex.test(mask))) - ) { - return true; - } - if ( - version === "v4" && - ipv4Regex.test(ipAddress) && - ipv4CidrRegex.test(mask) - ) { - return true; +const ipToBinary = (ip: string, version: IpVersion): string => { + if (version === "v4") { + return ip + .split(".") + .map((octet) => parseInt(octet, 10).toString(2).padStart(8, "0")) + .join(""); + } else if (version === "v6") { + const segments = ip.split(":"); + const fullSegments: string[] = []; + let zeroFillCount = 0; + + for (const segment of segments) { + if (segment === "") { + zeroFillCount = 8 - (segments.length - 1); + continue; + } + fullSegments.push(segment.padStart(4, "0")); + } + + for (let i = 0; i < zeroFillCount; i++) { + fullSegments.splice(segments.indexOf("") + i, 0, "0000"); + } + + return fullSegments + .map((segment) => parseInt(segment, 16).toString(2).padStart(16, "0")) + .join(""); + } else { + throw new Error("Invalid IP version"); } +}; + +function isValidIPRange(ip: string, cidr: string, version: IpVersion) { + const [rangeIp, prefixLengthStr] = cidr.split("/"); + const prefixLength = parseInt(prefixLengthStr, 10); + if ( - version === "v6" && - ipv6Regex.test(ipAddress) && - ipv6CidrRegex.test(mask) + (version === "v4" && (prefixLength < 0 || prefixLength > 32)) || + (version === "v6" && (prefixLength < 0 || prefixLength > 128)) ) { - return true; + throw new Error("Invalid prefix length"); } - return false; + const ipBinary = ipToBinary(ip, version); + const rangeBinary = ipToBinary(rangeIp, version); + + return ( + ipBinary.substring(0, prefixLength) === + rangeBinary.substring(0, prefixLength) + ); } export class ZodString extends ZodType { @@ -965,7 +984,7 @@ export class ZodString extends ZodType { status.dirty(); } } else if (check.kind === "ipRange") { - if (!isValidIPRange(input.data, check.version)) { + if (!isValidIPRange(input.data, check.cidr, check.version)) { ctx = this._getOrReturnCtx(input, ctx); addIssueToContext(ctx, { validation: "ipRange", @@ -1047,9 +1066,14 @@ export class ZodString extends ZodType { return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) }); } - ipRange(options?: string | { version?: IpVersion; message?: string }) { + ipRange( + args: { cidr: string; version: IpVersion }, + options?: string | { message?: string } + ) { return this._addCheck({ kind: "ipRange", + cidr: args.cidr, + version: args.version, ...errorUtil.errToObj(options), }); }