2026-02-24 14:28:36 -05:00
|
|
|
package plugins
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"io"
|
Merge commit from fork
* 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.
2026-09-12 13:38:08 -04:00
|
|
|
"net"
|
2026-02-24 14:28:36 -05:00
|
|
|
"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
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-23 18:42:11 -04:00
|
|
|
BeforeEach(func() {
|
|
|
|
|
stubLocalhostDNS()
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-24 14:28:36 -05:00
|
|
|
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"))
|
|
|
|
|
})
|
|
|
|
|
|
Merge commit from fork
* 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.
2026-09-12 13:38:08 -04:00
|
|
|
It("should block a symbolic hostname that resolves to loopback (SSRF)", func() {
|
|
|
|
|
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.WriteHeader(200)
|
|
|
|
|
}))
|
|
|
|
|
// The trailing dot passes the pre-flight string check; only the dial-time guard catches it.
|
|
|
|
|
_, port, _ := net.SplitHostPort(strings.TrimPrefix(ts.URL, "http://"))
|
|
|
|
|
_, err := svc.Send(context.Background(), host.HTTPRequest{
|
|
|
|
|
Method: "GET",
|
|
|
|
|
URL: "http://localhost.:" + port + "/test",
|
|
|
|
|
TimeoutMs: 1000,
|
|
|
|
|
})
|
|
|
|
|
Expect(err).To(HaveOccurred())
|
|
|
|
|
Expect(err.Error()).To(ContainSubstring("private/loopback"))
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-24 14:28:36 -05:00
|
|
|
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"))
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-20 18:16:07 -04:00
|
|
|
It("should not follow redirects when NoFollowRedirects is true", func() {
|
|
|
|
|
dest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
_, _ = w.Write([]byte("final"))
|
|
|
|
|
}))
|
|
|
|
|
defer dest.Close()
|
|
|
|
|
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
http.Redirect(w, r, dest.URL, http.StatusFound)
|
|
|
|
|
}))
|
|
|
|
|
resp, err := svc.Send(context.Background(), host.HTTPRequest{
|
|
|
|
|
Method: "GET",
|
|
|
|
|
URL: ts.URL,
|
|
|
|
|
TimeoutMs: 1000,
|
|
|
|
|
NoFollowRedirects: true,
|
|
|
|
|
})
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(resp.StatusCode).To(Equal(int32(302)))
|
|
|
|
|
Expect(resp.Headers["Location"]).To(Equal(dest.URL))
|
|
|
|
|
Expect(string(resp.Body)).ToNot(Equal("final"))
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-24 14:28:36 -05:00
|
|
|
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())
|
|
|
|
|
})
|
|
|
|
|
|
Merge commit from fork
* 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.
2026-09-12 13:38:08 -04:00
|
|
|
It("blocks a private IP reached via a hostname allowlist entry (rebinding protection)", func() {
|
|
|
|
|
// Allowlisting a name authorizes the external service, not whatever private
|
|
|
|
|
// IP it may resolve or rebind to. Only literal IP/CIDR entries do that.
|
|
|
|
|
svc.requiredHosts = []string{"api.example.com"}
|
|
|
|
|
err := svc.dialControl("tcp", "10.0.0.1:80", nil)
|
|
|
|
|
Expect(err).To(HaveOccurred())
|
|
|
|
|
Expect(err.Error()).To(ContainSubstring("private/loopback"))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("allows a private IP that an explicit CIDR allowlist entry authorizes", func() {
|
|
|
|
|
svc.requiredHosts = []string{"10.0.0.0/8"}
|
|
|
|
|
Expect(svc.dialControl("tcp", "10.0.0.1:80", nil)).To(Succeed())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("allows private IPs when the allowlist is the bare '*' wildcard", func() {
|
|
|
|
|
svc.requiredHosts = []string{"*"}
|
|
|
|
|
Expect(svc.dialControl("tcp", "192.168.1.10:8000", nil)).To(Succeed())
|
|
|
|
|
Expect(svc.dialControl("tcp", "127.0.0.1:8000", nil)).To(Succeed())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("still blocks private IPs for a subdomain wildcard entry", func() {
|
|
|
|
|
svc.requiredHosts = []string{"*.example.com"}
|
|
|
|
|
Expect(svc.dialControl("tcp", "10.0.0.1:80", nil)).To(MatchError(ContainSubstring("private/loopback")))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
It("closes idle pooled connections on Close", func() {
|
|
|
|
|
closed := make(chan struct{})
|
|
|
|
|
ts = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
|
|
|
ts.Config.ConnState = func(_ net.Conn, state http.ConnState) {
|
|
|
|
|
if state == http.StateClosed {
|
|
|
|
|
close(closed)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ts.Start()
|
|
|
|
|
svc.requiredHosts = []string{"127.0.0.1"}
|
|
|
|
|
_, err := svc.Send(context.Background(), host.HTTPRequest{Method: "GET", URL: ts.URL, TimeoutMs: 1000})
|
|
|
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
|
Expect(svc.Close()).To(Succeed())
|
|
|
|
|
Eventually(closed).Should(BeClosed())
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-24 14:28:36 -05:00
|
|
|
It("should allow wildcard host patterns", func() {
|
|
|
|
|
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
_, _ = w.Write([]byte("wildcard"))
|
|
|
|
|
}))
|
Merge commit from fork
* 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.
2026-09-12 13:38:08 -04:00
|
|
|
// The literal IP is what authorizes the loopback dial under the private-IP guard.
|
|
|
|
|
svc.requiredHosts = []string{"*.0.0.1", "127.0.0.1"}
|
2026-02-24 14:28:36 -05:00
|
|
|
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())
|
|
|
|
|
})
|
|
|
|
|
|
Merge commit from fork
* 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.
2026-09-12 13:38:08 -04:00
|
|
|
It("should detect unspecified addresses, which dial the local host", func() {
|
|
|
|
|
Expect(isPrivateOrLoopback("0.0.0.0")).To(BeTrue())
|
|
|
|
|
Expect(isPrivateOrLoopback("::")).To(BeTrue())
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-24 14:28:36 -05:00
|
|
|
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())
|
|
|
|
|
})
|
|
|
|
|
})
|