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 @@ -76,6 +76,7 @@
- [Dates](#dates)
- [Times](#times)
- [IP addresses](#ip-addresses)
- [IP addresses range](#ip-addresses-range)
- [Numbers](#numbers)
- [BigInts](#bigints)
- [NaNs](#nans)
Expand Down Expand Up @@ -915,6 +916,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
```

## Numbers

You can customize certain error messages when creating a number schema.
Expand Down
22 changes: 22 additions & 0 deletions deno/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
- [Dates](#dates)
- [Times](#times)
- [IP addresses](#ip-addresses)
- [IP addresses range](#ip-addresses-range)
- [Numbers](#numbers)
- [BigInts](#bigints)
- [NaNs](#nans)
Expand Down Expand Up @@ -491,6 +492,7 @@ There are a growing number of tools that are built atop or support Zod natively!
- [`sveltekit-superforms`](https://github.com/ciscoheat/sveltekit-superforms): Supercharged form library for SvelteKit with Zod validation.
- [`mobx-zod-form`](https://github.com/MonoidDev/mobx-zod-form): Data-first form builder based on MobX & Zod.
- [`@vee-validate/zod`](https://github.com/logaretm/vee-validate/tree/main/packages/zod): Form library for Vue.js with Zod schema validation.
- [`zod-form-renderer`](https://github.com/thepeaklab/zod-form-renderer): Auto-infer form fields from zod schema and render them with react-hook-form with E2E type safety.

#### Zod to X

Expand All @@ -505,6 +507,7 @@ There are a growing number of tools that are built atop or support Zod natively!
- [`zod-openapi`](https://github.com/samchungy/zod-openapi): Create full OpenAPI v3.x documentation from Zod schemas.
- [`fastify-zod-openapi`](https://github.com/samchungy/fastify-zod-openapi): Fastify type provider, validation, serialization and @fastify/swagger support for Zod schemas.
- [`typeschema`](https://typeschema.com/): Universal adapter for schema validation.
- [`zodex`](https://github.com/commonbaseapp/zodex): (De)serialization for zod schemas

#### X to Zod

Expand Down Expand Up @@ -538,11 +541,13 @@ There are a growing number of tools that are built atop or support Zod natively!

- [`freerstore`](https://github.com/JacobWeisenburger/freerstore): Firestore cost optimizer.
- [`slonik`](https://github.com/gajus/slonik/tree/gajus/add-zod-validation-backwards-compatible#runtime-validation-and-static-type-inference): Node.js Postgres client with strong Zod integration.
- [`schemql`](https://github.com/a2lix/schemql): Enhances your SQL workflow by combining raw SQL with targeted type safety and schema validation.
- [`soly`](https://github.com/mdbetancourt/soly): Create CLI applications with zod.
- [`pastel`](https://github.com/vadimdemedes/pastel): Create CLI applications with react, zod, and ink.
- [`zod-xlsx`](https://github.com/sidwebworks/zod-xlsx): A xlsx based resource validator using Zod schemas.
- [`znv`](https://github.com/lostfictions/znv): Type-safe environment parsing and validation for Node.js with Zod schemas.
- [`zod-config`](https://github.com/alexmarqs/zod-config): Load configurations across multiple sources with flexible adapters, ensuring type safety with Zod.
- [`unplugin-environment`](https://github.com/r17x/js/tree/main/packages/unplugin-environment#readme): A plugin for loading enviroment variables safely with schema validation, simple with virtual module, type-safe with intellisense, and better DX 🔥 🚀 👷. Powered by Zod.

#### Utilities for Zod

Expand Down Expand Up @@ -911,6 +916,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
```

## Numbers

You can customize certain error messages when creating a number schema.
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"
| "base64"
| { includes: string; position?: number }
| { startsWith: string }
Expand Down
91 changes: 90 additions & 1 deletion deno/lib/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,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().isULID).toEqual(false);

expect(z.string().url().isEmail).toEqual(false);
Expand All @@ -382,6 +383,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().isULID).toEqual(false);

expect(z.string().cuid().isEmail).toEqual(false);
Expand All @@ -391,6 +393,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().isULID).toEqual(false);

expect(z.string().cuid2().isEmail).toEqual(false);
Expand All @@ -400,6 +403,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().isULID).toEqual(false);

expect(z.string().uuid().isEmail).toEqual(false);
Expand All @@ -409,6 +413,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().isULID).toEqual(false);

expect(z.string().nanoid().isEmail).toEqual(false);
Expand All @@ -418,6 +423,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().isULID).toEqual(false);

expect(z.string().ip().isEmail).toEqual(false);
Expand All @@ -427,6 +433,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().isULID).toEqual(false);

expect(z.string().ulid().isEmail).toEqual(false);
Expand All @@ -436,6 +443,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().isULID).toEqual(true);
});

Expand Down Expand Up @@ -740,7 +748,6 @@ test("IP validation", () => {
const validIPs = [
"1e5e:e6c8:daac:514b:114b:e360:d8c0:682c",
"9d4:c956:420f:5788:4339:9b3b:2418:75c3",
"a6ea::2454:a5ce:94.105.123.75",
"474f:4c83::4e40:a47:ff95:0cda",
"d329:0:25b4:db47:a9d1:0:4926:0000",
"e48:10fb:1499:3e28:e4b6:dea5:4692:912c",
Expand Down Expand Up @@ -770,3 +777,85 @@ test("IP validation", () => {
invalidIPs.every((ip) => ipSchema.safeParse(ip).success === false)
).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;
})
).toBe(true);
});
77 changes: 77 additions & 0 deletions deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,7 @@ export type ZodStringCheck =
}
| { kind: "duration"; message?: string }
| { kind: "ip"; version?: IpVersion; message?: string }
| { kind: "ipRange"; cidr: string; version: IpVersion; message?: string }
| { kind: "base64"; message?: string };

export interface ZodStringDef extends ZodTypeDef {
Expand Down Expand Up @@ -669,6 +670,57 @@ 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)
);
}

export class ZodString extends ZodType<string, ZodStringDef, string> {
_parse(input: ParseInput): ParseReturnType<string> {
if (this._def.coerce) {
Expand Down Expand Up @@ -931,6 +983,16 @@ export class ZodString extends ZodType<string, ZodStringDef, string> {
});
status.dirty();
}
} else if (check.kind === "ipRange") {
if (!isValidIPRange(input.data, check.cidr, check.version)) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
validation: "ipRange",
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 @@ -1004,6 +1066,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),
});
}

datetime(
options?:
| string
Expand Down Expand Up @@ -1197,6 +1271,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 isBase64() {
return !!this._def.checks.find((ch) => ch.kind === "base64");
}
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"
| "base64"
| { includes: string; position?: number }
| { startsWith: string }
Expand Down
Loading