Files
navidrome/plugins/host_httpclient.go
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

191 lines
5.7 KiB
Go

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)