Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(#2758): z.string().ipRange() - add support for IP address range #3754

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
- [Dates](#dates)
- [Times](#times)
- [IP addresses](#ip-addresses)
- [IP addresses range](#ip-addresses-range)
- [IP ranges](#ip-ranges-cidr)
- [Numbers](#numbers)
- [BigInts](#bigints)
Expand Down Expand Up @@ -933,6 +934,23 @@ const ipv6 = z.string().ip({ version: "v6" });
ipv6.parse("192.168.1.1"); // fail
```

### IP addresses range

The `z.string().ipRange()` method checks if a given IP address falls within a specified CIDR range.

```ts
const ipv4Range = z.string().ipRange({ cidr: "192.168.1.0/24", version: "v4" });

ipv4Range.parse("192.168.1.10"); // pass
ipv4Range.parse("192.168.1.20"); // pass
ipv4Range.parse("192.168.2.10"); // fail

const ipv6Range = z.string().ipRange({ cidr: "2001:db8::/32", version: "v6" });

ipv6Range.parse("2001:db8::1"); // pass
ipv6Range.parse("2001:db9::1"); // fail
```

### IP ranges (CIDR)

Validate IP address ranges specified with [CIDR notation](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing). By default, `.cidr()` allows both IPv4 and IPv6.
Expand Down
18 changes: 18 additions & 0 deletions deno/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
- [Dates](#dates)
- [Times](#times)
- [IP addresses](#ip-addresses)
- [IP addresses range](#ip-addresses-range)
- [IP ranges](#ip-ranges-cidr)
- [Numbers](#numbers)
- [BigInts](#bigints)
Expand Down Expand Up @@ -933,6 +934,23 @@ const ipv6 = z.string().ip({ version: "v6" });
ipv6.parse("192.168.1.1"); // fail
```

### IP addresses range

The `z.string().ipRange()` method checks if a given IP address falls within a specified CIDR range.

```ts
const ipv4Range = z.string().ipRange({ cidr: "192.168.1.0/24", version: "v4" });

ipv4Range.parse("192.168.1.10"); // pass
ipv4Range.parse("192.168.1.20"); // pass
ipv4Range.parse("192.168.2.10"); // fail

const ipv6Range = z.string().ipRange({ cidr: "2001:db8::/32", version: "v6" });

ipv6Range.parse("2001:db8::1"); // pass
ipv6Range.parse("2001:db9::1"); // fail
```

### IP ranges (CIDR)

Validate IP address ranges specified with [CIDR notation](https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing). By default, `.cidr()` allows both IPv4 and IPv6.
Expand Down
1 change: 1 addition & 0 deletions deno/lib/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export type StringValidation =
| "time"
| "duration"
| "ip"
| "ipRange"
| "cidr"
| "base64"
| "jwt"
Expand Down
90 changes: 90 additions & 0 deletions deno/lib/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ test("checks getters", () => {
expect(z.string().email().isUUID).toEqual(false);
expect(z.string().email().isNANOID).toEqual(false);
expect(z.string().email().isIP).toEqual(false);
expect(z.string().email().isIPRange).toEqual(false);
expect(z.string().email().isCIDR).toEqual(false);
expect(z.string().email().isULID).toEqual(false);

Expand All @@ -473,6 +474,7 @@ test("checks getters", () => {
expect(z.string().url().isUUID).toEqual(false);
expect(z.string().url().isNANOID).toEqual(false);
expect(z.string().url().isIP).toEqual(false);
expect(z.string().url().isIPRange).toEqual(false);
expect(z.string().url().isCIDR).toEqual(false);
expect(z.string().url().isULID).toEqual(false);

Expand All @@ -483,6 +485,7 @@ test("checks getters", () => {
expect(z.string().cuid().isUUID).toEqual(false);
expect(z.string().cuid().isNANOID).toEqual(false);
expect(z.string().cuid().isIP).toEqual(false);
expect(z.string().cuid().isIPRange).toEqual(false);
expect(z.string().cuid().isCIDR).toEqual(false);
expect(z.string().cuid().isULID).toEqual(false);

Expand All @@ -493,6 +496,7 @@ test("checks getters", () => {
expect(z.string().cuid2().isUUID).toEqual(false);
expect(z.string().cuid2().isNANOID).toEqual(false);
expect(z.string().cuid2().isIP).toEqual(false);
expect(z.string().cuid2().isIPRange).toEqual(false);
expect(z.string().cuid2().isCIDR).toEqual(false);
expect(z.string().cuid2().isULID).toEqual(false);

Expand All @@ -503,6 +507,7 @@ test("checks getters", () => {
expect(z.string().uuid().isUUID).toEqual(true);
expect(z.string().uuid().isNANOID).toEqual(false);
expect(z.string().uuid().isIP).toEqual(false);
expect(z.string().uuid().isIPRange).toEqual(false);
expect(z.string().uuid().isCIDR).toEqual(false);
expect(z.string().uuid().isULID).toEqual(false);

Expand All @@ -513,6 +518,7 @@ test("checks getters", () => {
expect(z.string().nanoid().isUUID).toEqual(false);
expect(z.string().nanoid().isNANOID).toEqual(true);
expect(z.string().nanoid().isIP).toEqual(false);
expect(z.string().nanoid().isIPRange).toEqual(false);
expect(z.string().nanoid().isCIDR).toEqual(false);
expect(z.string().nanoid().isULID).toEqual(false);

Expand All @@ -523,6 +529,7 @@ test("checks getters", () => {
expect(z.string().ip().isUUID).toEqual(false);
expect(z.string().ip().isNANOID).toEqual(false);
expect(z.string().ip().isIP).toEqual(true);
expect(z.string().ip().isIPRange).toEqual(false);
expect(z.string().ip().isCIDR).toEqual(false);
expect(z.string().ip().isULID).toEqual(false);

Expand All @@ -543,6 +550,7 @@ test("checks getters", () => {
expect(z.string().ulid().isUUID).toEqual(false);
expect(z.string().ulid().isNANOID).toEqual(false);
expect(z.string().ulid().isIP).toEqual(false);
expect(z.string().ulid().isIPRange).toEqual(false);
expect(z.string().ulid().isCIDR).toEqual(false);
expect(z.string().ulid().isULID).toEqual(true);
});
Expand Down Expand Up @@ -878,6 +886,88 @@ test("IP validation", () => {
).toBe(true);
});

test("IP Range validation", () => {
const ipv4Range = z
.string()
.ipRange({ cidr: "192.168.1.0/33", version: "v4" });
expect(() => ipv4Range.parse("192.168.1.1")).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 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" },
];

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" },
];

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(
validIPv6Ranges.every(({ ip, cidr }) => {
const ipRangeSchema = z.string().ipRange({ cidr, version: "v6" });

return ipRangeSchema.safeParse(ip).success;
})
).toBe(true);
expect(
invalidIPv6Ranges.every(({ ip, cidr }) => {
const ipRangeSchema = z.string().ipRange({ cidr, version: "v6" });

return ipRangeSchema.safeParse(ip).success === false;
})
);
});

test("CIDR validation", () => {
const ipv4Cidr = z.string().cidr({ version: "v4" });
expect(() => ipv4Cidr.parse("2001:0db8:85a3::8a2e:0370:7334/64")).toThrow();
Expand Down
105 changes: 91 additions & 14 deletions deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,7 @@ export type ZodStringCheck =
}
| { kind: "duration"; message?: string }
| { kind: "ip"; version?: IpVersion; message?: string }
| { kind: "ipRange"; cidr: string; version: IpVersion; message?: string }
| { kind: "cidr"; version?: IpVersion; message?: string }
| { kind: "base64"; message?: string }
| { kind: "base64url"; message?: string };
Expand Down Expand Up @@ -740,6 +741,68 @@ function isValidIP(ip: string, version?: IpVersion) {
return false;
}

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 === "v4" && (prefixLength < 0 || prefixLength > 32)) ||
(version === "v6" && (prefixLength < 0 || prefixLength > 128))
) {
throw new Error("Invalid prefix length");
}

const ipBinary = ipToBinary(ip, version);
const rangeBinary = ipToBinary(rangeIp, version);

return (
ipBinary.substring(0, prefixLength) ===
rangeBinary.substring(0, prefixLength)
);
}

function isValidCidr(ip: string, version?: IpVersion) {
if ((version === "v4" || !version) && ipv4CidrRegex.test(ip)) {
return true;
}
if ((version === "v6" || !version) && ipv6CidrRegex.test(ip)) {
return true;
}

return false;
}

function isValidJWT(jwt: string, alg?: string): boolean {
if (!jwtRegex.test(jwt)) return false;
try {
Expand All @@ -759,17 +822,6 @@ function isValidJWT(jwt: string, alg?: string): boolean {
}
}

function isValidCidr(ip: string, version?: IpVersion) {
if ((version === "v4" || !version) && ipv4CidrRegex.test(ip)) {
return true;
}
if ((version === "v6" || !version) && ipv6CidrRegex.test(ip)) {
return true;
}

return false;
}

Comment on lines -762 to -772
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[FYI] Just moved isValidCidr slightly up.

export class ZodString extends ZodType<string, ZodStringDef, string> {
_parse(input: ParseInput): ParseReturnType<string> {
if (this._def.coerce) {
Expand Down Expand Up @@ -1032,11 +1084,11 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
});
status.dirty();
}
} else if (check.kind === "jwt") {
if (!isValidJWT(input.data, check.alg)) {
} else if (check.kind === "ipRange") {
if (!isValidIPRange(input.data, check.cidr, check.version)) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
validation: "jwt",
validation: "ipRange",
code: ZodIssueCode.invalid_string,
message: check.message,
});
Comment on lines +1087 to 1094
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[FYI] I just rearranged the order so that all ip-related code is grouped together for better readability.
The internal code hasn't been changed 👍

Expand All @@ -1052,6 +1104,16 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
});
status.dirty();
}
} else if (check.kind === "jwt") {
if (!isValidJWT(input.data, check.alg)) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
validation: "jwt",
code: ZodIssueCode.invalid_string,
message: check.message,
});
status.dirty();
}
} else if (check.kind === "base64") {
if (!base64Regex.test(input.data)) {
ctx = this._getOrReturnCtx(input, ctx);
Expand Down Expand Up @@ -1146,6 +1208,18 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
return this._addCheck({ kind: "ip", ...errorUtil.errToObj(options) });
}

ipRange(
args: { cidr: string; version: IpVersion },
options?: string | { message?: string }
) {
return this._addCheck({
kind: "ipRange",
cidr: args.cidr,
version: args.version,
...errorUtil.errToObj(options),
});
}

cidr(options?: string | { version?: IpVersion; message?: string }) {
return this._addCheck({ kind: "cidr", ...errorUtil.errToObj(options) });
}
Expand Down Expand Up @@ -1342,6 +1416,9 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
get isIP() {
return !!this._def.checks.find((ch) => ch.kind === "ip");
}
get isIPRange() {
return !!this._def.checks.find((ch) => ch.kind === "ipRange");
}
get isCIDR() {
return !!this._def.checks.find((ch) => ch.kind === "cidr");
}
Expand Down
1 change: 1 addition & 0 deletions src/ZodError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ export type StringValidation =
| "time"
| "duration"
| "ip"
| "ipRange"
| "cidr"
| "base64"
| "jwt"
Expand Down
Loading
Loading