Summary
Surfaced from the security review of #1632 (the TOCTOU rebind close for detectProtocol). Two more buyer-side fetch sites still use raw fetch() against agent-supplied URLs — they have the same TOCTOU rebind window that #1627 just closed for detectProtocol.
Affected sites
High priority — same TOCTOU class as #1618/#1627
src/lib/discovery/network-consistency-checker.ts:375,397,569,590 — uses fetch(agent.url, ...) with a validateAgentUrl literal-hostname gate. A hostname that passes the literal validation but resolves to 169.254.169.254 reaches IMDS. Worse: it has its own redirect: 'manual' + validateAgentUrl(redirectUrl) path followed by a second native fetch — a hostname rebind between the two calls slips through.
src/lib/discovery/property-crawler.ts:259 — uses fetch(url) to crawl adcp+ properties from agent-supplied data. No SSRF gate visible in the surrounding context.
Medium — depends on call-site trust
src/lib/auth/index.ts:60 — transport wrapper used by adapters. If the URL it's called with comes from agent metadata (not operator config), it inherits the gap.
Already safe
src/lib/signing/jwks-https.ts, src/lib/signing/brand-jwks.ts, src/lib/signing/agent-resolver/fetch-helpers.ts — already route through ssrfSafeFetch.
src/lib/auth-introspection.ts, webhook emitters — operator-configured URLs (lower risk).
Proposed approach
Same shape as #1632:
- Replace native
fetch with ssrfSafeFetch at each affected call site.
- Pass
allowPrivateIp: isInternalProbesAllowed() so the env opt-in flows through.
- Adopt the same
dns_lookup_failed / dns_empty / body_exceeds_limit → suspect carve-out that detectProtocol uses (consider exporting a shared helper — see "centralized constant" suggestion below).
- For the
network-consistency-checker.ts redirect-follow path: replace the manual two-call dance with a single ssrfSafeFetch invocation if cards are small enough; if not, validate each redirect target as a fresh ssrfSafeFetch call (which DNS-pins each hop).
Companion suggestion: centralize the carve-out
Security-reviewer of #1632 suggested:
Consider exporting SsrfRefusedCode as a centralized constant of "must propagate" vs "treat as transient" so the next caller wiring up ssrfSafeFetch doesn't reinvent the carve-out and accidentally re-introduce the catch-swallow class.
Concrete shape: export SSRF_TRANSIENT_CODES: ReadonlySet<SsrfRefusedCode> from src/lib/net/ssrf-fetch.ts. Callers do if (err instanceof SsrfRefusedError && SSRF_TRANSIENT_CODES.has(err.code)) { suspect = true; continue; } throw err;.
Tracking
Filed from the security-reviewer's #1632 review. Defense-in-depth — not a regression caused by #1627. Worth landing before any server-side comply runner ships.
Summary
Surfaced from the security review of #1632 (the TOCTOU rebind close for
detectProtocol). Two more buyer-side fetch sites still use rawfetch()against agent-supplied URLs — they have the same TOCTOU rebind window that #1627 just closed fordetectProtocol.Affected sites
High priority — same TOCTOU class as #1618/#1627
src/lib/discovery/network-consistency-checker.ts:375,397,569,590— usesfetch(agent.url, ...)with avalidateAgentUrlliteral-hostname gate. A hostname that passes the literal validation but resolves to169.254.169.254reaches IMDS. Worse: it has its ownredirect: 'manual'+validateAgentUrl(redirectUrl)path followed by a second nativefetch— a hostname rebind between the two calls slips through.src/lib/discovery/property-crawler.ts:259— usesfetch(url)to crawladcp+properties from agent-supplied data. No SSRF gate visible in the surrounding context.Medium — depends on call-site trust
src/lib/auth/index.ts:60— transport wrapper used by adapters. If the URL it's called with comes from agent metadata (not operator config), it inherits the gap.Already safe
src/lib/signing/jwks-https.ts,src/lib/signing/brand-jwks.ts,src/lib/signing/agent-resolver/fetch-helpers.ts— already route throughssrfSafeFetch.src/lib/auth-introspection.ts, webhook emitters — operator-configured URLs (lower risk).Proposed approach
Same shape as #1632:
fetchwithssrfSafeFetchat each affected call site.allowPrivateIp: isInternalProbesAllowed()so the env opt-in flows through.dns_lookup_failed/dns_empty/body_exceeds_limit→ suspect carve-out thatdetectProtocoluses (consider exporting a shared helper — see "centralized constant" suggestion below).network-consistency-checker.tsredirect-follow path: replace the manual two-call dance with a singlessrfSafeFetchinvocation if cards are small enough; if not, validate each redirect target as a freshssrfSafeFetchcall (which DNS-pins each hop).Companion suggestion: centralize the carve-out
Security-reviewer of #1632 suggested:
Concrete shape: export
SSRF_TRANSIENT_CODES: ReadonlySet<SsrfRefusedCode>fromsrc/lib/net/ssrf-fetch.ts. Callers doif (err instanceof SsrfRefusedError && SSRF_TRANSIENT_CODES.has(err.code)) { suspect = true; continue; } throw err;.Tracking
Filed from the security-reviewer's #1632 review. Defense-in-depth — not a regression caused by #1627. Worth landing before any server-side comply runner ships.