Skip to content

Commit a1353c3

Browse files
fix(api): restrict /api/fetch to the hosts we actually fetch from
`/api/fetch` passed its request body straight to `fetch`, so the dev server would request whatever it was pointed at. It is dev-only — the deployed site is a static export with no API routes, and the one caller guards on `location.hostname === 'localhost'` — so this is hardening a local tool rather than closing a hole on the live site. Three layers, in `src/lib/server/safe-fetch.ts`: - A host allowlist. `image-util.ts` already knows the only three hosts we fetch HTML from; `src/lib/fetchable-hosts.ts` makes that list something both sides import instead of each keeping its own copy. The suffix match anchors on a dot, so `imgbox.com.evil.com` does not pass. - A resolved-address check, covering the ranges that are easy to miss by hand: IPv4-mapped IPv6, CGNAT, multicast and reserved space. - Manual redirect handling, revalidating every hop. Without it an allowed host can redirect us to link-local metadata and the allowlist means nothing. This does not stop DNS rebinding, and says so in a comment rather than implying otherwise: `fetch` resolves independently of our lookup. The allowlist is what makes that acceptable — exploiting it needs authoritative DNS for one of three specific domains. Rejected URLs now answer 400 instead of 500, so "unsupported link" and "upstream is down" are distinguishable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3a84a05 commit a1353c3

5 files changed

Lines changed: 465 additions & 2 deletions

File tree

src/lib/fetchable-hosts.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* The hosts we will fetch HTML from to extract image metadata.
3+
*
4+
* This is deliberately *not* the same list as the URL prefixes in
5+
* `extractImage`. Those pick which parser to run, and include hosts we never
6+
* fetch at all — an `i.imgur.com` or `cdn.discordapp.com` link is already a
7+
* direct image URL, so it is returned without a request. This list answers a
8+
* narrower question: which hosts may `/api/fetch` be pointed at.
9+
*
10+
* Keeping it in its own module means the dev-only API route and the browser
11+
* agree on the answer instead of drifting apart.
12+
*/
13+
export const FETCHABLE_HOSTS = ['imgsli.com', 'slow.pics', 'imgbox.com'] as const;
14+
15+
/**
16+
* Whether `hostname` is an allowlisted host or a subdomain of one.
17+
*
18+
* The suffix match anchors on a dot on purpose. Without it, `imgbox.com.evil.com`
19+
* and `evilimgbox.com` would both pass — the first is a domain the attacker
20+
* controls, and the second is one they can simply register.
21+
*/
22+
export function isFetchableHost(hostname: string): boolean {
23+
const host = hostname.toLowerCase();
24+
return FETCHABLE_HOSTS.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
25+
}

src/lib/image-util.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1+
import { isFetchableHost } from './fetchable-hosts';
12
import { Image, PairedImage, StandaloneImage } from './schema';
23

34
export type GetDocument = (url: string) => Promise<Document>;
45

56
export async function fetchHtml(url: string): Promise<string> {
7+
// `/api/fetch` enforces this too, and has to — it is reachable without going
8+
// through this function. Checking here as well means an unsupported host
9+
// fails the same way in dev and on the deployed site, rather than getting a
10+
// 400 from our own API in one and a cors-anywhere error in the other.
11+
if (!isFetchableHost(new URL(url).hostname)) {
12+
throw new Error(`Cannot fetch from this host: ${url}`);
13+
}
14+
615
if (location.hostname === 'localhost') {
716
// we should have access to our API routes
817
const res = await fetch('/api/fetch', {

src/lib/server/safe-fetch.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import dns from 'node:dns';
2+
import net from 'node:net';
3+
import { isFetchableHost } from '../fetchable-hosts';
4+
5+
/**
6+
* Fetching a URL that came from a request body means the server can be aimed at
7+
* anything it can reach, including services that are only listening because
8+
* they assumed nobody outside the machine could talk to them. The guard here is
9+
* three layers, because each one covers a hole the others leave open:
10+
*
11+
* 1. A host allowlist. The strongest of the three, and the reason the other
12+
* two are cheap: the set of hosts we ever need to fetch is three entries
13+
* long, so anything else — including every address literal — is refused
14+
* before a packet is sent.
15+
* 2. A resolved-address check, so an allowlisted name that happens to point at
16+
* a private address is still refused.
17+
* 3. Manual redirect handling, so a response from an allowed host cannot
18+
* redirect us somewhere we would never have agreed to fetch directly.
19+
*
20+
* Layer 2 does not eliminate DNS rebinding: `fetch` resolves the name again
21+
* itself, and nothing here pins the address we validated to the socket it opens.
22+
* Closing that properly needs a custom dispatcher, which is not worth it for a
23+
* route that only exists under `next dev` — and layer 1 already means an
24+
* attacker would need authoritative DNS for imgbox, slow.pics or imgsli, at
25+
* which point the rebinding is the least of the problems.
26+
*/
27+
28+
const MAX_REDIRECTS = 5;
29+
30+
/** Validation failures, kept distinct so the route can answer 400 rather than 500. */
31+
export class UrlNotAllowedError extends Error {
32+
constructor(message: string) {
33+
super(message);
34+
this.name = 'UrlNotAllowedError';
35+
}
36+
}
37+
38+
function isPrivateIPv4(ip: string): boolean {
39+
const parts = ip.split('.').map(Number);
40+
const [a, b] = parts;
41+
42+
if (a === 0) return true; // 0.0.0.0/8 "this network"
43+
if (a === 10) return true; // RFC1918
44+
if (a === 127) return true; // loopback
45+
if (a === 169 && b === 254) return true; // link-local, incl. cloud metadata at 169.254.169.254
46+
if (a === 172 && b >= 16 && b <= 31) return true; // RFC1918
47+
if (a === 192 && b === 0) return true; // IETF protocol assignments
48+
if (a === 192 && b === 168) return true; // RFC1918
49+
if (a === 198 && b >= 18 && b <= 19) return true; // benchmarking
50+
if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT
51+
if (a >= 224) return true; // multicast, reserved, and the broadcast address
52+
53+
return false;
54+
}
55+
56+
/**
57+
* Whether `ip` belongs to a range that should never be reachable from a
58+
* user-supplied URL.
59+
*
60+
* Anything that is not a valid address returns false — callers reject unknown
61+
* hosts through the allowlist, and reporting a hostname as "private" here would
62+
* only produce a confusing error.
63+
*/
64+
export function isPrivateAddress(ip: string): boolean {
65+
if (net.isIPv4(ip)) {
66+
return isPrivateIPv4(ip);
67+
}
68+
69+
if (net.isIPv6(ip)) {
70+
const lower = ip.toLowerCase();
71+
72+
// An IPv4-mapped address routes wherever its embedded IPv4 address
73+
// does, so ::ffff:127.0.0.1 is loopback. Nothing about the IPv6 text
74+
// says so, which is what makes a prefix check on the string miss it.
75+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
76+
if (mapped && net.isIPv4(mapped[1])) {
77+
return isPrivateIPv4(mapped[1]);
78+
}
79+
80+
if (lower === '::' || lower === '::1') return true;
81+
if (/^f[cd]/.test(lower)) return true; // fc00::/7 unique local
82+
if (/^fe[89ab]/.test(lower)) return true; // fe80::/10 link local
83+
if (/^ff/.test(lower)) return true; // ff00::/8 multicast
84+
85+
return false;
86+
}
87+
88+
return false;
89+
}
90+
91+
/**
92+
* Parses `raw` and rejects it unless it is an https URL on an allowlisted host
93+
* that resolves to a public address.
94+
*/
95+
export async function assertSafeUrl(raw: string | URL): Promise<URL> {
96+
let url: URL;
97+
try {
98+
url = new URL(raw);
99+
} catch {
100+
throw new UrlNotAllowedError(`Invalid URL: ${String(raw)}`);
101+
}
102+
103+
if (url.protocol !== 'https:') {
104+
throw new UrlNotAllowedError(`Protocol not allowed: ${url.protocol}`);
105+
}
106+
107+
// `URL.hostname` keeps the brackets around an IPv6 literal, and `net.isIPv6`
108+
// does not accept them — so `[::1]` reads as "not an IP address" unless they
109+
// come off first.
110+
const hostname = url.hostname.replace(/^\[|\]$/g, '');
111+
112+
if (!isFetchableHost(hostname)) {
113+
throw new UrlNotAllowedError(`Host not allowed: ${hostname}`);
114+
}
115+
116+
const addresses = await dns.promises.lookup(hostname, { all: true });
117+
for (const { address } of addresses) {
118+
if (isPrivateAddress(address)) {
119+
throw new UrlNotAllowedError(`Host resolves to a private address: ${hostname} -> ${address}`);
120+
}
121+
}
122+
123+
return url;
124+
}
125+
126+
/**
127+
* Fetches `raw` as text, validating the initial URL and every redirect it
128+
* follows.
129+
*/
130+
export async function safeFetchText(raw: string): Promise<string> {
131+
let url = await assertSafeUrl(raw);
132+
133+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
134+
const res = await fetch(url, { redirect: 'manual' });
135+
136+
if (res.status < 300 || res.status >= 400) {
137+
return await res.text();
138+
}
139+
140+
const location = res.headers.get('location');
141+
if (!location) {
142+
throw new Error(`Redirect with no Location header from ${url.href}`);
143+
}
144+
145+
// Relative redirects are common and legal, so resolve against the URL we
146+
// just requested before revalidating.
147+
url = await assertSafeUrl(new URL(location, url));
148+
}
149+
150+
throw new Error(`Too many redirects starting from ${String(raw)}`);
151+
}

src/pages/api/fetch.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextApiRequest, NextApiResponse } from 'next';
2+
import { UrlNotAllowedError, safeFetchText } from '../../lib/server/safe-fetch';
23

34
interface Body {
45
url: string;
@@ -7,9 +8,13 @@ interface Body {
78
export default async function fetchReq(req: NextApiRequest, res: NextApiResponse) {
89
try {
910
const body = JSON.parse(req.body as string) as Body;
10-
const text = await fetch(body.url).then((res) => res.text());
11+
const text = await safeFetchText(body.url);
1112
res.status(200).json({ ok: true, data: text });
1213
} catch (err) {
13-
res.status(500).json({ ok: false, error: String(err) });
14+
// A rejected URL is the caller's mistake and an upstream failure is not,
15+
// so they get different statuses — otherwise "you pasted a link we do
16+
// not support" and "imgbox is down" are the same 500.
17+
const status = err instanceof UrlNotAllowedError ? 400 : 500;
18+
res.status(status).json({ ok: false, error: String(err) });
1419
}
1520
}

0 commit comments

Comments
 (0)