|
| 1 | +import { Resolver } from 'node:dns/promises' |
| 2 | +import { isIP } from 'node:net' |
1 | 3 | import type { CustomPreview, OgData, OgTag } from './types' |
2 | 4 |
|
3 | 5 | const FETCH_UA = |
4 | 6 | 'Mozilla/5.0 (compatible; hon.ey-link-preview/1.0; +https://github.com/) facebookexternalhit/1.1' |
5 | 7 |
|
| 8 | +const resolver = new Resolver() |
| 9 | + |
| 10 | +/** |
| 11 | + * Reject loopback, link-local, cloud-metadata, and RFC1918 ranges so a malicious |
| 12 | + * trap target cannot pivot fetchOgData into the host's internal network. |
| 13 | + */ |
| 14 | +function isPrivateAddress(addr: string): boolean { |
| 15 | + const v = isIP(addr) |
| 16 | + if (v === 4) { |
| 17 | + const [a, b] = addr.split('.').map(Number) |
| 18 | + if (a === 10) return true |
| 19 | + if (a === 127) return true |
| 20 | + if (a === 0) return true |
| 21 | + if (a === 169 && b === 254) return true // link-local + AWS/GCP metadata |
| 22 | + if (a === 172 && b >= 16 && b <= 31) return true |
| 23 | + if (a === 192 && b === 168) return true |
| 24 | + if (a === 100 && b >= 64 && b <= 127) return true // CGNAT |
| 25 | + if (a >= 224) return true // multicast + reserved |
| 26 | + return false |
| 27 | + } |
| 28 | + if (v === 6) { |
| 29 | + const lower = addr.toLowerCase() |
| 30 | + if (lower === '::1' || lower === '::' || lower.startsWith('fe80:') || lower.startsWith('fc') || lower.startsWith('fd')) return true |
| 31 | + if (lower.startsWith('::ffff:')) return isPrivateAddress(lower.slice(7)) |
| 32 | + if (lower.startsWith('2001:db8:')) return true |
| 33 | + return false |
| 34 | + } |
| 35 | + return true |
| 36 | +} |
| 37 | + |
| 38 | +async function assertPublicHost(urlStr: string): Promise<void> { |
| 39 | + let parsed: URL |
| 40 | + try { |
| 41 | + parsed = new URL(urlStr) |
| 42 | + } catch { |
| 43 | + throw new Error('Invalid URL') |
| 44 | + } |
| 45 | + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { |
| 46 | + throw new Error(`Refusing non-http(s) scheme: ${parsed.protocol}`) |
| 47 | + } |
| 48 | + const host = parsed.hostname |
| 49 | + if (!host) throw new Error('URL has no host') |
| 50 | + if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) { |
| 51 | + throw new Error(`Refusing internal host: ${host}`) |
| 52 | + } |
| 53 | + |
| 54 | + const literal = isIP(host) |
| 55 | + if (literal) { |
| 56 | + if (isPrivateAddress(host)) throw new Error(`Refusing private IP: ${host}`) |
| 57 | + return |
| 58 | + } |
| 59 | + |
| 60 | + const addrs: string[] = [] |
| 61 | + for (const fn of ['resolve4', 'resolve6'] as const) { |
| 62 | + try { |
| 63 | + const r = await resolver[fn](host) |
| 64 | + addrs.push(...r) |
| 65 | + } catch {} |
| 66 | + } |
| 67 | + if (!addrs.length) throw new Error(`Could not resolve host: ${host}`) |
| 68 | + for (const a of addrs) { |
| 69 | + if (isPrivateAddress(a)) throw new Error(`Host ${host} resolves to private address ${a}`) |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | +/** |
| 74 | + * Follow redirects manually so each hop's hostname can be re-validated against |
| 75 | + * the private-network blocklist. Native fetch with redirect:'follow' would let |
| 76 | + * an attacker bounce us from a public host into 169.254.169.254. |
| 77 | + */ |
| 78 | +async function safePublicFetch(url: string, init: RequestInit & { maxRedirects?: number }): Promise<Response> { |
| 79 | + const max = init.maxRedirects ?? 5 |
| 80 | + let current = url |
| 81 | + const { maxRedirects: _omit, ...passthrough } = init |
| 82 | + for (let i = 0; i <= max; i++) { |
| 83 | + await assertPublicHost(current) |
| 84 | + const res = await fetch(current, { ...passthrough, redirect: 'manual' }) |
| 85 | + if (res.status >= 300 && res.status < 400) { |
| 86 | + const loc = res.headers.get('location') |
| 87 | + if (!loc) return res |
| 88 | + current = new URL(loc, current).toString() |
| 89 | + continue |
| 90 | + } |
| 91 | + return res |
| 92 | + } |
| 93 | + throw new Error('Too many redirects') |
| 94 | +} |
| 95 | + |
6 | 96 | /** Which meta tags are worth cloning for a link preview. */ |
7 | 97 | function wantMeta(attr: 'property' | 'name', key: string): boolean { |
8 | 98 | const k = key.toLowerCase() |
@@ -56,9 +146,8 @@ export async function fetchOgData(targetUrl: string): Promise<OgData> { |
56 | 146 | try { |
57 | 147 | const controller = new AbortController() |
58 | 148 | const t = setTimeout(() => controller.abort(), 8000) |
59 | | - const res = await fetch(targetUrl, { |
| 149 | + const res = await safePublicFetch(targetUrl, { |
60 | 150 | headers: { 'User-Agent': FETCH_UA, Accept: 'text/html,application/xhtml+xml' }, |
61 | | - redirect: 'follow', |
62 | 151 | signal: controller.signal |
63 | 152 | }).finally(() => clearTimeout(t)) |
64 | 153 |
|
|
0 commit comments