diff --git a/core/share.go b/core/share.go index 5ee1a5667..a291a060c 100644 --- a/core/share.go +++ b/core/share.go @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/nanoid" "github.com/navidrome/navidrome/utils/slice" @@ -91,6 +92,11 @@ func (r *shareRepositoryWrapper) newId() (string, error) { func (r *shareRepositoryWrapper) Save(entity any) (string, error) { s := entity.(*model.Share) + // Owner is always the caller; never trust a client-supplied UserID, as it + // determines the library-access context used to resolve the share contents. + if user, ok := request.UserFrom(r.ctx); ok { + s.UserID = user.ID + } id, err := r.newId() if err != nil { return "", err diff --git a/core/share_test.go b/core/share_test.go index a5ee0428b..e6dec1b92 100644 --- a/core/share_test.go +++ b/core/share_test.go @@ -5,6 +5,7 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -39,6 +40,15 @@ var _ = Describe("Share", func() { Expect(entity.ID).To(Equal(id)) }) + It("assigns the logged-in user as owner, ignoring a client-supplied UserID", func() { + loggedInCtx := request.WithUser(context.Background(), model.User{ID: "logged-in-user"}) + repo := share.NewRepository(loggedInCtx).(rest.Persistable) + entity := &model.Share{Description: "test", ResourceIDs: "123", UserID: "victim-user"} + _, err := repo.Save(entity) + Expect(err).ToNot(HaveOccurred()) + Expect(entity.UserID).To(Equal("logged-in-user")) + }) + It("does not truncate ASCII labels shorter than 30 characters", func() { _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"}) entity := &model.Share{Description: "test", ResourceIDs: "456"} diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 92c696688..bcf33790d 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -170,8 +170,10 @@ func (r *shareRepository) Update(id string, entity any, cols ...string) error { func (r *shareRepository) Save(entity any) (string, error) { s := entity.(*model.Share) // TODO Validate record + // Owner is server-managed: for an authenticated request, never trust a + // client-supplied UserID, as it drives the share's library-access context. u := loggedUser(r.ctx) - if s.UserID == "" { + if u.ID != invalidUserId || s.UserID == "" { s.UserID = u.ID } s.CreatedAt = time.Now() diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 02d8a7386..af8cf2ae9 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -393,6 +393,28 @@ var _ = Describe("ShareRepository", func() { }) }) + Describe("Save", func() { + It("assigns the logged-in user as owner, ignoring a client-supplied UserID", func() { + ur := NewUserRepository(ctx, GetDBXBuilder()) + Expect(ur.Put(&ownerUser)).To(Succeed()) + Expect(ur.Put(&otherUser)).To(Succeed()) + + attackerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) + attackerRepo := NewShareRepository(attackerCtx, GetDBXBuilder()).(rest.Persistable) + + id, err := attackerRepo.Save(&model.Share{ + ID: "spoof-save-share", UserID: otherUser.ID, + ResourceType: "media_file", ResourceIDs: "1001", + }) + Expect(err).ToNot(HaveOccurred()) + + adminRepo := NewShareRepository(request.WithUser(log.NewContext(GinkgoT().Context()), adminUser), GetDBXBuilder()) + got, err := adminRepo.Get(id) + Expect(err).ToNot(HaveOccurred()) + Expect(got.UserID).To(Equal(ownerUser.ID)) + }) + }) + Describe("Update", func() { It("allows a non-admin user to update their own share", func() { insertShare("own-share-upd", ownerUser.ID) diff --git a/plugins/README.md b/plugins/README.md index 7042b8c45..10eb34fbc 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -401,7 +401,7 @@ import "github.com/navidrome/navidrome/plugins/pdk/go/host" ### HTTP -Make HTTP requests to external services. This is a dedicated host service (separate from Extism's built-in HTTP support) with additional features like timeouts and redirect control. +Make HTTP requests to external services, with timeouts, redirect control, and protection against reaching private network addresses. This is the only supported way to make HTTP requests: Extism's built-in HTTP (`pdk.NewHTTPRequest`) is disabled. **Manifest permission:** diff --git a/plugins/examples/coverartarchive-py/Makefile b/plugins/examples/coverartarchive-py/Makefile index e3cd60d1c..c47c242d7 100644 --- a/plugins/examples/coverartarchive-py/Makefile +++ b/plugins/examples/coverartarchive-py/Makefile @@ -1,5 +1,5 @@ # Build the Cover Art Archive Python plugin -.PHONY: build test clean +.PHONY: build clean WASM_FILE = coverartarchive-py.wasm @@ -8,20 +8,5 @@ build: $(WASM_FILE) $(WASM_FILE): plugin/__init__.py extism-py plugin/__init__.py -o $(WASM_FILE) -test: build - @echo "Testing nd_manifest..." - extism call $(WASM_FILE) nd_manifest --wasi - @echo "" - @echo "Testing nd_get_album_images with Portishead's Dummy MBID..." - extism call $(WASM_FILE) nd_get_album_images --wasi \ - --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \ - --allow-host "coverartarchive.org" --allow-host "archive.org" - -test-error: build - @echo "Testing error case (missing MBID)..." - -extism call $(WASM_FILE) nd_get_album_images --wasi \ - --input '{"name":"Test Album","artist":"Test Artist"}' \ - --allow-host "coverartarchive.org" - clean: rm -f $(WASM_FILE) diff --git a/plugins/examples/coverartarchive-py/README.md b/plugins/examples/coverartarchive-py/README.md index 77957ac4f..c779f4a43 100644 --- a/plugins/examples/coverartarchive-py/README.md +++ b/plugins/examples/coverartarchive-py/README.md @@ -51,14 +51,7 @@ zip -j coverartarchive-py.ndp manifest.json plugin.wasm ## Testing -Extract the wasm file and test: - -```bash -unzip -p coverartarchive-py.ndp plugin.wasm > coverartarchive-py.wasm -extism call coverartarchive-py.wasm nd_get_album_images --wasi \ - --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \ - --allow-host "coverartarchive.org" --allow-host "archive.org" -``` +The plugin makes HTTP requests through Navidrome's `http_send` host function, so it only runs inside Navidrome (the `extism` CLI can't provide that function). Install the `.ndp` as described above, then open an album that has a MusicBrainz ID and check the Navidrome logs. ## How It Works diff --git a/plugins/examples/coverartarchive-py/plugin/__init__.py b/plugins/examples/coverartarchive-py/plugin/__init__.py index 3c1d4149e..e6f4c6a07 100644 --- a/plugins/examples/coverartarchive-py/plugin/__init__.py +++ b/plugins/examples/coverartarchive-py/plugin/__init__.py @@ -5,16 +5,25 @@ # # Build with: # extism-py plugin/__init__.py -o coverartarchive-py.wasm -# -# Test with: -# extism call coverartarchive-py.wasm nd_get_album_images --wasi \ -# --input '{"name":"Dummy","artist":"Portishead","mbid":"76df3287-6cda-33eb-8e9a-044b5e15ffdd"}' \ -# --allow-host "coverartarchive.org" --allow-host "archive.org" +import base64 import extism import json +@extism.import_fn("extism:host/user", "http_send") +def http_send(req: dict) -> dict: ... + + +def http_get(url): + """GET url via Navidrome's HTTP host service. Returns (status_code, body_bytes).""" + resp = http_send({"request": {"method": "GET", "url": url}}) + if resp.get("error"): + raise Exception(f"HTTP request failed: {resp['error']}") + result = resp.get("result") or {} + return result.get("statusCode", 0), base64.b64decode(result.get("body") or "") + + @extism.plugin_fn def nd_get_album_images(): """Retrieve album cover images from Cover Art Archive.""" @@ -26,13 +35,13 @@ def nd_get_album_images(): # Query Cover Art Archive API url = f"https://coverartarchive.org/release/{mbid}" - response = extism.Http.request(url, meth="GET") - - if response.status_code != 200: - raise Exception(f"not found: CAA returned status {response.status_code}") - + status, body = http_get(url) + + if status != 200: + raise Exception(f"not found: CAA returned status {status}") + try: - data = json.loads(response.data_str()) + data = json.loads(body) except json.JSONDecodeError: raise Exception("not found: invalid JSON response") diff --git a/plugins/examples/discord-rich-presence-rs/src/rpc.rs b/plugins/examples/discord-rich-presence-rs/src/rpc.rs index 3de9eff63..d90cb4341 100644 --- a/plugins/examples/discord-rich-presence-rs/src/rpc.rs +++ b/plugins/examples/discord-rich-presence-rs/src/rpc.rs @@ -4,7 +4,7 @@ //! presence updates, and heartbeat management. use extism_pdk::*; -use nd_pdk::host::{cache, scheduler, websocket}; +use nd_pdk::host::{cache, http, scheduler, websocket}; use serde::{Deserialize, Serialize}; // ============================================================================ @@ -359,19 +359,33 @@ fn find_username_for_connection(connection_id: &str) -> Result, E Ok(cache::get_string(&reverse_key)?.filter(|s| !s.is_empty())) } -fn get_discord_gateway() -> Result { - let req = HttpRequest::new("https://discord.com/api/gateway") - .with_method("GET"); +fn send_http( + method: &str, + url: &str, + headers: std::collections::HashMap, + body: Vec, +) -> Result { + http::send(http::HTTPRequest { + method: method.into(), + url: url.into(), + headers, + no_follow_redirects: false, + body, + timeout_ms: 0, + })? + .ok_or_else(|| Error::msg("empty HTTP response")) +} - let resp = http::request::(&req, None::)?; - if resp.status_code() >= 400 { +fn get_discord_gateway() -> Result { + let resp = send_http("GET", "https://discord.com/api/gateway", Default::default(), Vec::new())?; + if resp.status_code >= 400 { return Err(Error::msg(format!( "Failed to get Discord gateway: HTTP {}", - resp.status_code() + resp.status_code ))); } - let body = resp.body(); + let body = resp.body; let data: std::collections::HashMap = serde_json::from_slice(&body) .map_err(|e| Error::msg(format!("Failed to parse gateway response: {}", e)))?; @@ -487,23 +501,22 @@ fn process_image_inner( client_id ); - let req = HttpRequest::new(&api_url) - .with_method("POST") - .with_header("Authorization", token) - .with_header("Content-Type", "application/json"); - - let resp = http::request::(&req, Some(body))?; - if resp.status_code() >= 400 { + let headers = std::collections::HashMap::from([ + ("Authorization".to_string(), token.to_string()), + ("Content-Type".to_string(), "application/json".to_string()), + ]); + let resp = send_http("POST", &api_url, headers, body.into_bytes())?; + if resp.status_code >= 400 { if is_default { return Err(Error::msg(format!( "failed to process default image: HTTP {}", - resp.status_code() + resp.status_code ))); } return process_image_inner(DEFAULT_IMAGE, client_id, token, true); } - let body = resp.body(); + let body = resp.body; let data: Vec> = serde_json::from_slice(&body) .map_err(|e| Error::msg(format!("Failed to parse image response: {}", e)))?; diff --git a/plugins/examples/webhook-rs/src/lib.rs b/plugins/examples/webhook-rs/src/lib.rs index 743c03744..794cda59c 100644 --- a/plugins/examples/webhook-rs/src/lib.rs +++ b/plugins/examples/webhook-rs/src/lib.rs @@ -12,7 +12,8 @@ //! urls = "https://example.com/webhook1,https://example.com/webhook2" //! ``` -use extism_pdk::{config, error, http, info, warn, HttpRequest}; +use extism_pdk::{config, error, info, warn}; +use nd_pdk::host::http::{self, HTTPRequest}; use nd_pdk::scrobbler::{ Error, IsAuthorizedRequest, NowPlayingRequest, PlaybackReportRequest, ScrobbleRequest, Scrobbler, @@ -90,11 +91,18 @@ impl Scrobbler for WebhookPlugin { let full_url = format!("{}{}", url, query); info!("Sending webhook to: {}", full_url); - let http_req = HttpRequest::new(&full_url); - match http::request::<()>(&http_req, None) { + let http_req = HTTPRequest { + method: "GET".into(), + url: full_url, + headers: Default::default(), + no_follow_redirects: false, + body: Vec::new(), + timeout_ms: 0, + }; + match http::send(http_req) { Ok(res) => { - let status = res.status_code(); - if status >= 200 && status < 300 { + let status = res.map_or(0, |r| r.status_code); + if (200..300).contains(&status) { info!("Webhook succeeded: {} (status {})", url, status); } else { warn!("Webhook returned non-2xx status: {} (status {})", url, status); diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go index d52898bdd..749ba096e 100644 --- a/plugins/host_httpclient.go +++ b/plugins/host_httpclient.go @@ -9,7 +9,9 @@ import ( "net" "net/http" "net/url" + "slices" "strings" + "syscall" "time" "github.com/navidrome/navidrome/log" @@ -34,6 +36,7 @@ type httpServiceImpl struct { pluginName string requiredHosts []string client *http.Client + transport *http.Transport } // newHTTPService creates a new HTTPService for a plugin. @@ -46,8 +49,14 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI 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 = httpclient.New(0) + 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 @@ -65,6 +74,12 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI 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) @@ -159,11 +174,49 @@ func (s *httpServiceImpl) validateHost(ctx context.Context, hostStr string) erro 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 } @@ -182,11 +235,8 @@ func extractHostname(hostStr string) string { 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. +// 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 @@ -195,7 +245,11 @@ func isPrivateOrLoopback(hostname string) bool { if ip == nil { return false } - return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() + return isPrivateIP(ip) +} + +func isPrivateIP(ip net.IP) bool { + return ip.IsLoopback() || ip.IsUnspecified() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() } // Verify interface implementation diff --git a/plugins/host_httpclient_test.go b/plugins/host_httpclient_test.go index 4eb34a247..6edf75a97 100644 --- a/plugins/host_httpclient_test.go +++ b/plugins/host_httpclient_test.go @@ -3,6 +3,7 @@ package plugins import ( "context" "io" + "net" "net/http" "net/http/httptest" "strings" @@ -43,6 +44,21 @@ var _ = Describe("httpServiceImpl", func() { Expect(err.Error()).To(ContainSubstring("private/loopback")) }) + 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")) + }) + It("should block requests to localhost by name", func() { _, err := svc.Send(context.Background(), host.HTTPRequest{ Method: "GET", @@ -419,13 +435,53 @@ var _ = Describe("httpServiceImpl", func() { Expect(resp).To(BeNil()) }) + 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()) + }) + It("should allow wildcard host patterns", func() { ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("wildcard")) })) - // *.allowed.org is in the requiredHosts from BeforeEach, but test server is 127.0.0.1 - // Override with a wildcard that matches the test server - svc.requiredHosts = []string{"*.0.0.1"} + // The literal IP is what authorizes the loopback dial under the private-IP guard. + svc.requiredHosts = []string{"*.0.0.1", "127.0.0.1"} resp, err := svc.Send(context.Background(), host.HTTPRequest{ Method: "GET", URL: ts.URL, @@ -566,6 +622,11 @@ var _ = Describe("isPrivateOrLoopback", func() { Expect(isPrivateOrLoopback("fe80::1")).To(BeTrue()) }) + It("should detect unspecified addresses, which dial the local host", func() { + Expect(isPrivateOrLoopback("0.0.0.0")).To(BeTrue()) + Expect(isPrivateOrLoopback("::")).To(BeTrue()) + }) + It("should allow public IPs", func() { Expect(isPrivateOrLoopback("8.8.8.8")).To(BeFalse()) Expect(isPrivateOrLoopback("203.0.113.1")).To(BeFalse()) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 46da56396..024b14b72 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -148,7 +148,7 @@ var hostServices = []hostServiceEntry{ create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { perm := ctx.permissions.Http service := newHTTPService(ctx.pluginName, perm) - return host.RegisterHTTPHostFunctions(service), nil, nil + return host.RegisterHTTPHostFunctions(service), service, nil }, }, { @@ -498,18 +498,12 @@ func parsePluginConfig(configJSON string) (map[string]string, error) { return pluginConfig, nil } -// buildExtismManifest describes the plugin to extism. It must never set -// AllowedPaths: extism would replace our jailed FSConfig with plain dir mounts. +// buildExtismManifest describes the plugin to extism. It must never set AllowedPaths (extism would replace our +// jailed FSConfig) nor AllowedHosts (extism's http_request has no SSRF guard; plugins must use host.HTTPSend). func buildExtismManifest(pkg *ndpPackage, pluginConfig map[string]string) extism.Manifest { - manifest := extism.Manifest{ + return extism.Manifest{ Wasm: []extism.Wasm{extism.WasmData{Data: pkg.WasmBytes, Name: "main"}}, Config: pluginConfig, Timeout: uint64(defaultTimeout.Milliseconds()), } - if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Http != nil { - if hosts := pkg.Manifest.Permissions.Http.RequiredHosts; len(hosts) > 0 { - manifest.AllowedHosts = hosts - } - } - return manifest } diff --git a/plugins/manager_loader_test.go b/plugins/manager_loader_test.go index 6326b2218..85a636202 100644 --- a/plugins/manager_loader_test.go +++ b/plugins/manager_loader_test.go @@ -23,8 +23,8 @@ var _ = Describe("buildExtismManifest", func() { Expect(buildExtismManifest(pkg, nil).AllowedPaths).To(BeEmpty()) }) - It("carries the hosts the plugin is allowed to reach", func() { - Expect(buildExtismManifest(pkg, nil).AllowedHosts).To(Equal([]string{"example.com"})) + It("never sets AllowedHosts, so plugin HTTP can't bypass the host service's SSRF guard", func() { + Expect(buildExtismManifest(pkg, nil).AllowedHosts).To(BeEmpty()) }) })