fix(plugins): correct Rust codegen serde attributes and harden endpoint responses

Fix two issues from PR #5045 review. The Rust code generator was
producing incorrect skip_serializing_if attributes: map types incorrectly
used Option::is_none instead of HashMap::is_empty, and the bare int type
for HTTPHandleResponse.Status fell through to the default Option::is_none
case. The map fix is in skipSerializingFunc; the int issue is fixed at the
source by changing Status from int to int32 (HTTP status codes always fit
in int32, and this avoids platform-dependent int sizing on i386 vs amd64).

Additionally, plugin HTTP responses now include forced security headers
(X-Content-Type-Options: nosniff and a restrictive Content-Security-Policy
with sandbox) to prevent XSS from compromised plugins serving HTML+JS on
the same origin as Navidrome.
This commit is contained in:
Deluan 2026-02-13 13:43:41 -05:00
parent 5c52bbb130
commit 9a004fd043
8 changed files with 57 additions and 11 deletions

View File

@ -44,7 +44,7 @@ type HTTPUser struct {
// HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
type HTTPHandleResponse struct {
// Status is the HTTP status code. Defaults to 200 if zero or not set.
Status int `json:"status,omitempty"`
Status int32 `json:"status,omitempty"`
// Headers contains the HTTP response headers to set.
Headers map[string][]string `json:"headers,omitempty"`
// Body is the response body content.

View File

@ -560,9 +560,12 @@ func rustConstName(name string) string {
// skipSerializingFunc returns the appropriate skip_serializing_if function name.
func skipSerializingFunc(goType string) string {
if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") || strings.HasPrefix(goType, "map[") {
if strings.HasPrefix(goType, "*") || strings.HasPrefix(goType, "[]") {
return "Option::is_none"
}
if strings.HasPrefix(goType, "map[") {
return "HashMap::is_empty"
}
switch goType {
case "string":
return "String::is_empty"

View File

@ -1432,12 +1432,16 @@ type OnInitOutput struct {
var _ = Describe("Rust Generation", func() {
Describe("skipSerializingFunc", func() {
It("should return Option::is_none for pointer, slice, and map types", func() {
It("should return Option::is_none for pointer and slice types", func() {
Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none"))
Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none"))
Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none"))
Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none"))
Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none"))
})
It("should return HashMap::is_empty for map types", func() {
Expect(skipSerializingFunc("map[string]int")).To(Equal("HashMap::is_empty"))
Expect(skipSerializingFunc("map[string]string")).To(Equal("HashMap::is_empty"))
})
It("should return String::is_empty for string type", func() {

View File

@ -164,15 +164,19 @@ func (h *endpointHandler) dispatch(w http.ResponseWriter, r *http.Request, p *pl
return
}
// Write response headers
// Write response headers from plugin
for key, values := range resp.Headers {
for _, v := range values {
w.Header().Add(key, v)
}
}
// Security hardening: override any plugin-set security headers
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox")
// Write status code (default to 200)
status := resp.Status
status := int(resp.Status)
if status == 0 {
status = http.StatusOK
}

View File

@ -320,6 +320,38 @@ var _ = Describe("HTTP Endpoint Handler", Ordered, func() {
})
})
Describe("Security Headers", func() {
It("includes security headers in authenticated endpoint responses", func() {
req := httptest.NewRequest("GET", "/test-http-endpoint/hello?u=testuser", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"))
})
It("includes security headers in public endpoint responses", func() {
req := httptest.NewRequest("POST", "/test-http-endpoint-public/webhook", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"))
})
It("overrides plugin-set security headers", func() {
req := httptest.NewRequest("POST", "/test-http-endpoint/echo?u=testuser", strings.NewReader("body"))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("X-Content-Type-Options")).To(Equal("nosniff"))
Expect(w.Header().Get("Content-Security-Policy")).To(Equal("default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"))
})
})
Describe("Unknown Plugin", func() {
It("returns 404 for nonexistent plugin", func() {
req := httptest.NewRequest("GET", "/nonexistent-plugin/hello", nil)

View File

@ -17,6 +17,7 @@ type HTTPHandleRequest struct {
Method string `json:"method"`
// Path is the request path relative to the plugin's base URL.
// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
// Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
Path string `json:"path"`
// Query is the raw query string without the leading '?'.
Query string `json:"query,omitempty"`
@ -31,7 +32,7 @@ type HTTPHandleRequest struct {
// HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
type HTTPHandleResponse struct {
// Status is the HTTP status code. Defaults to 200 if zero or not set.
Status int `json:"status,omitempty"`
Status int32 `json:"status,omitempty"`
// Headers contains the HTTP response headers to set.
Headers map[string][]string `json:"headers,omitempty"`
// Body is the response body content.

View File

@ -14,6 +14,7 @@ type HTTPHandleRequest struct {
Method string `json:"method"`
// Path is the request path relative to the plugin's base URL.
// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
// Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
Path string `json:"path"`
// Query is the raw query string without the leading '?'.
Query string `json:"query,omitempty"`
@ -28,7 +29,7 @@ type HTTPHandleRequest struct {
// HTTPHandleResponse is the response returned by the plugin's HandleRequest function.
type HTTPHandleResponse struct {
// Status is the HTTP status code. Defaults to 200 if zero or not set.
Status int `json:"status,omitempty"`
Status int32 `json:"status,omitempty"`
// Headers contains the HTTP response headers to set.
Headers map[string][]string `json:"headers,omitempty"`
// Body is the response body content.

View File

@ -28,13 +28,14 @@ pub struct HTTPHandleRequest {
pub method: String,
/// Path is the request path relative to the plugin's base URL.
/// For example, if the full URL is /ext/my-plugin/webhook, Path is "/webhook".
/// Both /ext/my-plugin and /ext/my-plugin/ are normalized to Path = "".
#[serde(default)]
pub path: String,
/// Query is the raw query string without the leading '?'.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub query: String,
/// Headers contains the HTTP request headers.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: std::collections::HashMap<String, Vec<String>>,
/// Body is the request body content.
#[serde(default, skip_serializing_if = "String::is_empty")]
@ -48,10 +49,10 @@ pub struct HTTPHandleRequest {
#[serde(rename_all = "camelCase")]
pub struct HTTPHandleResponse {
/// Status is the HTTP status code. Defaults to 200 if zero or not set.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "is_zero_i32")]
pub status: i32,
/// Headers contains the HTTP response headers to set.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: std::collections::HashMap<String, Vec<String>>,
/// Body is the response body content.
#[serde(default, skip_serializing_if = "String::is_empty")]