Skip to content
Open
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
146 changes: 145 additions & 1 deletion client/src/utils/__tests__/urlValidation.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { validateRedirectUrl } from "../urlValidation";
import { validateRedirectUrl, isPrivateUrl } from "../urlValidation";

describe("validateRedirectUrl", () => {
describe("valid URLs", () => {
Expand Down Expand Up @@ -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);
});
});
76 changes: 72 additions & 4 deletions client/src/utils/urlValidation.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,92 @@
/**
* 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;
}
// If URL parsing fails, it's also invalid
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;
}
}