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

Add eslint plugin for detecting redos vulnerabilities and tweak regex ip regex to prevent backtracks #2849

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ module.exports = {
"simple-import-sort",
"unused-imports",
"ban",
"redos-detector",
],
extends: [
"eslint:recommended",
Expand Down Expand Up @@ -66,5 +67,7 @@ module.exports = {
message: "Number.isInteger() is not supported in legacy browsers",
},
],

"redos-detector/no-unsafe-regex": "error",
},
};
70 changes: 42 additions & 28 deletions deno/lib/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,34 +479,48 @@ test("IP validation", () => {

const ipv6 = z.string().ip({ version: "v6" });
expect(() => ipv6.parse("254.164.77.1")).toThrow();
});

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",
"114.71.82.94",
"0.0.0.0",
"37.85.236.115",
];
[
"1e5e:e6c8:daac:514b:114b:e360:d8c0:682c",
"9d4:c956:420f:5788:4339:9b3b:2418:75c3",
"9d4:c956:420f:5788:4339:9b3b:2418:75C3",
Copy link
Author

Choose a reason for hiding this comment

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

previously it only allowed lowercase, but uppercase is still a valid ip

"a6ea::2454:a5ce:94.105.123.75",
"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",
"1e5e:e6c8:daac:514b:114b::e360:682c",
"114.71.82.94",
"0.0.0.0",
"37.85.236.115",
"::",
"::1",
].forEach((ip) => {
test(`IP validation with valid ip: ${ip}`, () => {
// no parameters check IPv4 or IPv6
const ipSchema = z.string().ip();
expect(ipSchema.safeParse(ip).success).toBe(true);
});
});

const invalidIPs = [
"d329:1be4:25b4:db47:a9d1:dc71:4926:992c:14af",
"d5e7:7214:2b78::3906:85e6:53cc:709:32ba",
"8f69::c757:395e:976e::3441",
"54cb::473f:d516:0.255.256.22",
"54cb::473f:d516:192.168.1",
"256.0.4.4",
"-1.0.555.4",
"0.0.0.0.0",
"1.1.1",
];
// no parameters check IPv4 or IPv6
const ipSchema = z.string().ip();
expect(validIPs.every((ip) => ipSchema.safeParse(ip).success)).toBe(true);
expect(
invalidIPs.every((ip) => ipSchema.safeParse(ip).success === false)
).toBe(true);
[
"d329:1be4:25b4:db47:a9d1:dc71:4926:992c:14af",
"d5e7:7214:2b78::3906:85e6:53cc:709:32ba",
"1e5e:e6c8:daac:514b:114b:e360:d8c0::682c",
"1e5e:e6c8:daac:514b:114b::e360::682c",
"8f69::c757:395e:976e::3441",
"54cb::473f:d516:0.255.256.22",
"54cb::473f:d516:192.168.1",
"a6ea::2454:a5ce:94.105.123.75:a",
"256.0.4.4",
"-1.0.555.4",
"0.0.0.0.0",
"1.1.1",
].forEach((ip) => {
test(`IP validation with invalid ip: ${ip}`, () => {
// no parameters check IPv4 or IPv6
const ipSchema = z.string().ip();
expect(ipSchema.safeParse(ip).success).toBe(false);
});
});
83 changes: 65 additions & 18 deletions deno/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,15 +569,21 @@ const emailRegex =
// const emailRegex =
// /^[a-z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-z0-9-]+(?:\.[a-z0-9\-]+)*$/i;

// from https://thekevinscott.com/emojis-in-javascript/#writing-a-regular-expression
const _emojiRegex = `^(\\p{Extended_Pictographic}|\\p{Emoji_Component})+$`;
let emojiRegex: RegExp;
let emojiRegex: RegExp | undefined;

const ipv4Regex =
/^(((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}))$/;
/^(?:(?:(?=(25[0-5]))\1|(?=(2[0-4][0-9]))\2|(?=(1[0-9]{2}))\3|(?=([0-9]{1,2}))\4)\.){3}(?:(?=(25[0-5]))\5|(?=(2[0-4][0-9]))\6|(?=(1[0-9]{2}))\7|(?=([0-9]{1,2}))\8)$/;

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})))$/;
const ipv6PartRegex = /^[a-f0-9]{1,4}$/i;

const dateTimeRegexPrecision0Offset =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(([+-]\d{2}(:?\d{2})?)|Z)$/;
const dateTimeRegexPrecision0NoOffset =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
const dateTimeNoPrecisionOffset =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(([+-]\d{2}(:?\d{2})?)|Z)$/;
const dateTimeNoPrecisionNoOffset =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;

// Adapted from https://stackoverflow.com/a/3143231
const datetimeRegex = (args: { precision: number | null; offset: boolean }) => {
Expand All @@ -593,30 +599,70 @@ const datetimeRegex = (args: { precision: number | null; offset: boolean }) => {
}
} else if (args.precision === 0) {
if (args.offset) {
return new RegExp(
`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(([+-]\\d{2}(:?\\d{2})?)|Z)$`
);
return dateTimeRegexPrecision0Offset;
} else {
return new RegExp(`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$`);
return dateTimeRegexPrecision0NoOffset;
}
} else {
if (args.offset) {
return new RegExp(
`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(([+-]\\d{2}(:?\\d{2})?)|Z)$`
);
return dateTimeNoPrecisionOffset;
} else {
return new RegExp(
`^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?Z$`
);
return dateTimeNoPrecisionNoOffset;
}
}
};

function isValidIpv6(ip: string): boolean {
const parts = ip.split(":");
if (parts.length > 8) {
return false;
}

const ipv4Mapped = ipv4Regex.test(parts[parts.length - 1]);

let remainingParts = 8;
if (ipv4Mapped) {
// IPv4-mapped address takes up last 32 bits
parts.pop();
remainingParts -= 2;
}

if (!ipv4Mapped && parts[parts.length - 1] === "") {
parts.pop();
}

if (parts.length > 0 && parts[0] === "") {
parts.shift();
}

const noneEmptyParts = parts.filter((part) => part !== "");

if (
noneEmptyParts.length === remainingParts &&
parts.length !== noneEmptyParts.length
) {
return false;
}

if (
parts.length < remainingParts &&
parts.length - noneEmptyParts.length !== 1
) {
return false;
}

if (!noneEmptyParts.every((part) => ipv6PartRegex.test(part))) {
return false;
}

return true;
}

function isValidIP(ip: string, version?: IpVersion) {
if ((version === "v4" || !version) && ipv4Regex.test(ip)) {
return true;
}
if ((version === "v6" || !version) && ipv6Regex.test(ip)) {
if ((version === "v6" || !version) && isValidIpv6(ip)) {
return true;
}

Expand Down Expand Up @@ -712,7 +758,8 @@ export class ZodString extends ZodType<string, ZodStringDef> {
}
} else if (check.kind === "emoji") {
if (!emojiRegex) {
emojiRegex = new RegExp(_emojiRegex, "u");
// from https://thekevinscott.com/emojis-in-javascript/#writing-a-regular-expression
emojiRegex = /^[\p{Extended_Pictographic}\p{Emoji_Component}]+$/u;
}
if (!emojiRegex.test(input.data)) {
ctx = this._getOrReturnCtx(input, ctx);
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-ban": "^1.6.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-redos-detector": "^2.2.0",
"eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-unused-imports": "^2.0.0",
"husky": "^7.0.4",
Expand Down
70 changes: 42 additions & 28 deletions src/__tests__/string.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,34 +478,48 @@ test("IP validation", () => {

const ipv6 = z.string().ip({ version: "v6" });
expect(() => ipv6.parse("254.164.77.1")).toThrow();
});

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",
"114.71.82.94",
"0.0.0.0",
"37.85.236.115",
];
[
"1e5e:e6c8:daac:514b:114b:e360:d8c0:682c",
"9d4:c956:420f:5788:4339:9b3b:2418:75c3",
"9d4:c956:420f:5788:4339:9b3b:2418:75C3",
"a6ea::2454:a5ce:94.105.123.75",
"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",
"1e5e:e6c8:daac:514b:114b::e360:682c",
"114.71.82.94",
"0.0.0.0",
"37.85.236.115",
"::",
"::1",
].forEach((ip) => {
test(`IP validation with valid ip: ${ip}`, () => {
// no parameters check IPv4 or IPv6
const ipSchema = z.string().ip();
expect(ipSchema.safeParse(ip).success).toBe(true);
});
});

const invalidIPs = [
"d329:1be4:25b4:db47:a9d1:dc71:4926:992c:14af",
"d5e7:7214:2b78::3906:85e6:53cc:709:32ba",
"8f69::c757:395e:976e::3441",
"54cb::473f:d516:0.255.256.22",
"54cb::473f:d516:192.168.1",
"256.0.4.4",
"-1.0.555.4",
"0.0.0.0.0",
"1.1.1",
];
// no parameters check IPv4 or IPv6
const ipSchema = z.string().ip();
expect(validIPs.every((ip) => ipSchema.safeParse(ip).success)).toBe(true);
expect(
invalidIPs.every((ip) => ipSchema.safeParse(ip).success === false)
).toBe(true);
[
"d329:1be4:25b4:db47:a9d1:dc71:4926:992c:14af",
"d5e7:7214:2b78::3906:85e6:53cc:709:32ba",
"1e5e:e6c8:daac:514b:114b:e360:d8c0::682c",
"1e5e:e6c8:daac:514b:114b::e360::682c",
"8f69::c757:395e:976e::3441",
"54cb::473f:d516:0.255.256.22",
"54cb::473f:d516:192.168.1",
"a6ea::2454:a5ce:94.105.123.75:a",
"256.0.4.4",
"-1.0.555.4",
"0.0.0.0.0",
"1.1.1",
].forEach((ip) => {
test(`IP validation with invalid ip: ${ip}`, () => {
// no parameters check IPv4 or IPv6
const ipSchema = z.string().ip();
expect(ipSchema.safeParse(ip).success).toBe(false);
});
});
Loading