Problem
assertNotPrivateHost resolves the hostname via dns.resolve4/resolve6, checks the resulting IPs against a private-range blocklist, and returns. Immediately after, safeFetch calls net.fetch(url, ...) with the original URL string, which performs its own independent DNS resolution. A DNS rebinding attacker can return a public IP on the first lookup (passing the guard) and a private IP on the second lookup (the actual fetch), completely bypassing the SSRF check. This is directly triggerable from the renderer via artifact:save-image-url and the auto-extract path.
Location
File: packages/desktop/src/main/net/safe-fetch.ts:12-27 + packages/desktop/src/main/net/ssrf-guard.ts:49-59
// safe-fetch.ts
export async function safeFetch(url: string, opts: SafeFetchOptions = {}): Promise<Buffer> {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') throw new Error('HTTPS required');
await assertNotPrivateHost(parsed.hostname); // ← first DNS resolution
// ...
const res = await net.fetch(url, { signal: controller.signal }); // ← second independent DNS resolution
Attack surface: both call sites (packages/desktop/src/main/artifact/auto-extract.ts:35 and packages/desktop/src/main/ipc/artifact-handlers.ts:161) pass renderer-controlled URLs directly into safeFetch.
Fix Approach
Pin the IP between the guard and the fetch so the two operations cannot disagree. Options:
- Preferred: do the DNS resolution yourself in
assertNotPrivateHost, return the resolved IP, and pass that IP to net.fetch via a rewritten URL (with Host header preserved for TLS SNI). net.fetch in Electron supports a Host header override.
- Alternative: wrap
dns.lookup with a short-lived cache keyed by hostname that both the guard and the fetch read through, so both observe the same resolution result.
Either approach closes the TOCTOU window. The current code cannot be fixed in-place without pinning.
Verification
- Run
pnpm check — must pass.
- Unit test: mock
net.fetch to observe the resolved IP it is given; confirm it matches what the guard saw.
- Manual: set up a rebinding test server that flips between
1.1.1.1 and 127.0.0.1 across two DNS queries; confirm safeFetch rejects.
Context
- WG: Observability & DX (also security-critical)
- Priority: Medium (security, but attack surface is limited to user-supplied image URLs)
- Estimated effort: 1-2 hours
Problem
assertNotPrivateHostresolves the hostname viadns.resolve4/resolve6, checks the resulting IPs against a private-range blocklist, and returns. Immediately after,safeFetchcallsnet.fetch(url, ...)with the original URL string, which performs its own independent DNS resolution. A DNS rebinding attacker can return a public IP on the first lookup (passing the guard) and a private IP on the second lookup (the actual fetch), completely bypassing the SSRF check. This is directly triggerable from the renderer viaartifact:save-image-urland the auto-extract path.Location
File:
packages/desktop/src/main/net/safe-fetch.ts:12-27+packages/desktop/src/main/net/ssrf-guard.ts:49-59Attack surface: both call sites (
packages/desktop/src/main/artifact/auto-extract.ts:35andpackages/desktop/src/main/ipc/artifact-handlers.ts:161) pass renderer-controlled URLs directly intosafeFetch.Fix Approach
Pin the IP between the guard and the fetch so the two operations cannot disagree. Options:
assertNotPrivateHost, return the resolved IP, and pass that IP tonet.fetchvia a rewritten URL (withHostheader preserved for TLS SNI).net.fetchin Electron supports aHostheader override.dns.lookupwith a short-lived cache keyed by hostname that both the guard and the fetch read through, so both observe the same resolution result.Either approach closes the TOCTOU window. The current code cannot be fixed in-place without pinning.
Verification
pnpm check— must pass.net.fetchto observe the resolved IP it is given; confirm it matches what the guard saw.1.1.1.1and127.0.0.1across two DNS queries; confirmsafeFetchrejects.Context