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

@ -77,14 +77,6 @@ var idColumns = []struct{ table, col string }{
{"media_file_artists", "media_file_id"}, {"media_file_artists", "artist_id"},
{"album_artists", "album_id"}, {"album_artists", "artist_id"},
{"library_tag", "tag_id"},
{"podcast_channel", "id"},
{"podcast_episode", "id"}, {"podcast_episode", "channel_id"}, {"podcast_episode", "stream_id"},
{"podcast_transcript", "id"}, {"podcast_transcript", "episode_id"},
{"podcast_person", "id"}, {"podcast_person", "channel_id"}, {"podcast_person", "episode_id"},
{"podcast_podroll", "id"}, {"podcast_podroll", "channel_id"},
{"podcast_live_item", "id"}, {"podcast_live_item", "channel_id"},
{"podcast_funding", "id"}, {"podcast_funding", "channel_id"},
{"podcast_image", "id"}, {"podcast_image", "channel_id"}, {"podcast_image", "episode_id"},
}
// embeddedIDColumns holds ids nested inside a larger value; the id-columns guard checks this
@ -100,7 +92,7 @@ var embeddedIDColumns = []struct {
}
func upUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error {
if err := buildIDMap(ctx, tx); err != nil {
if err := buildIDMap(ctx, tx, idColumns); err != nil {
return err
}
for _, tc := range idColumns {
@ -137,7 +129,10 @@ func rotateSessionSecret(ctx context.Context, tx *sql.Tx) error {
}
// buildIDMap stages old->new pairs for every id that changes, indexed for the update joins.
func buildIDMap(ctx context.Context, tx *sql.Tx) error {
// columns is a parameter (not always the package-level idColumns) so a later migration can
// reuse this same collect-and-rewrite machinery for a different, disjoint set of columns - see
// podcast_uniform_canonical_ids.go, which does exactly that for tables idColumns predates.
func buildIDMap(ctx context.Context, tx *sql.Tx, columns []struct{ table, col string }) error {
_, err := tx.ExecContext(ctx,
"CREATE TEMP TABLE _id_map (old_id TEXT PRIMARY KEY, new_id TEXT NOT NULL) WITHOUT ROWID")
if err != nil {
@ -148,7 +143,7 @@ func buildIDMap(ctx context.Context, tx *sql.Tx) error {
return err
}
defer ins.Close()
for _, tc := range idColumns {
for _, tc := range columns {
if err := collectColumn(ctx, tx, ins, tc.table, tc.col); err != nil {
return fmt.Errorf("collecting %s.%s: %w", tc.table, tc.col, err)
}

View file

@ -7,6 +7,14 @@ import (
"github.com/pressly/goose/v3"
)
// This file (and the 4 that follow it, add_podcast_downloaded_bytes/podcast20/podcast_tier3/
// podcast_metadata) were originally timestamped 2026-04-27/28, matching when the podcast
// feature was actually developed. They were renumbered to 2026-09-02 (after every migration
// already on master as of this PR) before merging: goose.UpContext (as navidrome calls it, with
// no WithAllowMissing) hard-errors and refuses to start if it finds a pending migration whose
// version is lower than the DB's already-applied max version - which every one of these files
// would have been, for any install that had already migrated past April on current master.
// Keep new migrations timestamped at-or-after merge time, not authoring time.
func init() {
goose.AddMigrationContext(upAddPodcast, downAddPodcast)
}

View file

@ -0,0 +1,57 @@
package migrations
import (
"context"
"database/sql"
"fmt"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upPodcastUniformCanonicalIds, downPodcastUniformCanonicalIds)
}
// podcastIDColumns lists every Navidrome-id-bearing podcast_* column, the same inventory
// uniform_canonical_ids (20260720015443) keeps for every other table - see this file's own
// upPodcastUniformCanonicalIds doc comment for why podcast ids need their own, later migration
// instead of just being added to that one's idColumns.
var podcastIDColumns = []struct{ table, col string }{
{"podcast_channel", "id"},
{"podcast_episode", "id"}, {"podcast_episode", "channel_id"}, {"podcast_episode", "stream_id"},
{"podcast_transcript", "id"}, {"podcast_transcript", "episode_id"},
{"podcast_person", "id"}, {"podcast_person", "channel_id"}, {"podcast_person", "episode_id"},
{"podcast_podroll", "id"}, {"podcast_podroll", "channel_id"},
{"podcast_live_item", "id"}, {"podcast_live_item", "channel_id"},
{"podcast_funding", "id"}, {"podcast_funding", "channel_id"},
{"podcast_image", "id"}, {"podcast_image", "channel_id"}, {"podcast_image", "episode_id"},
}
// upPodcastUniformCanonicalIds rewrites podcast_* ids to the same canonical 22-char base62
// encoding uniform_canonical_ids (20260720015443) already applied to every other table.
//
// It has to be a separate, later migration rather than an addition to that one's idColumns,
// for two independent reasons:
// 1. The podcast_* tables don't exist yet when 20260720015443 runs - the add_podcast* migrations
// that create them are timestamped after it (2026-09-02, see add_podcast.go's own comment on
// why) - so a SELECT against them there would fail outright, even on a fresh install.
// 2. Editing an already-applied migration's Go source has no runtime effect on any install that
// already ran it: goose tracks migrations as applied-or-not by version, not by re-diffing
// their source on every startup. An install that ran 20260720015443 before this feature
// existed would never re-run it, no matter what idColumns says today.
func upPodcastUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error {
if err := buildIDMap(ctx, tx, podcastIDColumns); err != nil {
return err
}
for _, tc := range podcastIDColumns {
if err := applyIDMap(ctx, tx, tc.table, tc.col); err != nil {
return fmt.Errorf("canonicalizing %s.%s: %w", tc.table, tc.col, err)
}
}
_, err := tx.ExecContext(ctx, "DROP TABLE _id_map")
return err
}
func downPodcastUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error {
return nil // irreversible data migration
}

View file

@ -65,14 +65,6 @@ var _ = Describe("upUniformCanonicalIds", func() {
CREATE TABLE library_tag (tag_id text, library_id integer);
CREATE TABLE plugin (id text, users text);
CREATE TABLE property (id text primary key, value text);
CREATE TABLE podcast_channel (id text);
CREATE TABLE podcast_episode (id text, channel_id text, stream_id text);
CREATE TABLE podcast_transcript (id text, episode_id text);
CREATE TABLE podcast_person (id text, channel_id text, episode_id text);
CREATE TABLE podcast_podroll (id text, channel_id text);
CREATE TABLE podcast_live_item (id text, channel_id text);
CREATE TABLE podcast_funding (id text, channel_id text);
CREATE TABLE podcast_image (id text, channel_id text, episode_id text);
`)
Expect(err).ToNot(HaveOccurred())