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>
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/metadata"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
@@ -77,21 +78,28 @@ func sparqlQuery(endpoint, query string) (*SPARQLResult, error) {
|
||||
form := url.Values{}
|
||||
form.Set("query", query)
|
||||
|
||||
req := pdk.NewHTTPRequest(pdk.MethodPost, endpoint)
|
||||
req.SetHeader("Accept", "application/sparql-results+json")
|
||||
req.SetHeader("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetHeader("User-Agent", "NavidromeWikimediaPlugin/1.0")
|
||||
req.SetBody([]byte(form.Encode()))
|
||||
|
||||
pdk.Log(pdk.LogDebug, fmt.Sprintf("SPARQL query to %s: %s", endpoint, query))
|
||||
|
||||
resp := req.Send()
|
||||
if resp.Status() != 200 {
|
||||
return nil, fmt.Errorf("SPARQL HTTP error: status %d", resp.Status())
|
||||
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||
Method: "POST",
|
||||
URL: endpoint,
|
||||
Headers: map[string]string{
|
||||
"Accept": "application/sparql-results+json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "NavidromeWikimediaPlugin/1.0",
|
||||
},
|
||||
Body: []byte(form.Encode()),
|
||||
TimeoutMs: 10000,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SPARQL HTTP error: %w", err)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("SPARQL HTTP error: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result SPARQLResult
|
||||
if err := json.Unmarshal(resp.Body(), &result); err != nil {
|
||||
if err := json.Unmarshal(resp.Body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse SPARQL response: %w", err)
|
||||
}
|
||||
if len(result.Results.Bindings) == 0 {
|
||||
@@ -104,15 +112,22 @@ func sparqlQuery(endpoint, query string) (*SPARQLResult, error) {
|
||||
func mediawikiQuery(params url.Values) ([]byte, error) {
|
||||
apiURL := fmt.Sprintf("%s?%s", mediawikiAPIEndpoint, params.Encode())
|
||||
|
||||
req := pdk.NewHTTPRequest(pdk.MethodGet, apiURL)
|
||||
req.SetHeader("Accept", "application/json")
|
||||
req.SetHeader("User-Agent", "NavidromeWikimediaPlugin/1.0")
|
||||
|
||||
resp := req.Send()
|
||||
if resp.Status() != 200 {
|
||||
return nil, fmt.Errorf("MediaWiki HTTP error: status %d", resp.Status())
|
||||
resp, err := host.HTTPSend(host.HTTPRequest{
|
||||
Method: "GET",
|
||||
URL: apiURL,
|
||||
Headers: map[string]string{
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "NavidromeWikimediaPlugin/1.0",
|
||||
},
|
||||
TimeoutMs: 10000,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MediaWiki HTTP error: %w", err)
|
||||
}
|
||||
return resp.Body(), nil
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("MediaWiki HTTP error: status %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// getWikidataWikipediaURL fetches the Wikipedia URL from Wikidata using MBID or name
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package host
|
||||
|
||||
import "context"
|
||||
|
||||
// HTTPRequest represents an outbound HTTP request from a plugin.
|
||||
type HTTPRequest struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Body []byte `json:"body,omitempty"`
|
||||
TimeoutMs int32 `json:"timeoutMs,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPResponse represents the response from an outbound HTTP request.
|
||||
type HTTPResponse struct {
|
||||
StatusCode int32 `json:"statusCode"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Body []byte `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPService provides outbound HTTP request capabilities for plugins.
|
||||
//
|
||||
// This service allows plugins to make HTTP requests to external services.
|
||||
// Requests are validated against the plugin's declared requiredHosts patterns
|
||||
// from the http permission in the manifest. Redirects are followed but each
|
||||
// redirect destination is also validated against the allowed hosts.
|
||||
//
|
||||
//nd:hostservice name=HTTP permission=http
|
||||
type HTTPService interface {
|
||||
// Send executes an HTTP request and returns the response.
|
||||
//
|
||||
// Parameters:
|
||||
// - request: The HTTP request to execute, including method, URL, headers, body, and timeout
|
||||
//
|
||||
// Returns the HTTP response with status code, headers, and body.
|
||||
// Network errors, timeouts, and permission failures are returned as Go errors.
|
||||
// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error.
|
||||
//nd:hostfunc
|
||||
Send(ctx context.Context, request HTTPRequest) (*HTTPResponse, error)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
|
||||
package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// HTTPSendRequest is the request type for HTTP.Send.
|
||||
type HTTPSendRequest struct {
|
||||
Request HTTPRequest `json:"request"`
|
||||
}
|
||||
|
||||
// HTTPSendResponse is the response type for HTTP.Send.
|
||||
type HTTPSendResponse struct {
|
||||
Result *HTTPResponse `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterHTTPHostFunctions registers HTTP service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterHTTPHostFunctions(service HTTPService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newHTTPSendHostFunction(service),
|
||||
}
|
||||
}
|
||||
|
||||
func newHTTPSendHostFunction(service HTTPService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"http_send",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
httpWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req HTTPSendRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
httpWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
result, svcErr := service.Send(ctx, req.Request)
|
||||
if svcErr != nil {
|
||||
httpWriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := HTTPSendResponse{
|
||||
Result: result,
|
||||
}
|
||||
httpWriteResponse(p, stack, resp)
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
// httpWriteResponse writes a JSON response to plugin memory.
|
||||
func httpWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
||||
respBytes, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
httpWriteError(p, stack, err)
|
||||
return
|
||||
}
|
||||
respPtr, err := p.WriteBytes(respBytes)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
// httpWriteError writes an error response to plugin memory.
|
||||
func httpWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errResp := struct {
|
||||
Error string `json:"error"`
|
||||
}{Error: err.Error()}
|
||||
respBytes, _ := json.Marshal(errResp)
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
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)
|
||||
@@ -0,0 +1,565 @@
|
||||
//go:build !windows
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"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
|
||||
)
|
||||
|
||||
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"))
|
||||
})
|
||||
|
||||
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"))
|
||||
})
|
||||
|
||||
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())
|
||||
})
|
||||
|
||||
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"}
|
||||
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())
|
||||
})
|
||||
|
||||
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())
|
||||
})
|
||||
})
|
||||
@@ -256,8 +256,11 @@ func (s *webSocketServiceImpl) isHostAllowed(host string) bool {
|
||||
}
|
||||
|
||||
// matchHostPattern matches a host against a pattern.
|
||||
// Supports wildcards like *.example.com
|
||||
// Supports "*" (allow all) and wildcards like "*.example.com".
|
||||
func matchHostPattern(pattern, host string) bool {
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
if pattern == host {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -575,6 +575,12 @@ var _ = Describe("WebSocketService", Ordered, func() {
|
||||
Expect(matchHostPattern("*.example.com", "deep.api.example.com")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should match bare '*' as allow-all", func() {
|
||||
Expect(matchHostPattern("*", "anything.example.com")).To(BeTrue())
|
||||
Expect(matchHostPattern("*", "127.0.0.1")).To(BeTrue())
|
||||
Expect(matchHostPattern("*", "::1")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should not match partial patterns", func() {
|
||||
Expect(matchHostPattern("*.example.com", "example.com.evil.org")).To(BeFalse())
|
||||
})
|
||||
|
||||
@@ -119,6 +119,15 @@ var hostServices = []hostServiceEntry{
|
||||
return host.RegisterUsersHostFunctions(service), nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "HTTP",
|
||||
hasPermission: func(p *Permissions) bool { return p != nil && p.Http != nil },
|
||||
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
|
||||
perm := ctx.permissions.Http
|
||||
service := newHTTPService(ctx.pluginName, perm)
|
||||
return host.RegisterHTTPHostFunctions(service), nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// extractManifest reads manifest from an .ndp package and computes its SHA-256 hash.
|
||||
|
||||
@@ -38,6 +38,7 @@ The following host services are available:
|
||||
- Artwork: provides artwork public URL generation capabilities for plugins.
|
||||
- Cache: provides in-memory TTL-based caching capabilities for plugins.
|
||||
- Config: provides access to plugin configuration values.
|
||||
- HTTP: provides outbound HTTP request capabilities for plugins.
|
||||
- KVStore: provides persistent key-value storage for plugins.
|
||||
- Library: provides access to music library metadata for plugins.
|
||||
- Scheduler: provides task scheduling capabilities for plugins.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the HTTP host service.
|
||||
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||
//
|
||||
//go:build wasip1
|
||||
|
||||
package host
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
|
||||
// HTTPRequest represents an outbound HTTP request from a plugin.
|
||||
type HTTPRequest struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body []byte `json:"body"`
|
||||
TimeoutMs int32 `json:"timeoutMs"`
|
||||
}
|
||||
|
||||
// HTTPResponse represents the response from an outbound HTTP request.
|
||||
type HTTPResponse struct {
|
||||
StatusCode int32 `json:"statusCode"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body []byte `json:"body"`
|
||||
}
|
||||
|
||||
// http_send is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user http_send
|
||||
func http_send(uint64) uint64
|
||||
|
||||
type httpSendRequest struct {
|
||||
Request HTTPRequest `json:"request"`
|
||||
}
|
||||
|
||||
type httpSendResponse struct {
|
||||
Result *HTTPResponse `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPSend calls the http_send host function.
|
||||
// Send executes an HTTP request and returns the response.
|
||||
//
|
||||
// Parameters:
|
||||
// - request: The HTTP request to execute, including method, URL, headers, body, and timeout
|
||||
//
|
||||
// Returns the HTTP response with status code, headers, and body.
|
||||
// Network errors, timeouts, and permission failures are returned as Go errors.
|
||||
// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error.
|
||||
func HTTPSend(request HTTPRequest) (*HTTPResponse, error) {
|
||||
// Marshal request to JSON
|
||||
req := httpSendRequest{
|
||||
Request: request,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := http_send(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse the response
|
||||
var response httpSendResponse
|
||||
if err := json.Unmarshal(responseBytes, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert Error field to Go error
|
||||
if response.Error != "" {
|
||||
return nil, errors.New(response.Error)
|
||||
}
|
||||
|
||||
return response.Result, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains mock implementations for non-WASM builds.
|
||||
// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms.
|
||||
// Plugin authors can use the exported mock instances to set expectations in tests.
|
||||
//
|
||||
//go:build !wasip1
|
||||
|
||||
package host
|
||||
|
||||
import "github.com/stretchr/testify/mock"
|
||||
|
||||
// HTTPRequest represents an outbound HTTP request from a plugin.
|
||||
type HTTPRequest struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body []byte `json:"body"`
|
||||
TimeoutMs int32 `json:"timeoutMs"`
|
||||
}
|
||||
|
||||
// HTTPResponse represents the response from an outbound HTTP request.
|
||||
type HTTPResponse struct {
|
||||
StatusCode int32 `json:"statusCode"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body []byte `json:"body"`
|
||||
}
|
||||
|
||||
// mockHTTPService is the mock implementation for testing.
|
||||
type mockHTTPService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// HTTPMock is the auto-instantiated mock instance for testing.
|
||||
// Use this to set expectations: host.HTTPMock.On("MethodName", args...).Return(values...)
|
||||
var HTTPMock = &mockHTTPService{}
|
||||
|
||||
// Send is the mock method for HTTPSend.
|
||||
func (m *mockHTTPService) Send(request HTTPRequest) (*HTTPResponse, error) {
|
||||
args := m.Called(request)
|
||||
return args.Get(0).(*HTTPResponse), args.Error(1)
|
||||
}
|
||||
|
||||
// HTTPSend delegates to the mock instance.
|
||||
// Send executes an HTTP request and returns the response.
|
||||
//
|
||||
// Parameters:
|
||||
// - request: The HTTP request to execute, including method, URL, headers, body, and timeout
|
||||
//
|
||||
// Returns the HTTP response with status code, headers, and body.
|
||||
// Network errors, timeouts, and permission failures are returned as Go errors.
|
||||
// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error.
|
||||
func HTTPSend(request HTTPRequest) (*HTTPResponse, error) {
|
||||
return HTTPMock.Send(request)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# Code generated by ndpgen. DO NOT EDIT.
|
||||
#
|
||||
# This file contains client wrappers for the HTTP host service.
|
||||
# It is intended for use in Navidrome plugins built with extism-py.
|
||||
#
|
||||
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
|
||||
# The @extism.import_fn decorators are only detected when defined in the plugin's
|
||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import extism
|
||||
import json
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
"""Raised when a host function returns an error."""
|
||||
pass
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "http_send")
|
||||
def _http_send(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
def http_send(request: Any) -> Any:
|
||||
"""Send executes an HTTP request and returns the response.
|
||||
|
||||
Parameters:
|
||||
- request: The HTTP request to execute, including method, URL, headers, body, and timeout
|
||||
|
||||
Returns the HTTP response with status code, headers, and body.
|
||||
Network errors, timeouts, and permission failures are returned as errors.
|
||||
Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error.
|
||||
|
||||
Args:
|
||||
request: Any parameter.
|
||||
|
||||
Returns:
|
||||
Any: The result value.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"request": request,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _http_send(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
|
||||
if response.get("error"):
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("result", None)
|
||||
@@ -35,6 +35,7 @@
|
||||
//! - [`artwork`] - provides artwork public URL generation capabilities for plugins.
|
||||
//! - [`cache`] - provides in-memory TTL-based caching capabilities for plugins.
|
||||
//! - [`config`] - provides access to plugin configuration values.
|
||||
//! - [`http`] - provides outbound HTTP request capabilities for plugins.
|
||||
//! - [`kvstore`] - provides persistent key-value storage for plugins.
|
||||
//! - [`library`] - provides access to music library metadata for plugins.
|
||||
//! - [`scheduler`] - provides task scheduling capabilities for plugins.
|
||||
@@ -63,6 +64,13 @@ pub mod config {
|
||||
pub use super::nd_host_config::*;
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
mod nd_host_http;
|
||||
/// provides outbound HTTP request capabilities for plugins.
|
||||
pub mod http {
|
||||
pub use super::nd_host_http::*;
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
mod nd_host_kvstore;
|
||||
/// provides persistent key-value storage for plugins.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the HTTP host service.
|
||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// HTTPRequest represents an outbound HTTP request from a plugin.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpRequest {
|
||||
pub method: String,
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub headers: std::collections::HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub body: Vec<u8>,
|
||||
#[serde(default)]
|
||||
pub timeout_ms: i32,
|
||||
}
|
||||
|
||||
/// HTTPResponse represents the response from an outbound HTTP request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HttpResponse {
|
||||
pub status_code: i32,
|
||||
#[serde(default)]
|
||||
pub headers: std::collections::HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HTTPSendRequest {
|
||||
request: HttpRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HTTPSendResponse {
|
||||
#[serde(default)]
|
||||
result: Option<HttpResponse>,
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn http_send(input: Json<HTTPSendRequest>) -> Json<HTTPSendResponse>;
|
||||
}
|
||||
|
||||
/// Send executes an HTTP request and returns the response.
|
||||
///
|
||||
/// Parameters:
|
||||
/// - request: The HTTP request to execute, including method, URL, headers, body, and timeout
|
||||
///
|
||||
/// Returns the HTTP response with status code, headers, and body.
|
||||
/// Network errors, timeouts, and permission failures are returned as errors.
|
||||
/// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - HttpRequest parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// The result value.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn send(request: HttpRequest) -> Result<Option<HttpResponse>, Error> {
|
||||
let response = unsafe {
|
||||
http_send(Json(HTTPSendRequest {
|
||||
request: request,
|
||||
}))?
|
||||
};
|
||||
|
||||
if let Some(err) = response.0.error {
|
||||
return Err(Error::msg(err));
|
||||
}
|
||||
|
||||
Ok(response.0.result)
|
||||
}
|
||||
Reference in New Issue
Block a user