Add SSRF-safe recipe fetcher
CI / ci (push) Successful in 1m36s
CI / ci (pull_request) Successful in 1m35s

internal/recipes.Fetcher fetches https:// URLs only. Its DialContext
resolves the host once, refuses unless every answer is public (loopback,
RFC 1918, link-local, CGNAT 100.64/10, multicast, unspecified, reserved,
IPv6 ULA/link-local/multicast and IPv4-mapped or -embedding forms are
blocked) and dials the address it checked, so a rebinding DNS answer
cannot swap in a private IP. Keep-alives and environment proxies are off,
so every request and redirect hop dials and checks afresh.

At most 3 redirects, each re-checked for scheme and address; bodies over
2 MB are an error rather than truncated; 10 s for the whole fetch; a
descriptive User-Agent; requests to one host at least 1 s apart; 2xx
pages cached by URL for an hour.

Tests use a stub resolver and httptest TLS servers.

Maestro task: f4414e41-bd99-498d-a613-a90053963249

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 11:51:49 -04:00
co-authored by Claude Opus 5.5
parent e6fd24db0a
commit e7dbcf3d2c
3 changed files with 899 additions and 1 deletions
+11 -1
View File
@@ -208,7 +208,17 @@ automatic** (D-021).
- Dinners are grounded in real recipe pages found through recipe search
(`searxng` or `brave`, per `recipe_search_settings`).
- Fetching is **SSRF-safe**.
- Fetching is **SSRF-safe** (`recipes.Fetcher`): https:// only, at every
redirect hop. Its dialer resolves the host once and refuses unless every
answer is public — loopback, RFC 1918, link-local, carrier-grade NAT
(100.64/10), multicast, unspecified, reserved, IPv6 unique-local,
link-local and multicast, and IPv4-mapped or IPv4-embedding (NAT64, 6to4,
Teredo) forms are refused — then dials the address it checked, so a
rebinding DNS answer cannot swap in a private one. Keep-alives and
environment proxies are off, so every request dials and checks afresh. At
most 3 redirects; a body over 2 MB is an error, never truncated; 10 s for
the whole fetch; a descriptive `User-Agent`; requests to one host at least
1 s apart; 2xx pages cached by URL for an hour (64 pages, oldest evicted).
- Recipe data is parsed from schema.org `Recipe` JSON-LD.
- Photos are cached locally (`photos`) and processed with
`golang.org/x/image`.