diff --git a/plugins/capabilities/http_endpoint.go b/plugins/capabilities/http_endpoint.go index e5e7f984c..49e8cb81b 100644 --- a/plugins/capabilities/http_endpoint.go +++ b/plugins/capabilities/http_endpoint.go @@ -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. diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 69e232565..6c5022eaf 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -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" diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index ed15f174b..542759ae5 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -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() { diff --git a/plugins/http_endpoint.go b/plugins/http_endpoint.go index 025ce249d..cdd1c29d3 100644 --- a/plugins/http_endpoint.go +++ b/plugins/http_endpoint.go @@ -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 } diff --git a/plugins/http_endpoint_test.go b/plugins/http_endpoint_test.go index 1f6d3de97..2d88ed1f1 100644 --- a/plugins/http_endpoint_test.go +++ b/plugins/http_endpoint_test.go @@ -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) diff --git a/plugins/pdk/go/httpendpoint/httpendpoint.go b/plugins/pdk/go/httpendpoint/httpendpoint.go index f927cf081..383b3f955 100644 --- a/plugins/pdk/go/httpendpoint/httpendpoint.go +++ b/plugins/pdk/go/httpendpoint/httpendpoint.go @@ -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. diff --git a/plugins/pdk/go/httpendpoint/httpendpoint_stub.go b/plugins/pdk/go/httpendpoint/httpendpoint_stub.go index 7a97757f8..5343b8e78 100644 --- a/plugins/pdk/go/httpendpoint/httpendpoint_stub.go +++ b/plugins/pdk/go/httpendpoint/httpendpoint_stub.go @@ -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. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs index 0d639033c..72cec1cb3 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/httpendpoint.rs @@ -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>, /// 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>, /// Body is the response body content. #[serde(default, skip_serializing_if = "String::is_empty")]