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>
This commit is contained in:
Deluan Quintão
2026-02-24 14:28:36 -05:00
committed by GitHub
parent 2bb13e5ff1
commit 652c27690b
14 changed files with 1228 additions and 19 deletions
+33 -18
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