ssrf: fall back to public resolvers when the system resolver fails; homepage reveal SSR-visible; chart formatter keys
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 changed file +25 −2
modified
packages/core/src/ssrf.ts
+25 −2
@@ -158,8 +158,7 @@ export async function assertUrlAllowed(raw: string, opts: UrlPolicyOptions = {}) | ||
| 158 | 158 | const t0 = performance.now(); |
| 159 | 159 | let records: Array<{ address: string; family: number }>; |
| 160 | 160 | try { |
| 161 | − const { promises: dns } = await import("node:dns"); | |
| 162 | − records = await dns.lookup(hostname, { all: true, verbatim: true }); | |
| 161 | + records = await resolveAll(hostname); | |
| 163 | 162 | } catch { |
| 164 | 163 | throw new FetchaError("TARGET_UNAVAILABLE", "The target hostname could not be resolved."); |
| 165 | 164 | } |
@@ -172,3 +171,27 @@ export async function assertUrlAllowed(raw: string, opts: UrlPolicyOptions = {}) | ||
| 172 | 171 | } |
| 173 | 172 | return { url, hostname, addresses: records.map((r) => r.address), dns_ms }; |
| 174 | 173 | } |
| 174 | + | |
| 175 | +const FALLBACK_DNS_SERVERS = (process.env.FETCHA_DNS_FALLBACK ?? "1.1.1.1,8.8.8.8,9.9.9.9").split(",").map((s) => s.trim()).filter(Boolean); | |
| 176 | + | |
| 177 | +/** | |
| 178 | + * Resolve every address of a hostname. Uses the system resolver first, then falls back to | |
| 179 | + * public resolvers (some hosts run split-horizon/MagicDNS resolvers that fail on unrelated names). | |
| 180 | + */ | |
| 181 | +export async function resolveAll(hostname: string): Promise<Array<{ address: string; family: number }>> { | |
| 182 | + const dnsMod = await import("node:dns"); | |
| 183 | + try { | |
| 184 | + const recs = await dnsMod.promises.lookup(hostname, { all: true, verbatim: true }); | |
| 185 | + if (recs.length) return recs; | |
| 186 | + } catch { | |
| 187 | + /* fall through to public resolvers */ | |
| 188 | + } | |
| 189 | + const resolver = new dnsMod.promises.Resolver({ timeout: 4000, tries: 2 }); | |
| 190 | + resolver.setServers(FALLBACK_DNS_SERVERS); | |
| 191 | + const [v4, v6] = await Promise.allSettled([resolver.resolve4(hostname), resolver.resolve6(hostname)]); | |
| 192 | + const out: Array<{ address: string; family: number }> = []; | |
| 193 | + if (v4.status === "fulfilled") out.push(...v4.value.map((address) => ({ address, family: 4 }))); | |
| 194 | + if (v6.status === "fulfilled") out.push(...v6.value.map((address) => ({ address, family: 6 }))); | |
| 195 | + if (!out.length) throw new Error(`Unable to resolve ${hostname}`); | |
| 196 | + return out; | |
| 197 | +} | |
| 175 | 198 | |