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:
Deluan Quintão
2026-02-24 14:28:36 -05:00
committed by GitHub
parent 2bb13e5ff1
commit 652c27690b
14 changed files with 1228 additions and 19 deletions
+40
View File
@@ -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)
}
+88
View File
@@ -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
}