Skip to content

Commit c10f089

Browse files
committed
feat(api): add URL validation for external links in og and proxy handlers
1 parent a76ebf3 commit c10f089

File tree

2 files changed

+84
-0
lines changed

2 files changed

+84
-0
lines changed

src/pages/api/v1/og.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,43 @@
11
import type { NextApiRequest, NextApiResponse } from "next";
22

3+
function isValidExternalUrl(url: string): boolean {
4+
try {
5+
const parsed = new URL(url);
6+
7+
// Only allow HTTP and HTTPS protocols
8+
if (!['http:', 'https:'].includes(parsed.protocol)) {
9+
return false;
10+
}
11+
12+
// Block private/internal IP ranges
13+
const hostname = parsed.hostname;
14+
15+
// Block localhost and loopback
16+
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
17+
return false;
18+
}
19+
20+
// Block private IP ranges (RFC 1918)
21+
const privateRanges = [
22+
/^10\./, // 10.0.0.0/8
23+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12
24+
/^192\.168\./, // 192.168.0.0/16
25+
/^169\.254\./, // Link-local
26+
/^::1$/, // IPv6 loopback
27+
/^fc00:/, // IPv6 private
28+
/^fe80:/, // IPv6 link-local
29+
];
30+
31+
if (privateRanges.some(range => range.test(hostname))) {
32+
return false;
33+
}
34+
35+
return true;
36+
} catch {
37+
return false;
38+
}
39+
}
40+
341
function extractMeta(html: string, property: string): string | null {
442
const propRegex = new RegExp(`<meta[^>]+property=["']${property}["'][^>]*content=["']([^"']+)["'][^>]*>`, "i");
543
const nameRegex = new RegExp(`<meta[^>]+name=["']${property}["'][^>]*content=["']([^"']+)["'][^>]*>`, "i");
@@ -46,6 +84,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
4684
if (!url) {
4785
return res.status(400).json({ error: "Missing url parameter" });
4886
}
87+
88+
if (!isValidExternalUrl(url)) {
89+
return res.status(400).json({ error: "Invalid or unsafe URL" });
90+
}
4991

5092
try {
5193
const controller = new AbortController();

src/pages/api/v1/proxy.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,52 @@
11
import type { NextApiRequest, NextApiResponse } from "next";
22

3+
function isValidExternalUrl(url: string): boolean {
4+
try {
5+
const parsed = new URL(url);
6+
7+
// Only allow HTTP and HTTPS protocols
8+
if (!['http:', 'https:'].includes(parsed.protocol)) {
9+
return false;
10+
}
11+
12+
// Block private/internal IP ranges
13+
const hostname = parsed.hostname;
14+
15+
// Block localhost and loopback
16+
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
17+
return false;
18+
}
19+
20+
// Block private IP ranges (RFC 1918)
21+
const privateRanges = [
22+
/^10\./, // 10.0.0.0/8
23+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // 172.16.0.0/12
24+
/^192\.168\./, // 192.168.0.0/16
25+
/^169\.254\./, // Link-local
26+
/^::1$/, // IPv6 loopback
27+
/^fc00:/, // IPv6 private
28+
/^fe80:/, // IPv6 link-local
29+
];
30+
31+
if (privateRanges.some(range => range.test(hostname))) {
32+
return false;
33+
}
34+
35+
return true;
36+
} catch {
37+
return false;
38+
}
39+
}
40+
341
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
442
const src = req.query.src as string | undefined;
543
if (!src) {
644
return res.status(400).json({ error: "Missing src parameter" });
745
}
46+
47+
if (!isValidExternalUrl(src)) {
48+
return res.status(400).json({ error: "Invalid or unsafe URL" });
49+
}
850
try {
951
const controller = new AbortController();
1052
const timeout = setTimeout(() => controller.abort(), 10000);

0 commit comments

Comments
 (0)