Files
bookhoard/internal/services/podcast_http.go
T
John O'Keefe 49b0b6eeb9 test(podcasts): integration suite + RLS auditor + contract widening (task 1.10)
cmd/server/tests/podcast_test.go — six suites, the shared-dev-DB
doctrine (test*-named libraries, cascading cleanup, no binary
fixtures):

- TestPodcastRLSSurfaces: THE AUDITOR (invariant 13) — non-subscriber
  404 on feed detail + SSR page (existence leak), episodes absent from
  generic media reads, admin escape, subscriber visibility, then the
  hidden-library flip hiding an already-subscribed feed (the feed
  policy's library-visibility join). Seeding runs as the FIRST ADMIN —
  the seed identity has neither subscription nor users row, so podcast
  INSERT..RETURNING (SELECT policy under FORCE) 42501s for it;
  production pods run as exactly that admin (invariant 12). All direct
  row probes read through the admin scope for the same reason.
- TestPodcastAddFeedFlow: member add + subscribe + metadata-only
  upsert; duplicate-URL dedupe (no second row); allow_member_feeds
  gate (member 403 / admin 201 / subscribe-still-allowed).
- TestPodcastShrunkenFeedRetention: feed purges an episode from the
  XML, both rows persist (upsert-without-delete pin, D14).
- TestPodcastEditSegregation: generic edit 400 + write-to-file refusal
  for episodes; control book edit unaffected.
- TestPodcastDownloadWorker (ffmpeg-generated tone at runtime): file
  lands at <download-root>/<feed-slug>/NNNN-…, audio_metadata probed;
  re-download of a downloaded episode never re-fetches (idempotency
  pin, enclosure-hit counted); dead enclosure → failed + persisted
  reason + metadata intact. Fetch/SSRF tests swap the client via
  SetPodcastHTTPClientForTests (the production client refuses
  loopback httptest servers by design — the real client gets its own
  negative test in services).
- TestPodcastScannerExclusion: SetFolders on a podcasts library root
  yields zero scanner folders (invariant 4 pin) — MediaScanner
  .FolderCount() accessor added.

Unit: internal/services/podcast_rss_test.go — namespace-aware parsing
fixture (three XML namespaces, fallback chains, hosts/guests),
duration spellings, RFC-822 variants, explicit variants, slug/path
layout, enclosure-extension selection, stripHTML, and the SSRF matrix
(private/loopback/link-local/multicast/mapped-v6 blocked; a loopback
httptest server PROVES the production client refuses it).

suites green: full integration run 161s / 0 failures; vitest 12 files
163 tests.

Contract widening: TestContractPin_MediaListBothBranches' raw-row
allowlist gains the 15 podcast episode columns (additive; documented in
docs/contract_pins.md — inert defaults on scan rows). Bruno: bruno/
podcasts/ mirrors the §8 endpoints (12 requests) + Bookhoard env vars.
tests/ui/COVERAGE.md rows 18-23: podcasts pages/lifecycle/RLS/chain —
Playwright sweep pending.
2026-10-29 18:09:35 -04:00

207 lines
6.6 KiB
Go

// SSRF-guarded HTTP fetching for the podcast fetcher (plan §7).
//
// This is the code path that turns USER-SUPPLIED URLs into server
// fetches — treat it as a security boundary, not a convenience wrapper:
//
// - https-only schemes (feed URL, enclosure, artwork, EVERY redirect hop)
// - private/loopback/link-local/multicast/unspecified IP ranges blocked
// at DIAL time via Dialer.Control — the check sees the RESOLVED
// address, so a DNS name that rebinding-resolves to 127.0.0.1 (or a
// redirect into the internal network) is refused at the socket, not
// merely at URL-parse time
// - hard response size caps on every body (feed XML, artwork, enclosures)
//
// Go's redirect follower re-dials through the same dialer per hop, so
// the IP guard re-applies automatically; the scheme guard re-applies
// via CheckRedirect.
package services
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"syscall"
"time"
)
// Response size caps (bytes). Feeds are XML; artwork is an image;
// enclosures are audio episodes — the enclosure cap is deliberately the
// large one (audiobook chapters run long) but still bounded.
const (
maxFeedSizeBytes = 25 << 20 // 25 MiB
maxArtworkSizeBytes = 20 << 20 // 20 MiB
maxEnclosureCapBytes = 1 << 30 // 1 GiB
)
// ErrBlockedPrivateAddress marks SSRF-guard rejections (dial-time IP or
// scheme checks). Handlers surface it as a validation failure, and the
// download worker records it as the download_error text.
var ErrBlockedPrivateAddress = errors.New("address blocked: private, loopback, or otherwise non-public")
// podcastHTTPClient builds the shared guard client. Timeout is generous
// (podcast servers are slow) but bounded; enclosures stream through a
// LimitReader so a lying Content-Length cannot exhaust disk.
func podcastHTTPClient() *http.Client {
dialer := &net.Dialer{
Timeout: 15 * time.Second,
Control: func(network, address string, _ syscall.RawConn) error {
return guardDialAddress(address)
},
}
transport := &http.Transport{
DialContext: dialer.DialContext,
TLSHandshakeTimeout: 15 * time.Second,
ResponseHeaderTimeout: 60 * time.Second,
// Enclosure downloads are long; idle conn reuse still applies.
IdleConnTimeout: 90 * time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
Proxy: nil, // never route through a user-controllable proxy
}
return &http.Client{
Transport: transport,
Timeout: 15 * time.Minute, // outer bound (biggest enclosures on slow lines)
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
if err := guardURLScheme(req.URL); err != nil {
return err
}
return nil
},
}
}
// guardDialAddress validates the RESOLVED host:port at dial time.
func guardDialAddress(address string) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("dial guard: %w", err)
}
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("dial guard: unparseable address %q", host)
}
if !isPublicIP(ip) {
return ErrBlockedPrivateAddress
}
return nil
}
// guardURLScheme enforces https on any URL we are about to touch.
func guardURLScheme(u *url.URL) error {
if u == nil || u.Scheme != "https" {
return fmt.Errorf("only https feed/enclosure URLs are supported")
}
return nil
}
// isPublicIP reports whether ip is a routable public unicast address.
// Everything loopback/private/link-local/multicast/unspecified —
// including IPv4-mapped IPv6 spellings of those — is rejected.
func isPublicIP(ip net.IP) bool {
if ip == nil {
return false
}
// Normalize IPv4-mapped IPv6 (::ffff:10.0.0.1) to its v4 form so
// the v4 range checks below actually fire.
if ip4 := ip.To4(); ip4 != nil {
ip = ip4
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() || ip.IsUnspecified() {
return false
}
return true
}
// guardFetch performs a GET with the scheme/IP guards, conditional-GET
// validators, and a hard body cap. Returns the body bytes, the FINAL
// URL after redirects (new-feed-url / migration detection), validators
// to persist, and whether the server answered 304 Not Modified.
type fetchResult struct {
Body []byte
FinalURL string
ETag string
LastModified string
NotModified bool
StatusCode int
}
// podcastHTTPClientFactory is the indirection TESTS use: the SSRF suite
// exercises the REAL client; fetch-driven integration tests swap in a
// client that trusts httptest's loopback TLS cert. Never reassign
// outside tests (use SetPodcastHTTPClientForTests).
var podcastHTTPClientFactory = podcastHTTPClient
// SetPodcastHTTPClientForTests swaps the fetch client. nil restores the
// production client (SSRF guards intact).
func SetPodcastHTTPClientForTests(c *http.Client) {
if c == nil {
podcastHTTPClientFactory = podcastHTTPClient
return
}
podcastHTTPClientFactory = func() *http.Client { return c }
}
func guardFetch(ctx context.Context, rawURL, etag, lastModified string, maxSize int64) (*fetchResult, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
if err := guardURLScheme(u); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
// A recognizable UA; some podcast hosts 403 empty/default agents.
req.Header.Set("User-Agent", "Bookhoard/1.0 (podcast fetcher; +https://github.com/bookhoard)")
req.Header.Set("Accept", "*/*")
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
if lastModified != "" {
req.Header.Set("If-Modified-Since", lastModified)
}
resp, err := podcastHTTPClientFactory().Do(req)
if err != nil {
// Unwrap redirect/scheme guard errors so callers can classify.
return nil, err
}
defer func() { _ = resp.Body.Close() }()
res := &fetchResult{
FinalURL: resp.Request.URL.String(),
ETag: resp.Header.Get("ETag"),
LastModified: resp.Header.Get("Last-Modified"),
StatusCode: resp.StatusCode,
}
if resp.StatusCode == http.StatusNotModified {
res.NotModified = true
return res, nil
}
if resp.StatusCode != http.StatusOK {
return res, fmt.Errorf("fetch %s: HTTP %d", rawURL, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
if err != nil {
return res, fmt.Errorf("fetch %s: %w", rawURL, err)
}
if int64(len(body)) > maxSize {
return res, fmt.Errorf("fetch %s: response exceeds %d byte cap", rawURL, maxSize)
}
res.Body = body
return res, nil
}