diff --git a/client/src/utils/__tests__/urlValidation.test.ts b/client/src/utils/__tests__/urlValidation.test.ts index 9ee6a4545..84fb2c142 100644 --- a/client/src/utils/__tests__/urlValidation.test.ts +++ b/client/src/utils/__tests__/urlValidation.test.ts @@ -1,4 +1,4 @@ -import { validateRedirectUrl } from "../urlValidation"; +import { validateRedirectUrl, isPrivateUrl } from "../urlValidation"; describe("validateRedirectUrl", () => { describe("valid URLs", () => { @@ -132,4 +132,148 @@ describe("validateRedirectUrl", () => { ); }); }); + + describe("SSRF protection", () => { + it("should block localhost", () => { + expect(() => validateRedirectUrl("http://localhost/callback")).toThrow( + "private/internal address", + ); + }); + + it("should block localhost with port", () => { + expect(() => + validateRedirectUrl("http://localhost:3000/callback"), + ).toThrow("private/internal address"); + }); + + it("should block 127.0.0.1", () => { + expect(() => validateRedirectUrl("http://127.0.0.1/callback")).toThrow( + "private/internal address", + ); + }); + + it("should block 127.x.x.x range", () => { + expect(() => validateRedirectUrl("http://127.0.0.2:8080/")).toThrow( + "private/internal address", + ); + }); + + it("should block private 10.x.x.x range", () => { + expect(() => validateRedirectUrl("http://10.0.0.1/callback")).toThrow( + "private/internal address", + ); + expect(() => validateRedirectUrl("http://10.255.255.255/")).toThrow( + "private/internal address", + ); + }); + + it("should block private 172.16-31.x.x range", () => { + expect(() => validateRedirectUrl("http://172.16.0.1/callback")).toThrow( + "private/internal address", + ); + expect(() => validateRedirectUrl("http://172.31.255.255/")).toThrow( + "private/internal address", + ); + }); + + it("should allow non-private 172.x.x.x", () => { + // 172.15.x.x and 172.32.x.x are public + expect(() => + validateRedirectUrl("http://172.15.0.1/callback"), + ).not.toThrow(); + expect(() => + validateRedirectUrl("http://172.32.0.1/callback"), + ).not.toThrow(); + }); + + it("should block private 192.168.x.x range", () => { + expect(() => validateRedirectUrl("http://192.168.0.1/callback")).toThrow( + "private/internal address", + ); + expect(() => validateRedirectUrl("http://192.168.255.255/")).toThrow( + "private/internal address", + ); + }); + + it("should block link-local 169.254.x.x", () => { + expect(() => validateRedirectUrl("http://169.254.1.1/callback")).toThrow( + "private/internal address", + ); + }); + + it("should block AWS/GCP metadata endpoint 169.254.169.254", () => { + expect(() => + validateRedirectUrl("http://169.254.169.254/latest/meta-data/"), + ).toThrow("private/internal address"); + }); + + it("should block IPv6 localhost [::1]", () => { + expect(() => validateRedirectUrl("http://[::1]/callback")).toThrow( + "private/internal address", + ); + }); + + it("should block IPv6 link-local [fe80::]", () => { + expect(() => validateRedirectUrl("http://[fe80::1]/callback")).toThrow( + "private/internal address", + ); + }); + + it("should allow private IPs with allowPrivateIPs option", () => { + expect(() => + validateRedirectUrl("http://localhost/callback", { + allowPrivateIPs: true, + }), + ).not.toThrow(); + expect(() => + validateRedirectUrl("http://127.0.0.1/callback", { + allowPrivateIPs: true, + }), + ).not.toThrow(); + expect(() => + validateRedirectUrl("http://192.168.1.1/callback", { + allowPrivateIPs: true, + }), + ).not.toThrow(); + }); + + it("should allow public IPs", () => { + expect(() => + validateRedirectUrl("https://api.example.com/callback"), + ).not.toThrow(); + expect(() => + validateRedirectUrl("https://8.8.8.8/callback"), + ).not.toThrow(); + expect(() => + validateRedirectUrl("https://1.1.1.1/callback"), + ).not.toThrow(); + }); + }); +}); + +describe("isPrivateUrl", () => { + it("should return true for localhost", () => { + expect(isPrivateUrl("http://localhost/")).toBe(true); + }); + + it("should return true for private IPs", () => { + expect(isPrivateUrl("http://127.0.0.1/")).toBe(true); + expect(isPrivateUrl("http://10.0.0.1/")).toBe(true); + expect(isPrivateUrl("http://192.168.1.1/")).toBe(true); + expect(isPrivateUrl("http://172.16.0.1/")).toBe(true); + }); + + it("should return true for metadata endpoints", () => { + expect(isPrivateUrl("http://169.254.169.254/")).toBe(true); + }); + + it("should return false for public IPs", () => { + expect(isPrivateUrl("https://example.com/")).toBe(false); + expect(isPrivateUrl("https://8.8.8.8/")).toBe(false); + expect(isPrivateUrl("https://api.github.com/")).toBe(false); + }); + + it("should return false for invalid URLs", () => { + expect(isPrivateUrl("not a url")).toBe(false); + }); }); diff --git a/client/src/utils/urlValidation.ts b/client/src/utils/urlValidation.ts index f6811e043..268221903 100644 --- a/client/src/utils/urlValidation.ts +++ b/client/src/utils/urlValidation.ts @@ -1,20 +1,72 @@ +/** + * Check if a hostname is a private/internal IP address + * Used to prevent SSRF attacks by blocking requests to internal networks + */ +function isPrivateHostname(hostname: string): boolean { + const normalizedHostname = hostname.toLowerCase(); + + // Private IP patterns + const privatePatterns = [ + // Localhost variants + /^localhost$/, + /^localhost\./, + + // IPv4 private ranges + /^127\./, // 127.0.0.0/8 - loopback + /^10\./, // 10.0.0.0/8 - private + /^172\.(1[6-9]|2[0-9]|3[01])\./, // 172.16.0.0/12 - private + /^192\.168\./, // 192.168.0.0/16 - private + /^169\.254\./, // 169.254.0.0/16 - link-local + /^0\./, // 0.0.0.0/8 - current network + + // IPv6 private ranges (enclosed in brackets for URL hostname) + /^\[::1\]$/, // ::1 - loopback + /^\[::ffff:127\./, // IPv4-mapped loopback + /^\[fe80:/i, // fe80::/10 - link-local + /^\[fc/i, // fc00::/7 - unique local + /^\[fd/i, // fd00::/8 - unique local + + // Cloud metadata endpoints (common SSRF targets) + /^169\.254\.169\.254$/, // AWS/GCP metadata + /^metadata\./, // metadata.google.internal + ]; + + return privatePatterns.some((pattern) => pattern.test(normalizedHostname)); +} + /** * Validates that a URL is safe for redirection. - * Only allows HTTP and HTTPS protocols to prevent XSS attacks. + * - Only allows HTTP and HTTPS protocols to prevent XSS attacks + * - Blocks private/internal IPs to prevent SSRF attacks * * @param url - The URL string to validate - * @throws Error if the URL has an unsafe protocol + * @param options - Validation options + * @param options.allowPrivateIPs - If true, allows private IPs (default: false) + * @throws Error if the URL has an unsafe protocol or points to private networks */ -export function validateRedirectUrl(url: string | URL): void { +export function validateRedirectUrl( + url: string | URL, + options: { allowPrivateIPs?: boolean } = {}, +): void { try { const parsedUrl = new URL(url); + + // Check protocol if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { throw new Error("Authorization URL must be HTTP or HTTPS"); } + + // Check for private/internal IPs (SSRF protection) + if (!options.allowPrivateIPs && isPrivateHostname(parsedUrl.hostname)) { + throw new Error( + `Authorization URL cannot point to private/internal address: ${parsedUrl.hostname}`, + ); + } } catch (error) { if ( error instanceof Error && - error.message === "Authorization URL must be HTTP or HTTPS" + (error.message.startsWith("Authorization URL") || + error.message.startsWith("Authorization URL cannot")) ) { throw error; } @@ -22,3 +74,19 @@ export function validateRedirectUrl(url: string | URL): void { throw new Error(`Invalid URL: ${url}`); } } + +/** + * Check if a URL points to a private/internal network + * Useful for warning users without blocking the request + * + * @param url - The URL to check + * @returns true if the URL points to a private network + */ +export function isPrivateUrl(url: string | URL): boolean { + try { + const parsedUrl = new URL(url); + return isPrivateHostname(parsedUrl.hostname); + } catch { + return false; + } +}