diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index d5fd3cf11..e9b6f53a2 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -424,7 +424,7 @@ jobs: run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 + uses: goreleaser/goreleaser-action@v7 with: version: '~> v2' args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}" diff --git a/conf/configuration.go b/conf/configuration.go index 000bffb58..555d8f587 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -250,6 +250,7 @@ type pluginsOptions struct { type extAuthOptions struct { TrustedSources string UserHeader string + LogoutURL string } type searchOptions struct { @@ -345,6 +346,7 @@ func Load(noConfigDump bool) { validateBackupSchedule, validatePlaylistsPath, validatePurgeMissingOption, + validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL), ) if err != nil { os.Exit(1) @@ -548,6 +550,33 @@ func validateSchedule(schedule, field string) (string, error) { return schedule, err } +// validateURL checks if the provided URL is valid and has either http or https scheme. +// It returns a function that can be used as a hook to validate URLs in the config. +func validateURL(optionName, optionURL string) func() error { + return func() error { + if optionURL == "" { + return nil + } + u, err := url.Parse(optionURL) + if err != nil { + log.Error(fmt.Sprintf("Invalid %s: it could not be parsed", optionName), "url", optionURL, "err", err) + return err + } + if u.Scheme != "http" && u.Scheme != "https" { + err := fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme) + log.Error(err.Error()) + return err + } + // Require an absolute URL with a non-empty host and no opaque component. + if u.Host == "" || u.Opaque != "" { + err := fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL) + log.Error(err.Error()) + return err + } + return nil + } +} + func normalizeSearchBackend(value string) string { v := strings.ToLower(strings.TrimSpace(value)) switch v { @@ -641,6 +670,7 @@ func setViperDefaults() { viper.SetDefault("passwordencryptionkey", "") viper.SetDefault("extauth.userheader", "Remote-User") viper.SetDefault("extauth.trustedsources", "") + viper.SetDefault("extauth.logouturl", "") viper.SetDefault("prometheus.enabled", false) viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath) viper.SetDefault("prometheus.password", "") diff --git a/conf/configuration_test.go b/conf/configuration_test.go index b4ed6ca2d..73fec4196 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -52,6 +52,48 @@ var _ = Describe("Configuration", func() { }) }) + Describe("ValidateURL", func() { + It("accepts a valid http URL", func() { + fn := conf.ValidateURL("TestOption", "http://example.com/path") + Expect(fn()).To(Succeed()) + }) + + It("accepts a valid https URL", func() { + fn := conf.ValidateURL("TestOption", "https://example.com/path") + Expect(fn()).To(Succeed()) + }) + + It("rejects a URL with no scheme", func() { + fn := conf.ValidateURL("TestOption", "example.com/path") + Expect(fn()).To(MatchError(ContainSubstring("invalid scheme"))) + }) + + It("rejects a URL with an unsupported scheme", func() { + fn := conf.ValidateURL("TestOption", "javascript://example.com/path") + Expect(fn()).To(MatchError(ContainSubstring("invalid scheme"))) + }) + + It("accepts an empty URL (optional config)", func() { + fn := conf.ValidateURL("TestOption", "") + Expect(fn()).To(Succeed()) + }) + + It("includes the option name in the error message", func() { + fn := conf.ValidateURL("MyOption", "ftp://example.com") + Expect(fn()).To(MatchError(ContainSubstring("MyOption"))) + }) + + It("rejects a URL that cannot be parsed", func() { + fn := conf.ValidateURL("TestOption", "://invalid") + Expect(fn()).To(HaveOccurred()) + }) + + It("rejects a URL without a host", func() { + fn := conf.ValidateURL("TestOption", "http:///path") + Expect(fn()).To(MatchError(ContainSubstring("non-empty host is required"))) + }) + }) + DescribeTable("NormalizeSearchBackend", func(input, expected string) { Expect(conf.NormalizeSearchBackend(input)).To(Equal(expected)) diff --git a/conf/export_test.go b/conf/export_test.go index 7344dc4ca..d1d1bb3a9 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -8,4 +8,6 @@ var SetViperDefaults = setViperDefaults var ParseLanguages = parseLanguages +var ValidateURL = validateURL + var NormalizeSearchBackend = normalizeSearchBackend diff --git a/go.mod b/go.mod index f84ea65d0..c52bb211d 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,13 @@ module github.com/navidrome/navidrome -go 1.25 +go 1.25.0 replace ( // Fork to fix https://github.com/navidrome/navidrome/issues/3254 github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d // Fork to implement raw tags support - go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e + go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260225021432-1699562530f1 ) require ( @@ -53,7 +53,7 @@ require ( github.com/onsi/gomega v1.39.1 github.com/pelletier/go-toml/v2 v2.2.4 github.com/pocketbase/dbx v1.12.0 - github.com/pressly/goose/v3 v3.26.0 + github.com/pressly/goose/v3 v3.27.0 github.com/prometheus/client_golang v1.23.2 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 @@ -88,7 +88,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/creack/pty v1.1.24 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -140,7 +140,6 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect - golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect golang.org/x/tools v0.42.0 // indirect diff --git a/go.sum b/go.sum index 4e05f46bc..bbfd51e11 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= @@ -34,10 +34,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e h1:yQF3eOcI2dMMtxqdKXm3cgfYZlDcq9SUDDv90bsMj2I= -github.com/deluan/go-taglib v0.0.0-20260221220301-2fab4903f48e/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/deluan/go-taglib v0.0.0-20260225021432-1699562530f1 h1:seWJmkPAb+M1ysRNGzTGS7FfdrUe9wQTHhB9p2fxDWg= +github.com/deluan/go-taglib v0.0.0-20260225021432-1699562530f1/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4= @@ -143,8 +143,8 @@ github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2Og github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -193,8 +193,8 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= @@ -212,8 +212,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= -github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= -github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= +github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -321,8 +321,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= -golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= @@ -423,11 +423,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= -modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= +modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= -modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= diff --git a/plugins/examples/wikimedia/main.go b/plugins/examples/wikimedia/main.go index 6f56d4221..8508354cf 100644 --- a/plugins/examples/wikimedia/main.go +++ b/plugins/examples/wikimedia/main.go @@ -14,6 +14,7 @@ import ( "net/url" "strings" + "github.com/navidrome/navidrome/plugins/pdk/go/host" "github.com/navidrome/navidrome/plugins/pdk/go/metadata" "github.com/navidrome/navidrome/plugins/pdk/go/pdk" ) @@ -77,21 +78,28 @@ func sparqlQuery(endpoint, query string) (*SPARQLResult, error) { form := url.Values{} form.Set("query", query) - req := pdk.NewHTTPRequest(pdk.MethodPost, endpoint) - req.SetHeader("Accept", "application/sparql-results+json") - req.SetHeader("Content-Type", "application/x-www-form-urlencoded") - req.SetHeader("User-Agent", "NavidromeWikimediaPlugin/1.0") - req.SetBody([]byte(form.Encode())) - pdk.Log(pdk.LogDebug, fmt.Sprintf("SPARQL query to %s: %s", endpoint, query)) - resp := req.Send() - if resp.Status() != 200 { - return nil, fmt.Errorf("SPARQL HTTP error: status %d", resp.Status()) + resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "POST", + URL: endpoint, + Headers: map[string]string{ + "Accept": "application/sparql-results+json", + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": "NavidromeWikimediaPlugin/1.0", + }, + Body: []byte(form.Encode()), + TimeoutMs: 10000, + }) + if err != nil { + return nil, fmt.Errorf("SPARQL HTTP error: %w", err) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("SPARQL HTTP error: status %d", resp.StatusCode) } var result SPARQLResult - if err := json.Unmarshal(resp.Body(), &result); err != nil { + if err := json.Unmarshal(resp.Body, &result); err != nil { return nil, fmt.Errorf("failed to parse SPARQL response: %w", err) } if len(result.Results.Bindings) == 0 { @@ -104,15 +112,22 @@ func sparqlQuery(endpoint, query string) (*SPARQLResult, error) { func mediawikiQuery(params url.Values) ([]byte, error) { apiURL := fmt.Sprintf("%s?%s", mediawikiAPIEndpoint, params.Encode()) - req := pdk.NewHTTPRequest(pdk.MethodGet, apiURL) - req.SetHeader("Accept", "application/json") - req.SetHeader("User-Agent", "NavidromeWikimediaPlugin/1.0") - - resp := req.Send() - if resp.Status() != 200 { - return nil, fmt.Errorf("MediaWiki HTTP error: status %d", resp.Status()) + resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "GET", + URL: apiURL, + Headers: map[string]string{ + "Accept": "application/json", + "User-Agent": "NavidromeWikimediaPlugin/1.0", + }, + TimeoutMs: 10000, + }) + if err != nil { + return nil, fmt.Errorf("MediaWiki HTTP error: %w", err) } - return resp.Body(), nil + if resp.StatusCode != 200 { + return nil, fmt.Errorf("MediaWiki HTTP error: status %d", resp.StatusCode) + } + return resp.Body, nil } // getWikidataWikipediaURL fetches the Wikipedia URL from Wikidata using MBID or name diff --git a/plugins/host/httpclient.go b/plugins/host/httpclient.go new file mode 100644 index 000000000..b61361c57 --- /dev/null +++ b/plugins/host/httpclient.go @@ -0,0 +1,40 @@ +package host + +import "context" + +// HTTPRequest represents an outbound HTTP request from a plugin. +type HTTPRequest struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers,omitempty"` + Body []byte `json:"body,omitempty"` + TimeoutMs int32 `json:"timeoutMs,omitempty"` +} + +// HTTPResponse represents the response from an outbound HTTP request. +type HTTPResponse struct { + StatusCode int32 `json:"statusCode"` + Headers map[string]string `json:"headers,omitempty"` + Body []byte `json:"body,omitempty"` +} + +// HTTPService provides outbound HTTP request capabilities for plugins. +// +// This service allows plugins to make HTTP requests to external services. +// Requests are validated against the plugin's declared requiredHosts patterns +// from the http permission in the manifest. Redirects are followed but each +// redirect destination is also validated against the allowed hosts. +// +//nd:hostservice name=HTTP permission=http +type HTTPService interface { + // Send executes an HTTP request and returns the response. + // + // Parameters: + // - request: The HTTP request to execute, including method, URL, headers, body, and timeout + // + // Returns the HTTP response with status code, headers, and body. + // Network errors, timeouts, and permission failures are returned as Go errors. + // Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. + //nd:hostfunc + Send(ctx context.Context, request HTTPRequest) (*HTTPResponse, error) +} diff --git a/plugins/host/httpclient_gen.go b/plugins/host/httpclient_gen.go new file mode 100644 index 000000000..c14a533d0 --- /dev/null +++ b/plugins/host/httpclient_gen.go @@ -0,0 +1,88 @@ +// Code generated by ndpgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" +) + +// HTTPSendRequest is the request type for HTTP.Send. +type HTTPSendRequest struct { + Request HTTPRequest `json:"request"` +} + +// HTTPSendResponse is the response type for HTTP.Send. +type HTTPSendResponse struct { + Result *HTTPResponse `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterHTTPHostFunctions registers HTTP service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterHTTPHostFunctions(service HTTPService) []extism.HostFunction { + return []extism.HostFunction{ + newHTTPSendHostFunction(service), + } +} + +func newHTTPSendHostFunction(service HTTPService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "http_send", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + httpWriteError(p, stack, err) + return + } + var req HTTPSendRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + httpWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.Send(ctx, req.Request) + if svcErr != nil { + httpWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := HTTPSendResponse{ + Result: result, + } + httpWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// httpWriteResponse writes a JSON response to plugin memory. +func httpWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + httpWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// httpWriteError writes an error response to plugin memory. +func httpWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go new file mode 100644 index 000000000..4dc8a2b9e --- /dev/null +++ b/plugins/host_httpclient.go @@ -0,0 +1,190 @@ +package plugins + +import ( + "bytes" + "cmp" + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/host" +) + +const ( + httpClientDefaultTimeout = 10 * time.Second + httpClientMaxRedirects = 5 + httpClientMaxResponseBodyLen = 10 * 1024 * 1024 // 10 MB +) + +// httpServiceImpl implements host.HTTPService. +type httpServiceImpl struct { + pluginName string + requiredHosts []string + client *http.Client +} + +// newHTTPService creates a new HTTPService for a plugin. +func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceImpl { + var requiredHosts []string + if permission != nil { + requiredHosts = permission.RequiredHosts + } + svc := &httpServiceImpl{ + pluginName: pluginName, + requiredHosts: requiredHosts, + } + svc.client = &http.Client{ + Transport: http.DefaultTransport, + // Timeout is set per-request via context deadline, not here. + // CheckRedirect validates hosts and enforces redirect limits. + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= httpClientMaxRedirects { + log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via)) + return http.ErrUseLastResponse + } + if err := svc.validateHost(req.Context(), req.URL.Host); err != nil { + log.Warn(req.Context(), "HTTP redirect blocked", "plugin", svc.pluginName, "url", req.URL.String(), "err", err) + return err + } + return nil + }, + } + return svc +} + +func (s *httpServiceImpl) Send(ctx context.Context, request host.HTTPRequest) (*host.HTTPResponse, error) { + // Parse and validate URL + parsedURL, err := url.Parse(request.URL) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + // Validate URL scheme + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return nil, fmt.Errorf("invalid URL scheme %q: must be http or https", parsedURL.Scheme) + } + + // Validate host against allowed hosts and private IP restrictions + if err := s.validateHost(ctx, parsedURL.Host); err != nil { + return nil, err + } + + // Apply per-request timeout via context deadline + timeout := cmp.Or(time.Duration(request.TimeoutMs)*time.Millisecond, httpClientDefaultTimeout) + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // Build request body + method := strings.ToUpper(request.Method) + var body io.Reader + if len(request.Body) > 0 { + body = bytes.NewReader(request.Body) + } + + // Create HTTP request + httpReq, err := http.NewRequestWithContext(ctx, method, request.URL, body) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + for k, v := range request.Headers { + httpReq.Header.Set(k, v) + } + + // Execute request + resp, err := s.client.Do(httpReq) //nolint:gosec // URL is validated against requiredHosts + if err != nil { + return nil, err + } + defer resp.Body.Close() + + log.Trace(ctx, "HTTP request", "plugin", s.pluginName, "method", method, "url", request.URL, "status", resp.StatusCode) + + // Read response body (with size limit to prevent memory exhaustion) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, httpClientMaxResponseBodyLen)) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + + // Flatten response headers (first value only) + headers := make(map[string]string, len(resp.Header)) + for k, v := range resp.Header { + if len(v) > 0 { + headers[k] = v[0] + } + } + + return &host.HTTPResponse{ + StatusCode: int32(resp.StatusCode), + Headers: headers, + Body: respBody, + }, nil +} + +// validateHost checks whether a request to the given host is permitted. +// When requiredHosts is set, it checks against the allowlist. +// When requiredHosts is empty, it blocks private/loopback IPs to prevent SSRF. +func (s *httpServiceImpl) validateHost(ctx context.Context, hostStr string) error { + hostname := extractHostname(hostStr) + + if len(s.requiredHosts) > 0 { + if !s.isHostAllowed(hostname) { + return fmt.Errorf("host %q is not allowed", hostStr) + } + return nil + } + + // No explicit allowlist: block private/loopback IPs + if isPrivateOrLoopback(hostname) { + log.Warn(ctx, "HTTP request to private/loopback address blocked", "plugin", s.pluginName, "host", hostStr) + return fmt.Errorf("host %q is not allowed: private/loopback addresses require explicit requiredHosts in manifest", hostStr) + } + return nil +} + +func (s *httpServiceImpl) isHostAllowed(hostname string) bool { + for _, pattern := range s.requiredHosts { + if matchHostPattern(pattern, hostname) { + return true + } + } + return false +} + +// extractHostname returns the hostname portion of a host string, stripping +// any port number and IPv6 brackets. It handles IPv6 addresses correctly +// (e.g. "[::1]:8080" → "::1", "[::1]" → "::1"). +func extractHostname(hostStr string) string { + if h, _, err := net.SplitHostPort(hostStr); err == nil { + return h + } + // Strip IPv6 brackets when no port is present (e.g. "[::1]" → "::1") + if strings.HasPrefix(hostStr, "[") && strings.HasSuffix(hostStr, "]") { + return hostStr[1 : len(hostStr)-1] + } + return hostStr +} + +// isPrivateOrLoopback returns true if the given hostname resolves to or is +// a private, loopback, or link-local IP address. This includes: +// IPv4: 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 +// IPv6: ::1, fc00::/7, fe80::/10 +// It also blocks "localhost" by name. +func isPrivateOrLoopback(hostname string) bool { + if strings.EqualFold(hostname, "localhost") { + return true + } + ip := net.ParseIP(hostname) + if ip == nil { + return false + } + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() +} + +// Verify interface implementation +var _ host.HTTPService = (*httpServiceImpl)(nil) diff --git a/plugins/host_httpclient_test.go b/plugins/host_httpclient_test.go new file mode 100644 index 000000000..29796b052 --- /dev/null +++ b/plugins/host_httpclient_test.go @@ -0,0 +1,565 @@ +//go:build !windows + +package plugins + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/navidrome/navidrome/plugins/host" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("httpServiceImpl", func() { + var ( + svc *httpServiceImpl + ts *httptest.Server + ) + + AfterEach(func() { + if ts != nil { + ts.Close() + } + }) + + Context("without host restrictions (default SSRF protection)", func() { + BeforeEach(func() { + svc = newHTTPService("test-plugin", nil) + }) + + It("should block requests to loopback IPs", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should block requests to localhost by name", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://localhost:12345/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should block requests to private IPs (10.x)", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://10.0.0.1/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should block requests to private IPs (192.168.x)", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://192.168.1.1/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should block requests to private IPs (172.16.x)", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://172.16.0.1/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should block requests to link-local IPs (169.254.x)", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://169.254.169.254/latest/meta-data/", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should block requests to IPv6 loopback with port", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://[::1]:8080/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should block requests to IPv6 loopback without port", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://[::1]/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("private/loopback")) + }) + + It("should allow requests to public hostnames", func() { + // This will fail at the network level (connection refused or DNS), + // but it should NOT fail with a "private/loopback" error + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://203.0.113.1:1/test", // TEST-NET-3, non-routable but not private + TimeoutMs: 100, + }) + // Should get a network error, not a permission error + if err != nil { + Expect(err.Error()).ToNot(ContainSubstring("private/loopback")) + } + }) + + It("should return error for invalid URL", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "://bad-url", + }) + Expect(err).To(HaveOccurred()) + }) + + It("should reject non-http/https URL schemes", func() { + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "ftp://example.com/file", + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("must be http or https")) + }) + }) + + Context("with explicit requiredHosts allowing loopback", func() { + BeforeEach(func() { + svc = newHTTPService("test-plugin", &HTTPPermission{ + RequiredHosts: []string{"127.0.0.1"}, + }) + }) + + It("should handle GET requests", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal("GET")) + w.Header().Set("X-Test", "ok") + w.WriteHeader(201) + _, _ = w.Write([]byte("hello")) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + Headers: map[string]string{"Accept": "text/plain"}, + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(int32(201))) + Expect(string(resp.Body)).To(Equal("hello")) + Expect(resp.Headers["X-Test"]).To(Equal("ok")) + }) + + It("should handle POST requests with body", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal("POST")) + b, _ := io.ReadAll(r.Body) + _, _ = w.Write([]byte("got:" + string(b))) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "POST", + URL: ts.URL, + Body: []byte("abc"), + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(string(resp.Body)).To(Equal("got:abc")) + }) + + It("should handle PUT requests with body", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal("PUT")) + b, _ := io.ReadAll(r.Body) + _, _ = w.Write([]byte("put:" + string(b))) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "PUT", + URL: ts.URL, + Body: []byte("xyz"), + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(string(resp.Body)).To(Equal("put:xyz")) + }) + + It("should handle DELETE requests", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal("DELETE")) + w.WriteHeader(204) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "DELETE", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(int32(204))) + }) + + It("should handle DELETE requests with body", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal("DELETE")) + b, _ := io.ReadAll(r.Body) + _, _ = w.Write([]byte("del:" + string(b))) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "DELETE", + URL: ts.URL, + Body: []byte(`{"id":"123"}`), + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(string(resp.Body)).To(Equal(`del:{"id":"123"}`)) + }) + + It("should handle PATCH requests with body", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal("PATCH")) + b, _ := io.ReadAll(r.Body) + _, _ = w.Write([]byte("patch:" + string(b))) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "PATCH", + URL: ts.URL, + Body: []byte("data"), + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(string(resp.Body)).To(Equal("patch:data")) + }) + + It("should handle HEAD requests", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Method).To(Equal("HEAD")) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "HEAD", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(int32(200))) + Expect(resp.Headers["Content-Type"]).To(Equal("application/json")) + Expect(resp.Body).To(BeEmpty()) + }) + + It("should use default timeout when TimeoutMs is 0", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(int32(200))) + }) + + It("should return error on timeout", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + })) + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("deadline exceeded")) + }) + + It("should return error on context cancellation", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + })) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(1 * time.Millisecond) + cancel() + }() + _, err := svc.Send(ctx, host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 5000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("context canceled")) + }) + + It("should send request headers", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(r.Header.Get("X-Custom"))) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + Headers: map[string]string{"X-Custom": "myvalue"}, + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(string(resp.Body)).To(Equal("myvalue")) + }) + }) + + Context("with host restrictions", func() { + BeforeEach(func() { + svc = newHTTPService("test-plugin", &HTTPPermission{ + RequiredHosts: []string{"allowed.example.com", "*.allowed.org"}, + }) + }) + + It("should block requests to non-allowed hosts", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + // httptest server is on 127.0.0.1 which is not in requiredHosts + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not allowed")) + }) + + It("should follow redirects to allowed hosts", func() { + // Create a destination server + dest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("final")) + })) + defer dest.Close() + // Create a redirect server + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, dest.URL, http.StatusFound) + })) + // Allow both servers (both on 127.0.0.1) + svc.requiredHosts = []string{"127.0.0.1"} + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.StatusCode).To(Equal(int32(200))) + Expect(string(resp.Body)).To(Equal("final")) + }) + + It("should block redirects to non-allowed hosts", func() { + // Server that redirects to a disallowed host + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://evil.example.com/steal", http.StatusFound) + })) + // Override requiredHosts to allow the test server + svc.requiredHosts = []string{"127.0.0.1"} + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not allowed")) + }) + + It("should block redirects to private IPs when allowlist is set", func() { + // Server that redirects to a private IP + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "http://10.0.0.1/internal", http.StatusFound) + })) + // Allow the test server; redirect to 10.0.0.1 is blocked by allowlist + svc.requiredHosts = []string{"127.0.0.1"} + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(resp).To(BeNil()) + }) + + It("should allow wildcard host patterns", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("wildcard")) + })) + // *.allowed.org is in the requiredHosts from BeforeEach, but test server is 127.0.0.1 + // Override with a wildcard that matches the test server + svc.requiredHosts = []string{"*.0.0.1"} + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(string(resp.Body)).To(Equal("wildcard")) + }) + + It("should reject hosts not matching wildcard patterns", func() { + svc.requiredHosts = []string{"*.example.com"} + _, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: "http://evil.other.com/test", + TimeoutMs: 1000, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not allowed")) + }) + }) + + Context("response body size limit", func() { + BeforeEach(func() { + svc = newHTTPService("test-plugin", &HTTPPermission{ + RequiredHosts: []string{"127.0.0.1"}, + }) + }) + + It("should truncate response body at the size limit", func() { + // Serve a body larger than the limit + oversizedBody := strings.Repeat("x", httpClientMaxResponseBodyLen+1024) + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(oversizedBody)) + })) + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "GET", + URL: ts.URL, + TimeoutMs: 5000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(len(resp.Body)).To(Equal(httpClientMaxResponseBodyLen)) + }) + }) + + Context("edge cases", func() { + BeforeEach(func() { + svc = newHTTPService("test-plugin", &HTTPPermission{ + RequiredHosts: []string{"127.0.0.1"}, + }) + }) + + It("should default empty method to GET", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("method:" + r.Method)) + })) + // Empty method — Go's http.NewRequestWithContext normalizes "" to "GET" + resp, err := svc.Send(context.Background(), host.HTTPRequest{ + Method: "", + URL: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(string(resp.Body)).To(Equal("method:GET")) + }) + }) +}) + +var _ = Describe("extractHostname", func() { + It("should extract hostname from host:port", func() { + Expect(extractHostname("example.com:8080")).To(Equal("example.com")) + }) + + It("should return hostname when no port", func() { + Expect(extractHostname("example.com")).To(Equal("example.com")) + }) + + It("should handle IPv6 with port", func() { + Expect(extractHostname("[::1]:8080")).To(Equal("::1")) + }) + + It("should handle IPv6 without port", func() { + Expect(extractHostname("::1")).To(Equal("::1")) + }) + + It("should strip brackets from IPv6 without port", func() { + Expect(extractHostname("[::1]")).To(Equal("::1")) + }) + + It("should handle IPv4 with port", func() { + Expect(extractHostname("127.0.0.1:9090")).To(Equal("127.0.0.1")) + }) + + It("should handle IPv4 without port", func() { + Expect(extractHostname("127.0.0.1")).To(Equal("127.0.0.1")) + }) +}) + +var _ = Describe("isPrivateOrLoopback", func() { + It("should detect IPv4 loopback", func() { + Expect(isPrivateOrLoopback("127.0.0.1")).To(BeTrue()) + Expect(isPrivateOrLoopback("127.0.0.2")).To(BeTrue()) + }) + + It("should detect IPv6 loopback", func() { + Expect(isPrivateOrLoopback("::1")).To(BeTrue()) + }) + + It("should detect localhost by name", func() { + Expect(isPrivateOrLoopback("localhost")).To(BeTrue()) + Expect(isPrivateOrLoopback("LOCALHOST")).To(BeTrue()) + }) + + It("should detect 10.x.x.x private range", func() { + Expect(isPrivateOrLoopback("10.0.0.1")).To(BeTrue()) + Expect(isPrivateOrLoopback("10.255.255.255")).To(BeTrue()) + }) + + It("should detect 172.16.x.x private range", func() { + Expect(isPrivateOrLoopback("172.16.0.1")).To(BeTrue()) + Expect(isPrivateOrLoopback("172.31.255.255")).To(BeTrue()) + }) + + It("should detect 192.168.x.x private range", func() { + Expect(isPrivateOrLoopback("192.168.0.1")).To(BeTrue()) + Expect(isPrivateOrLoopback("192.168.255.255")).To(BeTrue()) + }) + + It("should detect link-local addresses", func() { + Expect(isPrivateOrLoopback("169.254.169.254")).To(BeTrue()) + Expect(isPrivateOrLoopback("169.254.0.1")).To(BeTrue()) + }) + + It("should detect IPv6 private (fc00::/7)", func() { + Expect(isPrivateOrLoopback("fd00::1")).To(BeTrue()) + }) + + It("should detect IPv6 link-local (fe80::/10)", func() { + Expect(isPrivateOrLoopback("fe80::1")).To(BeTrue()) + }) + + It("should allow public IPs", func() { + Expect(isPrivateOrLoopback("8.8.8.8")).To(BeFalse()) + Expect(isPrivateOrLoopback("203.0.113.1")).To(BeFalse()) + Expect(isPrivateOrLoopback("2001:db8::1")).To(BeFalse()) + }) + + It("should allow non-IP hostnames (DNS names)", func() { + Expect(isPrivateOrLoopback("example.com")).To(BeFalse()) + Expect(isPrivateOrLoopback("api.example.com")).To(BeFalse()) + }) + + It("should not treat 172.32.x.x as private", func() { + Expect(isPrivateOrLoopback("172.32.0.1")).To(BeFalse()) + }) +}) diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 06d905b49..84b28dd35 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -256,8 +256,11 @@ func (s *webSocketServiceImpl) isHostAllowed(host string) bool { } // matchHostPattern matches a host against a pattern. -// Supports wildcards like *.example.com +// Supports "*" (allow all) and wildcards like "*.example.com". func matchHostPattern(pattern, host string) bool { + if pattern == "*" { + return true + } if pattern == host { return true } diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index a3d8ee74a..7a0439129 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -575,6 +575,12 @@ var _ = Describe("WebSocketService", Ordered, func() { Expect(matchHostPattern("*.example.com", "deep.api.example.com")).To(BeTrue()) }) + It("should match bare '*' as allow-all", func() { + Expect(matchHostPattern("*", "anything.example.com")).To(BeTrue()) + Expect(matchHostPattern("*", "127.0.0.1")).To(BeTrue()) + Expect(matchHostPattern("*", "::1")).To(BeTrue()) + }) + It("should not match partial patterns", func() { Expect(matchHostPattern("*.example.com", "example.com.evil.org")).To(BeFalse()) }) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index b558da1be..c6355911f 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -119,6 +119,15 @@ var hostServices = []hostServiceEntry{ return host.RegisterUsersHostFunctions(service), nil }, }, + { + name: "HTTP", + hasPermission: func(p *Permissions) bool { return p != nil && p.Http != nil }, + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) { + perm := ctx.permissions.Http + service := newHTTPService(ctx.pluginName, perm) + return host.RegisterHTTPHostFunctions(service), nil + }, + }, } // extractManifest reads manifest from an .ndp package and computes its SHA-256 hash. diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index 82dc2c4aa..b801db44b 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -38,6 +38,7 @@ The following host services are available: - Artwork: provides artwork public URL generation capabilities for plugins. - Cache: provides in-memory TTL-based caching capabilities for plugins. - Config: provides access to plugin configuration values. + - HTTP: provides outbound HTTP request capabilities for plugins. - KVStore: provides persistent key-value storage for plugins. - Library: provides access to music library metadata for plugins. - Scheduler: provides task scheduling capabilities for plugins. diff --git a/plugins/pdk/go/host/nd_host_httpclient.go b/plugins/pdk/go/host/nd_host_httpclient.go new file mode 100644 index 000000000..8bd960351 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_httpclient.go @@ -0,0 +1,87 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the HTTP host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package host + +import ( + "encoding/json" + "errors" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// HTTPRequest represents an outbound HTTP request from a plugin. +type HTTPRequest struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Body []byte `json:"body"` + TimeoutMs int32 `json:"timeoutMs"` +} + +// HTTPResponse represents the response from an outbound HTTP request. +type HTTPResponse struct { + StatusCode int32 `json:"statusCode"` + Headers map[string]string `json:"headers"` + Body []byte `json:"body"` +} + +// http_send is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user http_send +func http_send(uint64) uint64 + +type httpSendRequest struct { + Request HTTPRequest `json:"request"` +} + +type httpSendResponse struct { + Result *HTTPResponse `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// HTTPSend calls the http_send host function. +// Send executes an HTTP request and returns the response. +// +// Parameters: +// - request: The HTTP request to execute, including method, URL, headers, body, and timeout +// +// Returns the HTTP response with status code, headers, and body. +// Network errors, timeouts, and permission failures are returned as Go errors. +// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. +func HTTPSend(request HTTPRequest) (*HTTPResponse, error) { + // Marshal request to JSON + req := httpSendRequest{ + Request: request, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := http_send(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response httpSendResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Result, nil +} diff --git a/plugins/pdk/go/host/nd_host_httpclient_stub.go b/plugins/pdk/go/host/nd_host_httpclient_stub.go new file mode 100644 index 000000000..053069391 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_httpclient_stub.go @@ -0,0 +1,55 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains mock implementations for non-WASM builds. +// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. +// Plugin authors can use the exported mock instances to set expectations in tests. +// +//go:build !wasip1 + +package host + +import "github.com/stretchr/testify/mock" + +// HTTPRequest represents an outbound HTTP request from a plugin. +type HTTPRequest struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Body []byte `json:"body"` + TimeoutMs int32 `json:"timeoutMs"` +} + +// HTTPResponse represents the response from an outbound HTTP request. +type HTTPResponse struct { + StatusCode int32 `json:"statusCode"` + Headers map[string]string `json:"headers"` + Body []byte `json:"body"` +} + +// mockHTTPService is the mock implementation for testing. +type mockHTTPService struct { + mock.Mock +} + +// HTTPMock is the auto-instantiated mock instance for testing. +// Use this to set expectations: host.HTTPMock.On("MethodName", args...).Return(values...) +var HTTPMock = &mockHTTPService{} + +// Send is the mock method for HTTPSend. +func (m *mockHTTPService) Send(request HTTPRequest) (*HTTPResponse, error) { + args := m.Called(request) + return args.Get(0).(*HTTPResponse), args.Error(1) +} + +// HTTPSend delegates to the mock instance. +// Send executes an HTTP request and returns the response. +// +// Parameters: +// - request: The HTTP request to execute, including method, URL, headers, body, and timeout +// +// Returns the HTTP response with status code, headers, and body. +// Network errors, timeouts, and permission failures are returned as Go errors. +// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. +func HTTPSend(request HTTPRequest) (*HTTPResponse, error) { + return HTTPMock.Send(request) +} diff --git a/plugins/pdk/python/host/nd_host_httpclient.py b/plugins/pdk/python/host/nd_host_httpclient.py new file mode 100644 index 000000000..c6bfb77c0 --- /dev/null +++ b/plugins/pdk/python/host/nd_host_httpclient.py @@ -0,0 +1,59 @@ +# Code generated by ndpgen. DO NOT EDIT. +# +# This file contains client wrappers for the HTTP host service. +# It is intended for use in Navidrome plugins built with extism-py. +# +# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly. +# The @extism.import_fn decorators are only detected when defined in the plugin's +# main __init__.py file. Copy the needed functions from this file into your plugin. + +from dataclasses import dataclass +from typing import Any + +import extism +import json + + +class HostFunctionError(Exception): + """Raised when a host function returns an error.""" + pass + + +@extism.import_fn("extism:host/user", "http_send") +def _http_send(offset: int) -> int: + """Raw host function - do not call directly.""" + ... + + +def http_send(request: Any) -> Any: + """Send executes an HTTP request and returns the response. + +Parameters: + - request: The HTTP request to execute, including method, URL, headers, body, and timeout + +Returns the HTTP response with status code, headers, and body. +Network errors, timeouts, and permission failures are returned as errors. +Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. + + Args: + request: Any parameter. + + Returns: + Any: The result value. + + Raises: + HostFunctionError: If the host function returns an error. + """ + request = { + "request": request, + } + request_bytes = json.dumps(request).encode("utf-8") + request_mem = extism.memory.alloc(request_bytes) + response_offset = _http_send(request_mem.offset) + response_mem = extism.memory.find(response_offset) + response = json.loads(extism.memory.string(response_mem)) + + if response.get("error"): + raise HostFunctionError(response["error"]) + + return response.get("result", None) diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index 3dff68269..52a3a86cd 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -35,6 +35,7 @@ //! - [`artwork`] - provides artwork public URL generation capabilities for plugins. //! - [`cache`] - provides in-memory TTL-based caching capabilities for plugins. //! - [`config`] - provides access to plugin configuration values. +//! - [`http`] - provides outbound HTTP request capabilities for plugins. //! - [`kvstore`] - provides persistent key-value storage for plugins. //! - [`library`] - provides access to music library metadata for plugins. //! - [`scheduler`] - provides task scheduling capabilities for plugins. @@ -63,6 +64,13 @@ pub mod config { pub use super::nd_host_config::*; } +#[doc(hidden)] +mod nd_host_http; +/// provides outbound HTTP request capabilities for plugins. +pub mod http { + pub use super::nd_host_http::*; +} + #[doc(hidden)] mod nd_host_kvstore; /// provides persistent key-value storage for plugins. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs new file mode 100644 index 000000000..c73241c80 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs @@ -0,0 +1,83 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the HTTP host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +/// HTTPRequest represents an outbound HTTP request from a plugin. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpRequest { + pub method: String, + pub url: String, + #[serde(default)] + pub headers: std::collections::HashMap, + #[serde(default)] + pub body: Vec, + #[serde(default)] + pub timeout_ms: i32, +} + +/// HTTPResponse represents the response from an outbound HTTP request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpResponse { + pub status_code: i32, + #[serde(default)] + pub headers: std::collections::HashMap, + #[serde(default)] + pub body: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct HTTPSendRequest { + request: HttpRequest, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct HTTPSendResponse { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn http_send(input: Json) -> Json; +} + +/// Send executes an HTTP request and returns the response. +/// +/// Parameters: +/// - request: The HTTP request to execute, including method, URL, headers, body, and timeout +/// +/// Returns the HTTP response with status code, headers, and body. +/// Network errors, timeouts, and permission failures are returned as errors. +/// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. +/// +/// # Arguments +/// * `request` - HttpRequest parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn send(request: HttpRequest) -> Result, Error> { + let response = unsafe { + http_send(Json(HTTPSendRequest { + request: request, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} diff --git a/server/serve_index.go b/server/serve_index.go index b5b364267..92ef47e23 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -76,6 +76,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "separator": string(os.PathSeparator), "enableInspect": conf.Server.Inspect.Enabled, "pluginsEnabled": conf.Server.Plugins.Enabled, + "extAuthLogoutURL": conf.Server.ExtAuth.LogoutURL, } if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL) diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 9d6f480ff..e08a42643 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -104,6 +104,7 @@ var _ = Describe("serveIndex", func() { Entry("enableUserEditing", func() { conf.Server.EnableUserEditing = false }, "enableUserEditing", false), Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true), Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true), + Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"), ) DescribeTable("sets other UI configuration values", diff --git a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML index 759f7b52d..38e0944cf 100644 --- a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML @@ -1,7 +1,7 @@ - - + + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 8c4e330b0..0fdbf1be6 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -303,7 +303,7 @@ type Playlist struct { Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"` SongCount int32 `xml:"songCount,attr" json:"songCount"` Duration int32 `xml:"duration,attr" json:"duration"` - Public bool `xml:"public,attr,omitempty" json:"public,omitempty"` + Public bool `xml:"public,attr" json:"public,omitempty"` Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"` Created time.Time `xml:"created,attr" json:"created"` Changed time.Time `xml:"changed,attr" json:"changed"` diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js index 4ae238eec..813a4f5b4 100644 --- a/ui/src/authProvider.js +++ b/ui/src/authProvider.js @@ -66,6 +66,10 @@ const authProvider = { logout: () => { removeItems() + if (config.extAuthLogoutURL) { + window.location.href = config.extAuthLogoutURL + return Promise.resolve(false) + } return Promise.resolve() }, diff --git a/ui/src/layout/UserMenu.jsx b/ui/src/layout/UserMenu.jsx index a5757a73c..e33185578 100644 --- a/ui/src/layout/UserMenu.jsx +++ b/ui/src/layout/UserMenu.jsx @@ -122,7 +122,7 @@ const UserMenu = (props) => { }) : null, )} - {!config.auth && logout} + {(!config.auth || !!config.extAuthLogoutURL) && logout}