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. 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())