Repository navigation
Expand file tree
/
Copy pathdns.ts
More file actions
106 lines (100 loc) · 3.86 KB
/
Copy pathdns.ts
File metadata and controls
106 lines (100 loc) · 3.86 KB
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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import dns from 'node:dns/promises'
import * as ipaddr from 'ipaddr.js'
/**
* Hard deadline on an SSRF-guard lookup.
*
* A guard that awaits a hung resolver holds its caller open — an HTTP handler,
* or a `webRequest` callback the browser is waiting on — so the lookup is
* bounded rather than left to the OS resolver's own retry schedule.
*/
export const DEFAULT_DNS_TIMEOUT_MS = 5_000
/**
* A resolver deadline rather than a missing host.
*
* Callers map both to the same user-facing message, but during a resolver
* outage every valid hostname would otherwise be reported as nonexistent with
* nothing in the logs telling the two apart.
*/
export class DnsTimeoutError extends Error {
readonly code = 'DNS_TIMEOUT'
constructor(host: string) {
super(`DNS lookup for ${host} timed out`)
this.name = 'DnsTimeoutError'
}
}
/**
* The address to connect to or pin, out of a set already judged acceptable.
*
* IPv4 first, for the reason described on {@link ResolvedHost.preferred}. Split
* out so a caller that narrows the set — dropping records its policy refuses —
* can re-apply the same preference to what is left instead of pinning an
* address it just rejected.
*/
export function preferIpv4(addresses: readonly [string, ...string[]]): string {
// IPv4.isValid rather than isValid + parse, which parses the string twice.
return addresses.find((address) => ipaddr.IPv4.isValid(address)) ?? addresses[0]
}
export interface ResolvedHost {
/**
* Every address the host resolves to.
*
* A guard must classify all of them. Checking one lets a host publishing both
* a public and a private record through whenever the public one happens to be
* picked, which is a matter of record order rather than of policy.
*/
addresses: string[]
/**
* The single address a caller should connect to or pin.
*
* IPv4 first: pinning strips Happy Eyeballs' fallback, so a pinned IPv6
* address hangs on IPv4-only egress (AWS NAT gateways, for one). Callers that
* pin depend on this ordering.
*/
preferred: string
}
/**
* Resolves a host for an SSRF guard: every address it points at, plus the one
* worth pinning, under a bounded deadline.
*
* Rejects when the host does not resolve or the deadline passes. Callers decide
* what that means — failing closed is right for a guard, but "unresolvable" and
* "resolves somewhere private" are different facts and some callers report them
* differently.
*/
export async function resolveHostAddresses(
host: string,
options: { timeoutMs?: number } = {}
): Promise<ResolvedHost> {
const { timeoutMs = DEFAULT_DNS_TIMEOUT_MS } = options
const lookup = dns.lookup(host, { all: true, verbatim: true })
// If the timeout wins the race the lookup stays pending; its eventual
// settlement is swallowed so a late rejection cannot surface as an unhandled
// one.
lookup.catch(() => {})
let timer: NodeJS.Timeout | undefined
try {
const resolved = await Promise.race([
lookup,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new DnsTimeoutError(host)), timeoutMs)
}),
])
if (resolved.length === 0) {
throw new Error(`No addresses for ${host}`)
}
const addresses = resolved.map((entry) => entry.address) as [string, ...string[]]
return {
addresses,
// Through preferIpv4 rather than re-derived from `entry.family`, so the
// rule has one implementation that callers narrowing the set also use.
preferred: preferIpv4(addresses),
// Resolver order is preserved (`verbatim: true`) because `preferred`
// applies the IPv4 preference itself, so the order here is informational.
// Deliberately unlike `createSsrfGuardedLookup` in apps/sim, which hands
// its full ordered list to undici to dial in turn and so wants
// `verbatim: false`.
}
} finally {
clearTimeout(timer)
}
}