Skip to content

Commit b0bea2b

Browse files
committed
fix(security): add SSRF protection to URL validation
- Add isPrivateIP() function to detect private/internal IPs - Block requests to localhost, private ranges, link-local addresses - Validate URLs before making HTTP requests - Prevent SSRF attacks through URL parameters 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent fe393e5 commit b0bea2b

File tree

2 files changed

+217
-5
lines changed

2 files changed

+217
-5
lines changed

client/src/utils/__tests__/urlValidation.test.ts

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { validateRedirectUrl } from "../urlValidation";
1+
import { validateRedirectUrl, isPrivateUrl } from "../urlValidation";
22

33
describe("validateRedirectUrl", () => {
44
describe("valid URLs", () => {
@@ -132,4 +132,148 @@ describe("validateRedirectUrl", () => {
132132
);
133133
});
134134
});
135+
136+
describe("SSRF protection", () => {
137+
it("should block localhost", () => {
138+
expect(() => validateRedirectUrl("http://localhost/callback")).toThrow(
139+
"private/internal address",
140+
);
141+
});
142+
143+
it("should block localhost with port", () => {
144+
expect(() =>
145+
validateRedirectUrl("http://localhost:3000/callback"),
146+
).toThrow("private/internal address");
147+
});
148+
149+
it("should block 127.0.0.1", () => {
150+
expect(() => validateRedirectUrl("http://127.0.0.1/callback")).toThrow(
151+
"private/internal address",
152+
);
153+
});
154+
155+
it("should block 127.x.x.x range", () => {
156+
expect(() => validateRedirectUrl("http://127.0.0.2:8080/")).toThrow(
157+
"private/internal address",
158+
);
159+
});
160+
161+
it("should block private 10.x.x.x range", () => {
162+
expect(() => validateRedirectUrl("http://10.0.0.1/callback")).toThrow(
163+
"private/internal address",
164+
);
165+
expect(() => validateRedirectUrl("http://10.255.255.255/")).toThrow(
166+
"private/internal address",
167+
);
168+
});
169+
170+
it("should block private 172.16-31.x.x range", () => {
171+
expect(() => validateRedirectUrl("http://172.16.0.1/callback")).toThrow(
172+
"private/internal address",
173+
);
174+
expect(() => validateRedirectUrl("http://172.31.255.255/")).toThrow(
175+
"private/internal address",
176+
);
177+
});
178+
179+
it("should allow non-private 172.x.x.x", () => {
180+
// 172.15.x.x and 172.32.x.x are public
181+
expect(() =>
182+
validateRedirectUrl("http://172.15.0.1/callback"),
183+
).not.toThrow();
184+
expect(() =>
185+
validateRedirectUrl("http://172.32.0.1/callback"),
186+
).not.toThrow();
187+
});
188+
189+
it("should block private 192.168.x.x range", () => {
190+
expect(() => validateRedirectUrl("http://192.168.0.1/callback")).toThrow(
191+
"private/internal address",
192+
);
193+
expect(() => validateRedirectUrl("http://192.168.255.255/")).toThrow(
194+
"private/internal address",
195+
);
196+
});
197+
198+
it("should block link-local 169.254.x.x", () => {
199+
expect(() => validateRedirectUrl("http://169.254.1.1/callback")).toThrow(
200+
"private/internal address",
201+
);
202+
});
203+
204+
it("should block AWS/GCP metadata endpoint 169.254.169.254", () => {
205+
expect(() =>
206+
validateRedirectUrl("http://169.254.169.254/latest/meta-data/"),
207+
).toThrow("private/internal address");
208+
});
209+
210+
it("should block IPv6 localhost [::1]", () => {
211+
expect(() => validateRedirectUrl("http://[::1]/callback")).toThrow(
212+
"private/internal address",
213+
);
214+
});
215+
216+
it("should block IPv6 link-local [fe80::]", () => {
217+
expect(() => validateRedirectUrl("http://[fe80::1]/callback")).toThrow(
218+
"private/internal address",
219+
);
220+
});
221+
222+
it("should allow private IPs with allowPrivateIPs option", () => {
223+
expect(() =>
224+
validateRedirectUrl("http://localhost/callback", {
225+
allowPrivateIPs: true,
226+
}),
227+
).not.toThrow();
228+
expect(() =>
229+
validateRedirectUrl("http://127.0.0.1/callback", {
230+
allowPrivateIPs: true,
231+
}),
232+
).not.toThrow();
233+
expect(() =>
234+
validateRedirectUrl("http://192.168.1.1/callback", {
235+
allowPrivateIPs: true,
236+
}),
237+
).not.toThrow();
238+
});
239+
240+
it("should allow public IPs", () => {
241+
expect(() =>
242+
validateRedirectUrl("https://api.example.com/callback"),
243+
).not.toThrow();
244+
expect(() =>
245+
validateRedirectUrl("https://8.8.8.8/callback"),
246+
).not.toThrow();
247+
expect(() =>
248+
validateRedirectUrl("https://1.1.1.1/callback"),
249+
).not.toThrow();
250+
});
251+
});
252+
});
253+
254+
describe("isPrivateUrl", () => {
255+
it("should return true for localhost", () => {
256+
expect(isPrivateUrl("http://localhost/")).toBe(true);
257+
});
258+
259+
it("should return true for private IPs", () => {
260+
expect(isPrivateUrl("http://127.0.0.1/")).toBe(true);
261+
expect(isPrivateUrl("http://10.0.0.1/")).toBe(true);
262+
expect(isPrivateUrl("http://192.168.1.1/")).toBe(true);
263+
expect(isPrivateUrl("http://172.16.0.1/")).toBe(true);
264+
});
265+
266+
it("should return true for metadata endpoints", () => {
267+
expect(isPrivateUrl("http://169.254.169.254/")).toBe(true);
268+
});
269+
270+
it("should return false for public IPs", () => {
271+
expect(isPrivateUrl("https://example.com/")).toBe(false);
272+
expect(isPrivateUrl("https://8.8.8.8/")).toBe(false);
273+
expect(isPrivateUrl("https://api.github.com/")).toBe(false);
274+
});
275+
276+
it("should return false for invalid URLs", () => {
277+
expect(isPrivateUrl("not a url")).toBe(false);
278+
});
135279
});

client/src/utils/urlValidation.ts

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,92 @@
1+
/**
2+
* Check if a hostname is a private/internal IP address
3+
* Used to prevent SSRF attacks by blocking requests to internal networks
4+
*/
5+
function isPrivateHostname(hostname: string): boolean {
6+
const normalizedHostname = hostname.toLowerCase();
7+
8+
// Private IP patterns
9+
const privatePatterns = [
10+
// Localhost variants
11+
/^localhost$/,
12+
/^localhost\./,
13+
14+
// IPv4 private ranges
15+
/^127\./, // 127.0.0.0/8 - loopback
16+
/^10\./, // 10.0.0.0/8 - private
17+
/^172\.(1[6-9]|2[0-9]|3[01])\./, // 172.16.0.0/12 - private
18+
/^192\.168\./, // 192.168.0.0/16 - private
19+
/^169\.254\./, // 169.254.0.0/16 - link-local
20+
/^0\./, // 0.0.0.0/8 - current network
21+
22+
// IPv6 private ranges (enclosed in brackets for URL hostname)
23+
/^\[::1\]$/, // ::1 - loopback
24+
/^\[::ffff:127\./, // IPv4-mapped loopback
25+
/^\[fe80:/i, // fe80::/10 - link-local
26+
/^\[fc/i, // fc00::/7 - unique local
27+
/^\[fd/i, // fd00::/8 - unique local
28+
29+
// Cloud metadata endpoints (common SSRF targets)
30+
/^169\.254\.169\.254$/, // AWS/GCP metadata
31+
/^metadata\./, // metadata.google.internal
32+
];
33+
34+
return privatePatterns.some((pattern) => pattern.test(normalizedHostname));
35+
}
36+
137
/**
238
* Validates that a URL is safe for redirection.
3-
* Only allows HTTP and HTTPS protocols to prevent XSS attacks.
39+
* - Only allows HTTP and HTTPS protocols to prevent XSS attacks
40+
* - Blocks private/internal IPs to prevent SSRF attacks
441
*
542
* @param url - The URL string to validate
6-
* @throws Error if the URL has an unsafe protocol
43+
* @param options - Validation options
44+
* @param options.allowPrivateIPs - If true, allows private IPs (default: false)
45+
* @throws Error if the URL has an unsafe protocol or points to private networks
746
*/
8-
export function validateRedirectUrl(url: string | URL): void {
47+
export function validateRedirectUrl(
48+
url: string | URL,
49+
options: { allowPrivateIPs?: boolean } = {},
50+
): void {
951
try {
1052
const parsedUrl = new URL(url);
53+
54+
// Check protocol
1155
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
1256
throw new Error("Authorization URL must be HTTP or HTTPS");
1357
}
58+
59+
// Check for private/internal IPs (SSRF protection)
60+
if (!options.allowPrivateIPs && isPrivateHostname(parsedUrl.hostname)) {
61+
throw new Error(
62+
`Authorization URL cannot point to private/internal address: ${parsedUrl.hostname}`,
63+
);
64+
}
1465
} catch (error) {
1566
if (
1667
error instanceof Error &&
17-
error.message === "Authorization URL must be HTTP or HTTPS"
68+
(error.message.startsWith("Authorization URL") ||
69+
error.message.startsWith("Authorization URL cannot"))
1870
) {
1971
throw error;
2072
}
2173
// If URL parsing fails, it's also invalid
2274
throw new Error(`Invalid URL: ${url}`);
2375
}
2476
}
77+
78+
/**
79+
* Check if a URL points to a private/internal network
80+
* Useful for warning users without blocking the request
81+
*
82+
* @param url - The URL to check
83+
* @returns true if the URL points to a private network
84+
*/
85+
export function isPrivateUrl(url: string | URL): boolean {
86+
try {
87+
const parsedUrl = new URL(url);
88+
return isPrivateHostname(parsedUrl.hostname);
89+
} catch {
90+
return false;
91+
}
92+
}

0 commit comments

Comments
 (0)