File size: 1,991 Bytes
6f91ce5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import { lookup } from 'node:dns/promises'
import { BlockList, isIP } from 'node:net'

// URLs that come from users (pasted bookmarks, paper links) must never make the server
// request itself, the cloud metadata service or anything else on a private network.
// BlockList also checks IPv4-mapped IPv6 addresses (::ffff:127.0.0.1) against the IPv4 rules.
const blocked = new BlockList()
for (const [net, prefix] of [
	['0.0.0.0', 8],
	['10.0.0.0', 8],
	['100.64.0.0', 10],
	['127.0.0.0', 8],
	['169.254.0.0', 16],
	['172.16.0.0', 12],
	['192.0.0.0', 24],
	['192.168.0.0', 16],
	['198.18.0.0', 15],
	['224.0.0.0', 4],
	['240.0.0.0', 4],
] as const) {
	blocked.addSubnet(net, prefix, 'ipv4')
}
for (const [net, prefix] of [
	['::', 128],
	['::1', 128],
	['fc00::', 7],
	['fe80::', 10],
	['ff00::', 8],
] as const) {
	blocked.addSubnet(net, prefix, 'ipv6')
}

export async function assertPublicUrl(url: URL) {
	if (url.protocol !== 'http:' && url.protocol !== 'https:') {
		throw new Error(`Blocked protocol ${url.protocol}`)
	}
	const host = url.hostname.replace(/^\[|\]$/g, '')
	const addresses = isIP(host) ? [{ address: host }] : await lookup(host, { all: true })
	for (const { address } of addresses) {
		if (blocked.check(address, isIP(address) === 6 ? 'ipv6' : 'ipv4')) {
			throw new Error(`Blocked private address ${address} for ${url.hostname}`)
		}
	}
}

// fetch that checks the address of every hop in a redirect chain, not just the first URL
export async function publicFetch(input: string, init: RequestInit = {}, maxRedirects = 3) {
	let url = new URL(input)
	for (let hop = 0; ; hop++) {
		await assertPublicUrl(url)
		const res = await fetch(url, {
			...init,
			redirect: 'manual',
			signal: init.signal ?? AbortSignal.timeout(5000),
		})
		const location = res.headers.get('location')
		if (res.status < 300 || res.status >= 400 || !location) return res
		if (hop >= maxRedirects) throw new Error(`More than ${maxRedirects} redirects`)
		url = new URL(location, url)
	}
}