navidrome/plugins/host_netguard.go

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

58 lines
1.5 KiB
Go
Raw Permalink Normal View History

package plugins
import (
"fmt"
"net"
"slices"
fix(artwork): block private and loopback addresses in remote image fetches (#6181) * fix(artwork): block private and loopback addresses in remote image fetches fromURL fetched any URL with a plain HTTP client, and two untrusted inputs reach it. A playlist can set #EXTALBUMARTURL to an http(s) URL, which the artwork worker later fetches when EnableM3UExternalAlbumArt is on, so any user who can import a playlist controls the target. Metadata agents, including WASM plugins without the http permission, return image URLs that the core fetches too. Either path could make the server request loopback, LAN or link-local addresses and store the response as artwork that is served back. Add httpclient.NewExternal, which dials through a net.Dialer Control hook that rejects private, loopback, link-local and unspecified addresses. The check runs at dial time on the resolved IP, so DNS names, redirects and DNS rebinding are covered. fromURL now uses one shared client built with it and treats a refused address as a definitive miss, so the item settles absent instead of retrying and tripping the agent's circuit breaker. httpclient.New is unchanged for the other callers. The IP classification moves from plugins to the new utils/netguard package, shared by the plugin host client and the new constructor. The artwork test suite swaps in a client that allows loopback so existing specs can keep using httptest servers; the fromURL specs use the production client to assert the refusal. * fix(httpclient): keep dialing a configured proxy in the guarded client The guard runs on the resolved address, and with HTTP_PROXY set that address is the proxy, not the image host. A proxy on a private address would have had every remote artwork fetch refused, and a refusal settles the item as absent, so covers would silently disappear for those setups. Dial the configured proxy endpoint directly and keep the guard for every other dial. A proxy relays the request itself, so it is the operator's egress policy, the same one every other httpclient.New caller already goes through. * fix(httpclient): exempt only the hop that actually goes through the proxy The exemption matched any dial to a configured proxy's address, but net/http never proxies loopback targets, so a URL aimed at a loopback proxy was dialed directly and skipped the guard. That let an image URL reach that one address. Tag each request with the proxy it resolves to and exempt a dial only when it is that hop. Redirects re-enter the RoundTripper, so every hop is tagged on its own.
2026-09-20 20:46:46 -04:00
"github.com/navidrome/navidrome/utils/netguard"
)
// dialResolver is nil in production (the system resolver); tests swap in a stub to avoid real DNS.
var dialResolver *net.Resolver
// checkPrivateDial runs at dial time on 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 checkPrivateDial(requiredHosts []string, address string) error {
if slices.Contains(requiredHosts, "*") {
return nil
}
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
fix(artwork): block private and loopback addresses in remote image fetches (#6181) * fix(artwork): block private and loopback addresses in remote image fetches fromURL fetched any URL with a plain HTTP client, and two untrusted inputs reach it. A playlist can set #EXTALBUMARTURL to an http(s) URL, which the artwork worker later fetches when EnableM3UExternalAlbumArt is on, so any user who can import a playlist controls the target. Metadata agents, including WASM plugins without the http permission, return image URLs that the core fetches too. Either path could make the server request loopback, LAN or link-local addresses and store the response as artwork that is served back. Add httpclient.NewExternal, which dials through a net.Dialer Control hook that rejects private, loopback, link-local and unspecified addresses. The check runs at dial time on the resolved IP, so DNS names, redirects and DNS rebinding are covered. fromURL now uses one shared client built with it and treats a refused address as a definitive miss, so the item settles absent instead of retrying and tripping the agent's circuit breaker. httpclient.New is unchanged for the other callers. The IP classification moves from plugins to the new utils/netguard package, shared by the plugin host client and the new constructor. The artwork test suite swaps in a client that allows loopback so existing specs can keep using httptest servers; the fromURL specs use the production client to assert the refusal. * fix(httpclient): keep dialing a configured proxy in the guarded client The guard runs on the resolved address, and with HTTP_PROXY set that address is the proxy, not the image host. A proxy on a private address would have had every remote artwork fetch refused, and a refusal settles the item as absent, so covers would silently disappear for those setups. Dial the configured proxy endpoint directly and keep the guard for every other dial. A proxy relays the request itself, so it is the operator's egress policy, the same one every other httpclient.New caller already goes through. * fix(httpclient): exempt only the hop that actually goes through the proxy The exemption matched any dial to a configured proxy's address, but net/http never proxies loopback targets, so a URL aimed at a loopback proxy was dialed directly and skipped the guard. That let an image URL reach that one address. Tag each request with the proxy it resolves to and exempt a dial only when it is that hop. Redirects re-enter the RoundTripper, so every hop is tagged on its own.
2026-09-20 20:46:46 -04:00
if ip == nil || !netguard.IsPrivateIP(ip) {
return nil
}
for _, entry := range 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)
}
func isHostInAllowlist(requiredHosts []string, hostname string) bool {
ip := net.ParseIP(hostname)
for _, pattern := range requiredHosts {
if matchHostPattern(pattern, hostname) {
return true
}
if ip != nil && ipMatchesEntry(pattern, ip) {
return true
}
}
return false
}
chore(plugins): document requiredHosts rules and deprecate pdk.NewHTTPRequest (#6129) * fix(plugins): align the Python HTTP example with the repo's host-call pattern Bind http_send with raw memory offsets like nowplaying-py does, drop guards for fields the host always sends, and document how plugins without a PDK call host services and which built-in HTTP APIs are disabled. * docs(plugins): document the private-address rules for HTTP requiredHosts Explain in the README and manifest schema that named hosts can't reach private addresses while IP/CIDR entries and a bare "*" can. * docs(plugins): document the private-address rules for requiredHosts Explain in the README and manifest schema that named hosts can't reach private addresses while IP/CIDR entries and a bare "*" can, for both HTTP and WebSocket. Inline the single-use HTTP isHostAllowed wrapper. * feat(plugins): derive Default for Rust host service structs The ndpgen client.rs template now adds Default to the derive list of host service structs, as the capability and shared types templates already do. Plugin authors can now set only the fields they need, for example HTTPRequest { method, url, ..Default::default() }. The webhook-rs and discord-rich-presence-rs examples use this form now. The golden files and the generated nd-pdk-host crate are updated to match. * feat(plugins): deprecate pdk.NewHTTPRequest in the Go PDK Navidrome no longer enables extism's http_request host function, so a request built with pdk.NewHTTPRequest always fails. ndpgen now reads a small deprecation table and writes a Deprecated: paragraph for the listed extism functions, in both the WASM wrapper and the native stub. Linters and IDEs now point plugin authors to host.HTTPSend. The PDK example tests used to teach NewHTTPRequest. They now use host.HTTPSend and host.HTTPMock. * docs(plugins): correct requiredHosts rules for websocket and private addresses Two statements in the plugin docs did not match the code. The WebSocket section claimed requiredHosts behaves like HTTP. It does not: host_httpclient.go only consults the allowlist when the list is non-empty and otherwise falls back to allowing public addresses, while host_websocket.go always calls isHostInAllowlist, so an absent list blocks every connection. The HTTP section claimed a named host can never reach a private address. checkPrivateDial scans the whole requiredHosts list, so a named host does reach a private address when the same list also holds a covering IP or CIDR. Reworded both, plus the matching requiredHosts descriptions in manifest-schema.json, and regenerated manifest_gen.go.
2026-09-12 13:59:46 -04:00
// ipMatchesEntry reports whether a requiredHosts entry is a literal IP or CIDR that covers ip.
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
}