fix(podcast): implement missing SSRF validation and fix migration boot failure

Two blocking bugs found while reviewing this branch:

1. Compile error: validateURL() was called in fetchAndParse/doDownload (added
   while applying Strix's SSRF suggestions) but the function itself was never
   committed - only "Add validateURL and isReservedIP helper functions" was
   left as a plain-text suggestion with no one-click apply, and it got missed.
   Implemented validateURL/isReservedIP plus a safeHTTPTransport whose
   DialContext re-resolves and re-checks the target IP at actual connection
   time (not just once via a URL pre-check), so a DNS answer that changes
   between the check and the request (DNS rebinding) can't reach a reserved
   address - this also covers HTTP redirect targets for free, since redirects
   reuse the same Transport. Added AllowLoopbackHTTPForTests() so the
   existing httptest-based suite (which binds to 127.0.0.1) still passes
   without weakening the guard for any other address.

2. Migration boot failure: the podcast migrations were dated 2026-04-27/28
   (when the feature was actually developed), but goose.UpContext (as this
   project calls it, no WithAllowMissing) hard-errors on any pending
   migration older than the DB's already-applied max version. Any install
   already past April on current master would fail to start entirely on
   upgrade. Renumbered all 5 podcast migrations to 2026-09-02 (after
   everything currently on master). This also meant the podcast_* columns
   added to the already-shipped uniform_canonical_ids migration's idColumns
   were dead code for any install that had already run that migration -
   editing an applied migration's Go source doesn't make it re-run. Reverted
   that edit and split the podcast id canonicalization into its own,
   later migration (20260902000005) that reuses the same buildIDMap/
   applyIDMap machinery. Verified both fresh-install and existing-install
   upgrade paths end-to-end against real sqlite DBs: no boot error, and
   legacy-shaped podcast ids (plus their FK references) get correctly
   rewritten to canonical form.

Verified: full build clean, core/podcasts + server/nativeapi + db/migrations
test suites all pass, gofmt clean.
This commit is contained in:
Jiho Andrew Lee 2026-09-19 20:37:25 +09:00
commit 23f28aac66
10 changed files with 171 additions and 21 deletions

View file

@ -316,7 +316,7 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod
s.setEpisodeError(ctx, ep, fmt.Errorf("invalid enclosure URL: %w", err))
return
}
httpClient := &http.Client{Timeout: 30 * time.Second}
httpClient := &http.Client{Timeout: 30 * time.Second, Transport: safeHTTPTransport}
resp, err := httpClient.Get(ep.EnclosureURL) //nolint:gosec
if err != nil {
s.setEpisodeError(ctx, ep, err)
@ -535,11 +535,102 @@ func (pw *progressWriter) Write(p []byte) (int, error) {
return n, err
}
// validateURL rejects any URL that is not a plain http/https request to a
// named host. It exists to prevent SSRF: without it, an authenticated user
// could point the preview/download endpoints at internal services, cloud
// metadata endpoints (e.g. 169.254.169.254), or any other host only
// reachable from the server itself. This is a cheap, fast-failing check on
// the URL's shape - the actual IP-level check happens per-connection in
// safeHTTPTransport below, since the host a URL names and the IP it
// resolves to at request time aren't guaranteed to be the same thing.
func validateURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("parsing URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("unsupported URL scheme %q, only http/https are allowed", u.Scheme)
}
if u.Hostname() == "" {
return fmt.Errorf("URL has no host")
}
return nil
}
// isReservedIP reports whether ip is a loopback, private, link-local,
// multicast, or otherwise non-routable/internal address. Cloud metadata
// endpoints (e.g. AWS/GCP/Azure's 169.254.169.254) fall under the
// link-local range, so they're covered without a special case.
//
// This is a var, not a plain func, only so AllowLoopbackHTTPForTests (below)
// can narrow it for test binaries - production code never reassigns it.
var isReservedIP = func(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() ||
ip.IsUnspecified()
}
// AllowLoopbackHTTPForTests relaxes safeHTTPTransport's SSRF guard to permit
// loopback addresses (127.0.0.0/8, ::1) - every other reserved/private/
// link-local range (including cloud metadata endpoints) is still refused.
// It exists because httptest.Server always binds to loopback, so the podcast
// test suite needs a way to point the service at one without disabling the
// guard entirely. Not for production use.
func AllowLoopbackHTTPForTests() {
strict := isReservedIP
isReservedIP = func(ip net.IP) bool {
if ip.IsLoopback() {
return false
}
return strict(ip)
}
}
// safeHTTPTransport is shared by every outbound podcast HTTP request (RSS
// feed fetch and episode download). Its DialContext resolves the host and
// checks isReservedIP at the moment of connection, not just once via
// validateURL up front - so a DNS answer that changes between the URL
// check and the actual TCP connect (DNS rebinding) can't be used to reach
// a reserved address that validateURL alone would have caught.
var safeHTTPTransport = &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("parsing address %q: %w", addr, err)
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("resolving host %q: %w", host, err)
}
if len(ips) == 0 {
return nil, fmt.Errorf("host %q did not resolve to any address", host)
}
var dialer net.Dialer
var lastErr error
for _, ip := range ips {
if isReservedIP(ip.IP) {
lastErr = fmt.Errorf("host %q resolves to a reserved/internal address (%s), refusing to connect", host, ip.IP)
continue
}
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, lastErr
},
}
func fetchAndParse(rssURL string) (*rssFeed, error) {
if err := validateURL(rssURL); err != nil {
return nil, fmt.Errorf("invalid RSS feed URL: %w", err)
}
httpClient := &http.Client{Timeout: 15 * time.Second}
httpClient := &http.Client{Timeout: 15 * time.Second, Transport: safeHTTPTransport}
resp, err := httpClient.Get(rssURL) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("fetching RSS feed: %w", err)

View file

@ -3,6 +3,7 @@ package podcasts_test
import (
"testing"
"github.com/navidrome/navidrome/core/podcasts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@ -12,6 +13,12 @@ import (
func TestPodcasts(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
// This suite's specs fetch RSS feeds/episodes from an httptest.Server, which
// always binds to loopback - safeHTTPTransport's SSRF guard would otherwise
// refuse every request the suite makes. See AllowLoopbackHTTPForTests's own
// doc comment: every other reserved/private/link-local address is still
// refused, so this doesn't disable the guard, just narrows it for this run.
podcasts.AllowLoopbackHTTPForTests()
RegisterFailHandler(Fail)
RunSpecs(t, "Podcasts Suite")
}