mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-14 05:17:13 +02:00
* fix(share): always assign the authenticated user as share owner A share's UserID was taken from the request body and only defaulted when empty, so any authenticated user could create a share attributed to another user. For playlist shares the contents are resolved in the owner's library-access context, turning the spoofed owner into an access-escalation vector in multi-library setups. Force the owner from the request context at both the service boundary and the persistence layer, ignoring any client-supplied UserID. * fix(plugins): block SSRF to private IPs resolved from hostnames The HTTP host client only checked the literal host string, so a symbolic hostname (or a trailing-dot "localhost.") resolving to a private/loopback address bypassed the SSRF guard when a plugin declared no requiredHosts. Enforce the check at dial time via net.Dialer.Control on the resolved IP, which also covers redirect hops and DNS rebinding. When an explicit requiredHosts allowlist is set, defer to it as the operator's trust decision. * fix(plugins): gate private IPs on explicit IP/CIDR allowlist entries Following review feedback: an allowlisted hostname authorizes the external service, not whatever private IP it may resolve or rebind to. Enforce the resolved-IP guard even when requiredHosts is set, permitting a private address only when a literal IP or CIDR entry explicitly covers it. This keeps "reach this external API" and "reach my internal network" as two separate, explicit operator decisions. * fix(plugins): treat unspecified addresses as private in the SSRF guard Dialing 0.0.0.0 or :: reaches the local host, so they bypassed the private/loopback check. * fix(plugins): let a bare "*" allowlist reach private addresses Plugins such as AudioMuse-AI declare requiredHosts ["*"] to reach a user-configured service on the LAN, whose address the manifest cannot know. Requiring a literal IP/CIDR entry broke them. Named hosts and subdomain wildcards still cannot resolve to private addresses. * refactor(plugins): simplify the SSRF-guarded HTTP client and release its pool Build the client directly around the guarded transport instead of replacing a throwaway one, fail closed on an unparseable dial address, and close the per-plugin transport's idle connections when the plugin unloads. Trim stale comments. * fix(plugins): stop enabling extism's unguarded http_request host function Passing requiredHosts as the extism manifest's AllowedHosts enabled extism's own http_request (pdk.NewHTTPRequest), which only glob-matches the hostname and follows redirects without re-checking, bypassing the resolved-IP SSRF guard. Plugins must use host.HTTPSend. * fix(plugins): move bundled Rust examples to the host HTTP service Extism's built-in http_request is now disabled, so the webhook and Discord examples switch to nd_pdk::host::http::send. Update the README to say host.HTTPSend is the only supported way to make HTTP requests. * fix(plugins): move the Python example to the host HTTP service coverartarchive-py used extism's built-in Http.request, which is now disabled. Call Navidrome's http_send host function instead. The plugin can no longer run under the standalone extism CLI, so drop the CLI test targets and instructions.
256 lines
7.7 KiB
Go
256 lines
7.7 KiB
Go
package plugins
|
|
|
|
import (
|
|
"bytes"
|
|
"cmp"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/plugins/host"
|
|
"github.com/navidrome/navidrome/utils/httpclient"
|
|
)
|
|
|
|
const (
|
|
httpClientDefaultTimeout = 10 * time.Second
|
|
httpClientMaxRedirects = 5
|
|
httpClientMaxResponseBodyLen = 10 * 1024 * 1024 // 10 MB
|
|
)
|
|
|
|
// contextKey is used for per-request redirect control via context.
|
|
type contextKey struct{}
|
|
|
|
// noFollowRedirectsKey signals the CheckRedirect callback to stop following redirects.
|
|
var noFollowRedirectsKey = contextKey{}
|
|
|
|
// httpServiceImpl implements host.HTTPService.
|
|
type httpServiceImpl struct {
|
|
pluginName string
|
|
requiredHosts []string
|
|
client *http.Client
|
|
transport *http.Transport
|
|
}
|
|
|
|
// 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.transport = http.DefaultTransport.(*http.Transport).Clone()
|
|
svc.transport.DialContext = (&net.Dialer{
|
|
Timeout: 30 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
Control: svc.dialControl,
|
|
}).DialContext
|
|
// No client timeout: it is set per-request via context deadline.
|
|
svc.client = &http.Client{Transport: httpclient.NewTransport(svc.transport)}
|
|
svc.client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
|
if req.Context().Value(noFollowRedirectsKey) != nil {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
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
|
|
}
|
|
|
|
// Close releases the plugin's pooled connections when the plugin is unloaded.
|
|
func (s *httpServiceImpl) Close() error {
|
|
s.transport.CloseIdleConnections()
|
|
return nil
|
|
}
|
|
|
|
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()
|
|
|
|
// Signal CheckRedirect to not follow redirects for this request
|
|
if request.NoFollowRedirects {
|
|
ctx = context.WithValue(ctx, noFollowRedirectsKey, true)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// dialControl checks the resolved IP, so hostnames can't reach private addresses unless a literal
|
|
// IP/CIDR entry or a bare "*" (plugins targeting user-configured LAN services) allows it.
|
|
func (s *httpServiceImpl) dialControl(_, address string, _ syscall.RawConn) error {
|
|
if slices.Contains(s.requiredHosts, "*") {
|
|
return nil
|
|
}
|
|
host, _, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ip := net.ParseIP(host)
|
|
if ip == nil || !isPrivateIP(ip) {
|
|
return nil
|
|
}
|
|
for _, entry := range s.requiredHosts {
|
|
if ipMatchesEntry(entry, ip) {
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("dial to private/loopback address %q blocked: requires an explicit IP or CIDR in requiredHosts", address)
|
|
}
|
|
|
|
// ipMatchesEntry reports whether a requiredHosts entry is a literal IP or CIDR
|
|
// that covers ip. Hostname and wildcard entries never match.
|
|
func ipMatchesEntry(entry string, ip net.IP) bool {
|
|
if _, cidr, err := net.ParseCIDR(entry); err == nil {
|
|
return cidr.Contains(ip)
|
|
}
|
|
if entryIP := net.ParseIP(entry); entryIP != nil {
|
|
return entryIP.Equal(ip)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *httpServiceImpl) isHostAllowed(hostname string) bool {
|
|
ip := net.ParseIP(hostname)
|
|
for _, pattern := range s.requiredHosts {
|
|
if matchHostPattern(pattern, hostname) {
|
|
return true
|
|
}
|
|
if ip != nil && ipMatchesEntry(pattern, ip) {
|
|
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 is a pre-flight check on the literal host (IP or "localhost"); it does not
|
|
// resolve names, so dialControl remains the real guard.
|
|
func isPrivateOrLoopback(hostname string) bool {
|
|
if strings.EqualFold(hostname, "localhost") {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(hostname)
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
return isPrivateIP(ip)
|
|
}
|
|
|
|
func isPrivateIP(ip net.IP) bool {
|
|
return ip.IsLoopback() || ip.IsUnspecified() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()
|
|
}
|
|
|
|
// Verify interface implementation
|
|
var _ host.HTTPService = (*httpServiceImpl)(nil)
|