Compare commits

...

7 Commits

Author SHA1 Message Date
Deluan
759214cfbc chore(deps): update Go version to 1.26 in Dockerfile and go.mod
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-24 21:49:19 -05:00
Deluan
14343d91b0 chore(deps): update goose to 3.27.0
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-24 21:44:04 -05:00
Deluan
fc36f1daa6 chore(deps): update go-taglib dependency to latest version (mka fix)
Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-24 21:19:11 -05:00
Deluan Quintão
652c27690b feat(plugins): add HTTP host service (#5095)
* feat(httpclient): implement HttpClient service for outbound HTTP requests in plugins

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(httpclient): enhance SSRF protection by validating host requests against private IPs

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(httpclient): support DELETE requests with body in HttpClient service

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(httpclient): refactor HTTP client initialization and enhance redirect handling

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(http): standardize naming conventions for HTTP types and methods

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor example plugin to use host.HTTPSend for improved error management

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(plugins): fix IPv6 SSRF bypass and wildcard host matching

Fix two bugs in the plugin HTTP/WebSocket host validation:

1. extractHostname now strips IPv6 brackets when no port is present
(e.g. "[::1]" → "::1"). Previously, net.SplitHostPort failed for
bracketed IPv6 without a port, leaving brackets intact. This caused
net.ParseIP to return nil, bypassing the private/loopback SSRF guard.

2. matchHostPattern now treats "*" as an allow-all pattern. Previously,
a bare "*" only matched via exact equality, so plugins declaring
requiredHosts: ["*"] (like webhook-rs) had all requests rejected.

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-24 14:28:36 -05:00
Deluan Quintão
2bb13e5ff1 feat(server): add ExtAuth logout URL configuration (#5074)
* feat(server): add ExtAuth logout URL configuration (#4467)

When external authentication (reverse proxy auth) is active, the Logout
button is hidden because authentication is managed externally. Many
external auth services (Authelia, Authentik, Keycloak) provide a logout
URL that can terminate the session.

Add `ExtAuth.LogoutURL` config option that, when set, shows the Logout
button in the UI and redirects the user to the external auth provider's
logout endpoint instead of the Navidrome login page.

* feat(server): add validation for ExtAuth logout URL configuration

* feat(server): refactor ExtAuth logout URL validation to a reusable function

* fix(configuration): rename URL validation functions for consistency

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(configuration): rename URL validation functions for consistency

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-23 20:28:38 -05:00
dependabot[bot]
d1c5e6a2f2 chore(deps): bump goreleaser/goreleaser-action in /.github/workflows (#5089)
Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 6 to 7.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](https://github.com/goreleaser/goreleaser-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 19:06:45 -05:00
Deluan
0c3cc86535 fix(subsonic): restore public attribute for playlists in XML responses
This was causing issues with DSub and DSub2000

Signed-off-by: Deluan <deluan@navidrome.org>
2026-02-23 18:17:44 -05:00
29 changed files with 1338 additions and 50 deletions

View File

@@ -4,7 +4,7 @@
"dockerfile": "Dockerfile",
"args": {
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
"VARIANT": "1.25",
"VARIANT": "1.26",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v24",

View File

@@ -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 }}"

View File

@@ -63,7 +63,7 @@ COPY --from=ui /build /build
########################################################################################################################
### Build Navidrome binary
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-trixie AS base
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base
RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / /
WORKDIR /workspace

View File

@@ -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", "")

View File

@@ -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))

View File

@@ -8,4 +8,6 @@ var SetViperDefaults = setViperDefaults
var ParseLanguages = parseLanguages
var ValidateURL = validateURL
var NormalizeSearchBackend = normalizeSearchBackend

9
go.mod
View File

@@ -1,13 +1,13 @@
module github.com/navidrome/navidrome
go 1.25
go 1.26
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

36
go.sum
View File

@@ -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=

View File

@@ -1,6 +1,6 @@
module github.com/navidrome/navidrome/plugins/cmd/ndpgen
go 1.25
go 1.26
require (
github.com/extism/go-pdk v1.1.3

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
}

190
plugins/host_httpclient.go Normal file
View File

@@ -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)

View File

@@ -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())
})
})

View File

@@ -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
}

View File

@@ -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())
})

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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.

View File

@@ -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<String, String>,
#[serde(default)]
pub body: Vec<u8>,
#[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<String, String>,
#[serde(default)]
pub body: Vec<u8>,
}
#[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<HttpResponse>,
#[serde(default)]
error: Option<String>,
}
#[host_fn]
extern "ExtismHost" {
fn http_send(input: Json<HTTPSendRequest>) -> Json<HTTPSendResponse>;
}
/// 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<Option<HttpResponse>, 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)
}

View File

@@ -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)

View File

@@ -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",

View File

@@ -1,7 +1,7 @@
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
<playlists>
<playlist id="111" name="aaa" comment="comment" songCount="2" duration="120" public="true" owner="admin" created="2023-02-20T14:45:00Z" changed="2023-02-20T14:45:00Z" coverArt="pl-123123123123" readonly="true" validUntil="2023-02-20T14:45:00Z"></playlist>
<playlist id="333" name="ccc" songCount="0" duration="0" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
<playlist id="222" name="bbb" songCount="0" duration="0" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
<playlist id="333" name="ccc" songCount="0" duration="0" public="false" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
<playlist id="222" name="bbb" songCount="0" duration="0" public="false" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
</playlists>
</subsonic-response>

View File

@@ -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"`

View File

@@ -66,6 +66,10 @@ const authProvider = {
logout: () => {
removeItems()
if (config.extAuthLogoutURL) {
window.location.href = config.extAuthLogoutURL
return Promise.resolve(false)
}
return Promise.resolve()
},

View File

@@ -122,7 +122,7 @@ const UserMenu = (props) => {
})
: null,
)}
{!config.auth && logout}
{(!config.auth || !!config.extAuthLogoutURL) && logout}
</MenuList>
</Popover>
</div>