diff --git a/backend/src/lib/ip/index.test.ts b/backend/src/lib/ip/index.test.ts new file mode 100644 index 000000000..5f3b64fac --- /dev/null +++ b/backend/src/lib/ip/index.test.ts @@ -0,0 +1,121 @@ +import { extractIPDetails, IPType, isValidCidr, isValidIp, isValidIpOrCidr } from "./index"; + +describe("IP Validation", () => { + describe("isValidIp", () => { + test("should validate IPv4 addresses with ports", () => { + expect(isValidIp("192.168.1.1:8080")).toBe(true); + expect(isValidIp("10.0.0.1:1234")).toBe(true); + expect(isValidIp("172.16.0.1:80")).toBe(true); + }); + + test("should validate IPv6 addresses with ports", () => { + expect(isValidIp("[2001:db8::1]:8080")).toBe(true); + expect(isValidIp("[fe80::1ff:fe23:4567:890a]:1234")).toBe(true); + expect(isValidIp("[::1]:80")).toBe(true); + }); + + test("should validate regular IPv4 addresses", () => { + expect(isValidIp("192.168.1.1")).toBe(true); + expect(isValidIp("10.0.0.1")).toBe(true); + expect(isValidIp("172.16.0.1")).toBe(true); + }); + + test("should validate regular IPv6 addresses", () => { + expect(isValidIp("2001:db8::1")).toBe(true); + expect(isValidIp("fe80::1ff:fe23:4567:890a")).toBe(true); + expect(isValidIp("::1")).toBe(true); + }); + + test("should reject invalid IP addresses", () => { + expect(isValidIp("256.256.256.256")).toBe(false); + expect(isValidIp("192.168.1")).toBe(false); + expect(isValidIp("192.168.1.1.1")).toBe(false); + expect(isValidIp("2001:db8::1::1")).toBe(false); + expect(isValidIp("invalid")).toBe(false); + }); + + test("should reject malformed IP addresses with ports", () => { + expect(isValidIp("192.168.1.1:")).toBe(false); + expect(isValidIp("192.168.1.1:abc")).toBe(false); + expect(isValidIp("[2001:db8::1]")).toBe(false); + expect(isValidIp("[2001:db8::1]:")).toBe(false); + expect(isValidIp("[2001:db8::1]:abc")).toBe(false); + }); + }); + + describe("isValidCidr", () => { + test("should validate IPv4 CIDR blocks", () => { + expect(isValidCidr("192.168.1.0/24")).toBe(true); + expect(isValidCidr("10.0.0.0/8")).toBe(true); + expect(isValidCidr("172.16.0.0/16")).toBe(true); + }); + + test("should validate IPv6 CIDR blocks", () => { + expect(isValidCidr("2001:db8::/32")).toBe(true); + expect(isValidCidr("fe80::/10")).toBe(true); + expect(isValidCidr("::/0")).toBe(true); + }); + + test("should reject invalid CIDR blocks", () => { + expect(isValidCidr("192.168.1.0/33")).toBe(false); + expect(isValidCidr("2001:db8::/129")).toBe(false); + expect(isValidCidr("192.168.1.0/abc")).toBe(false); + expect(isValidCidr("invalid/24")).toBe(false); + }); + }); + + describe("isValidIpOrCidr", () => { + test("should validate both IP addresses and CIDR blocks", () => { + expect(isValidIpOrCidr("192.168.1.1")).toBe(true); + expect(isValidIpOrCidr("2001:db8::1")).toBe(true); + expect(isValidIpOrCidr("192.168.1.0/24")).toBe(true); + expect(isValidIpOrCidr("2001:db8::/32")).toBe(true); + }); + + test("should reject invalid inputs", () => { + expect(isValidIpOrCidr("invalid")).toBe(false); + expect(isValidIpOrCidr("192.168.1.0/33")).toBe(false); + expect(isValidIpOrCidr("2001:db8::/129")).toBe(false); + }); + }); + + describe("extractIPDetails", () => { + test("should extract IPv4 address details", () => { + const result = extractIPDetails("192.168.1.1"); + expect(result).toEqual({ + ipAddress: "192.168.1.1", + type: IPType.IPV4 + }); + }); + + test("should extract IPv6 address details", () => { + const result = extractIPDetails("2001:db8::1"); + expect(result).toEqual({ + ipAddress: "2001:db8::1", + type: IPType.IPV6 + }); + }); + + test("should extract IPv4 CIDR details", () => { + const result = extractIPDetails("192.168.1.0/24"); + expect(result).toEqual({ + ipAddress: "192.168.1.0", + type: IPType.IPV4, + prefix: 24 + }); + }); + + test("should extract IPv6 CIDR details", () => { + const result = extractIPDetails("2001:db8::/32"); + expect(result).toEqual({ + ipAddress: "2001:db8::", + type: IPType.IPV6, + prefix: 32 + }); + }); + + test("should throw error for invalid IP", () => { + expect(() => extractIPDetails("invalid")).toThrow("Failed to extract IP details"); + }); + }); +}); diff --git a/backend/src/lib/ip/index.ts b/backend/src/lib/ip/index.ts index 0b35a2759..9b594a583 100644 --- a/backend/src/lib/ip/index.ts +++ b/backend/src/lib/ip/index.ts @@ -1,5 +1,7 @@ import net from "node:net"; +import RE2 from "re2"; + import { ForbiddenRequestError } from "../errors"; export enum IPType { @@ -7,25 +9,55 @@ export enum IPType { IPV6 = "ipv6" } +const PORT_REGEX = new RE2(/^\d+$/); + +/** + * Strips port from IP address if present. + * Handles both IPv4 (e.g. 1.2.3.4:1234) and IPv6 (e.g. [2001:db8::1]:8080) formats. + * Returns the IP address without port and a boolean indicating if a port was present. + */ +const stripPort = (ip: string): { ipAddress: string } => { + // Handle IPv6 with port (e.g. [2001:db8::1]:8080) + if (ip.startsWith("[") && ip.includes("]:")) { + const endBracketIndex = ip.indexOf("]"); + if (endBracketIndex === -1) return { ipAddress: ip }; + const ipPart = ip.slice(1, endBracketIndex); + const portPart = ip.slice(endBracketIndex + 2); + if (!portPart || !PORT_REGEX.test(portPart)) return { ipAddress: ip }; + return { ipAddress: ipPart }; + } + + // Handle IPv4 with port (e.g. 1.2.3.4:1234) + if (ip.includes(":")) { + const [ipPart, portPart] = ip.split(":"); + if (!portPart || !PORT_REGEX.test(portPart)) return { ipAddress: ip }; + return { ipAddress: ipPart }; + } + + return { ipAddress: ip }; +}; + /** * Return details of IP [ip]: * - If [ip] is a specific IP address then return the IPv4/IPv6 address * - If [ip] is a subnet then return the network IPv4/IPv6 address and prefix */ export const extractIPDetails = (ip: string) => { - if (net.isIPv4(ip)) + const { ipAddress } = stripPort(ip); + + if (net.isIPv4(ipAddress)) return { - ipAddress: ip, + ipAddress, type: IPType.IPV4 }; - if (net.isIPv6(ip)) + if (net.isIPv6(ipAddress)) return { - ipAddress: ip, + ipAddress, type: IPType.IPV6 }; - const [ipNet, prefix] = ip.split("/"); + const [ipNet, prefix] = ipAddress.split("/"); let type; switch (net.isIP(ipNet)) { @@ -57,7 +89,8 @@ export const extractIPDetails = (ip: string) => { * */ export const isValidCidr = (cidr: string): boolean => { - const [ip, prefix] = cidr.split("/"); + const { ipAddress } = stripPort(cidr); + const [ip, prefix] = ipAddress.split("/"); const prefixNum = parseInt(prefix, 10); @@ -90,13 +123,15 @@ export const isValidCidr = (cidr: string): boolean => { * */ export const isValidIpOrCidr = (ip: string): boolean => { + const { ipAddress } = stripPort(ip); + // if the string contains a slash, treat it as a CIDR block - if (ip.includes("/")) { - return isValidCidr(ip); + if (ipAddress.includes("/")) { + return isValidCidr(ipAddress); } // otherwise, treat it as a standalone IP address - if (net.isIPv4(ip) || net.isIPv6(ip)) { + if (net.isIPv4(ipAddress) || net.isIPv6(ipAddress)) { return true; } @@ -104,7 +139,8 @@ export const isValidIpOrCidr = (ip: string): boolean => { }; export const isValidIp = (ip: string) => { - return net.isIPv4(ip) || net.isIPv6(ip); + const { ipAddress } = stripPort(ip); + return net.isIPv4(ipAddress) || net.isIPv6(ipAddress); }; export type TIp = { @@ -112,6 +148,7 @@ export type TIp = { type: IPType; prefix: number; }; + /** * Validates the IP address [ipAddress] against the trusted IPs [trustedIps]. */ @@ -126,8 +163,9 @@ export const checkIPAgainstBlocklist = ({ ipAddress, trustedIps }: { ipAddress: } } - const { type } = extractIPDetails(ipAddress); - const check = blockList.check(ipAddress, type); + const { type, ipAddress: cleanIpAddress } = extractIPDetails(ipAddress); + + const check = blockList.check(cleanIpAddress, type); if (!check) throw new ForbiddenRequestError({