From dadd9834e4027577eb4e22743bde01d26bf8b3ef Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 08:48:28 +0200 Subject: [PATCH 1/2] feat: Add securityHeadersDecorator for browser hardening Provide an opt-in ResponseDecorator that sets two browser-hardening headers on every response routed through the OpenAPI dispatch chain: - X-Content-Type-Options: nosniff - Cross-Origin-Resource-Policy: same-origin Both are skipped when the handler has already set the header, so per-response overrides keep working. Wire in with OpenApiServer.builder().responseDecorator(Handlers.securityHeadersDecorator()). ServerLauncher now applies the decorator so the local demo and the ZAP scan exercise it. Note: ResponseDecorator runs in the dispatch chain, not in ExceptionFilter, so 500 responses produced by the default exception path remain unaffected. That's an intentional scope limit for this change. --- .../java/com/retailsvc/http/Handlers.java | 26 ++++++++++++ .../http/SecurityHeadersDecoratorTest.java | 42 +++++++++++++++++++ .../retailsvc/http/start/ServerLauncher.java | 2 + 3 files changed, 70 insertions(+) create mode 100644 src/test/java/com/retailsvc/http/SecurityHeadersDecoratorTest.java diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index 4e2ceda..b3cc8a2 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -28,6 +28,32 @@ public final class Handlers { private Handlers() {} + /** + * Response decorator that adds two browser-hardening headers to every response: + * + * + * + *

Existing headers with the same names are preserved, so a handler that sets either header + * keeps its value. Wire it in with {@code + * OpenApiServer.builder().responseDecorator(Handlers.securityHeadersDecorator())}. + */ + public static ResponseDecorator securityHeadersDecorator() { + return (request, response) -> { + Response decorated = response; + if (!response.headers().containsKey("X-Content-Type-Options")) { + decorated = decorated.withHeader("X-Content-Type-Options", "nosniff"); + } + if (!response.headers().containsKey("Cross-Origin-Resource-Policy")) { + decorated = decorated.withHeader("Cross-Origin-Resource-Policy", "same-origin"); + } + return decorated; + }; + } + public static ExceptionHandler defaultExceptionHandler() { return t -> switch (t) { diff --git a/src/test/java/com/retailsvc/http/SecurityHeadersDecoratorTest.java b/src/test/java/com/retailsvc/http/SecurityHeadersDecoratorTest.java new file mode 100644 index 0000000..ca86f96 --- /dev/null +++ b/src/test/java/com/retailsvc/http/SecurityHeadersDecoratorTest.java @@ -0,0 +1,42 @@ +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class SecurityHeadersDecoratorTest { + + private final ResponseDecorator decorator = Handlers.securityHeadersDecorator(); + + @Test + void addsBothHeadersWhenMissing() { + Response decorated = decorator.decorate(null, Response.status(200)); + + assertThat(decorated.headers()) + .containsEntry("X-Content-Type-Options", "nosniff") + .containsEntry("Cross-Origin-Resource-Policy", "same-origin"); + } + + @Test + void preservesHandlerSuppliedNosniffValue() { + Response handlerResponse = Response.status(200).withHeader("X-Content-Type-Options", "custom"); + + Response decorated = decorator.decorate(null, handlerResponse); + + assertThat(decorated.headers()) + .containsEntry("X-Content-Type-Options", "custom") + .containsEntry("Cross-Origin-Resource-Policy", "same-origin"); + } + + @Test + void preservesHandlerSuppliedCorpValue() { + Response handlerResponse = + Response.status(200).withHeader("Cross-Origin-Resource-Policy", "cross-origin"); + + Response decorated = decorator.decorate(null, handlerResponse); + + assertThat(decorated.headers()) + .containsEntry("X-Content-Type-Options", "nosniff") + .containsEntry("Cross-Origin-Resource-Policy", "cross-origin"); + } +} diff --git a/src/test/java/com/retailsvc/http/start/ServerLauncher.java b/src/test/java/com/retailsvc/http/start/ServerLauncher.java index 104517a..74ef550 100644 --- a/src/test/java/com/retailsvc/http/start/ServerLauncher.java +++ b/src/test/java/com/retailsvc/http/start/ServerLauncher.java @@ -1,5 +1,6 @@ package com.retailsvc.http.start; +import com.retailsvc.http.Handlers; import com.retailsvc.http.OpenApiServer; import com.retailsvc.http.RequestHandler; import com.retailsvc.http.Response; @@ -45,6 +46,7 @@ public ServerLauncher() throws IOException { OpenApiServer.builder() .spec(spec) .handlers(handlers) + .responseDecorator(Handlers.securityHeadersDecorator()) .securityValidator("apiKeyAuth", (req, cred) -> Optional.empty()) .securityValidator("bearerAuth", (req, cred) -> Optional.empty()) .securityValidator("basicAuth", (req, cred) -> Optional.empty()) From 7e3ede3a4ef9a0727ec858cf649d46828b48e300 Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Thu, 21 May 2026 08:50:30 +0200 Subject: [PATCH 2/2] docs: Document securityHeadersDecorator in README --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 86a765a..c4994d2 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,24 @@ OpenApiServer.builder() .build(); ``` +#### Built-in: browser security headers + +`Handlers.securityHeadersDecorator()` adds two browser-hardening headers to every response — +`X-Content-Type-Options: nosniff` and `Cross-Origin-Resource-Policy: same-origin`. Handler-supplied +values for either header are preserved, so individual responses can opt out by setting the header +explicitly. + +``` java +OpenApiServer.builder() + .spec(spec) + .handlers(handlers) + .responseDecorator(Handlers.securityHeadersDecorator()) + .build(); +``` + +Decorators run on the dispatch path only — error responses produced by `ExceptionFilter` (e.g. +the default 500) bypass them. + ### Request interceptors `Builder.interceptor(...)` registers a `RequestInterceptor` that wraps every handler invocation.