Add security headers functionality with opt-in HSTS, CSP, and other browser-hardening features
CI - Test, Publish and Release / run-tests (push) Successful in 19s
CI - Test, Publish and Release / create-release (push) Successful in 19s
CI - Test, Publish and Release / check-and-publish (push) Successful in 13s

This commit is contained in:
2026-06-15 07:17:35 +02:00
parent bcf5572aeb
commit a0790400e2
8 changed files with 504 additions and 5 deletions
+54
View File
@@ -12,6 +12,7 @@ A lightweight, high-performance HTTP server library built on top of Netty. Nexus
- **Annotation-based controllers** — define routes declaratively with `@Controller`, `@GET`, `@POST`, etc. - **Annotation-based controllers** — define routes declaratively with `@Controller`, `@GET`, `@POST`, etc.
- **Middleware chain** — attach cross-cutting logic to all routes - **Middleware chain** — attach cross-cutting logic to all routes
- **CORS support** — configurable origins, methods, headers, credentials, and preflight caching - **CORS support** — configurable origins, methods, headers, credentials, and preflight caching
- **Security headers** — opt-in `nosniff`, `X-Frame-Options`, `Referrer-Policy`, CSP and HTTPS-only HSTS, applied to every response
- **Rate limiting** — four algorithm implementations with per-IP, per-header, per-cookie, per-principal or custom key strategies, with automatic eviction of idle state - **Rate limiting** — four algorithm implementations with per-IP, per-header, per-cookie, per-principal or custom key strategies, with automatic eviction of idle state
- **Spoofing-safe client IP** — `X-Forwarded-For` is honoured only behind configured trusted proxies - **Spoofing-safe client IP** — `X-Forwarded-For` is honoured only behind configured trusted proxies
- **WebSockets** — path-routed handlers with origin validation, optional authentication, ordered per-connection delivery, backpressure, idle timeout, frame size limits and permessage-deflate - **WebSockets** — path-routed handlers with origin validation, optional authentication, ordered per-connection delivery, backpressure, idle timeout, frame size limits and permessage-deflate
@@ -294,6 +295,13 @@ router.get("/api/me", (req, res) -> {
`validator` returns the resolved `Principal`, or `null` for missing/invalid credentials (→ `401` on a `REQUIRED` path). A thrown exception is treated as an internal error (→ generic `500`); details are logged, never sent to the client. Rate limiting runs **before** authentication, so an unauthenticated flood is shed before reaching a (potentially expensive) authenticator. `validator` returns the resolved `Principal`, or `null` for missing/invalid credentials (→ `401` on a `REQUIRED` path). A thrown exception is treated as an internal error (→ generic `500`); details are logged, never sent to the client. Rate limiting runs **before** authentication, so an unauthenticated flood is shed before reaching a (potentially expensive) authenticator.
When a validator compares a presented secret (API key, token, password) against an expected value, use `Authenticator.constantTimeEquals(presented, expected)` instead of `String.equals` to avoid leaking how many characters matched through a timing side channel:
```java
Authenticator auth = Authenticator.apiKey("X-API-Key",
key -> Authenticator.constantTimeEquals(key, EXPECTED_KEY) ? Principal.of("svc") : null);
```
WebSocket upgrades on protected paths are authenticated the same way; the resolved principal is available via `session.principal()`. WebSocket upgrades on protected paths are authenticated the same way; the resolved principal is available via `session.principal()`.
--- ---
@@ -332,6 +340,51 @@ HttpServer.builder(8080, router)
--- ---
## Security headers
`withSecurityHeaders(...)` adds standard browser-hardening response headers to **every** response (handler responses, errors, CORS preflights and rejections alike). It is opt-in; without the call no security headers are sent.
```java
import dev.coph.nextusweb.server.security.SecurityHeaders;
HttpServer.builder(443, router)
.withTls(TlsConfig.fromPem(cert, key))
.withSecurityHeaders(SecurityHeaders.defaults())
.start();
```
`SecurityHeaders.defaults()` emits a conservative baseline:
| Header | Value | Notes |
|---|---|---|
| `X-Content-Type-Options` | `nosniff` | Blocks MIME sniffing |
| `X-Frame-Options` | `DENY` | Click-jacking defence |
| `Referrer-Policy` | `no-referrer` | No referrer leakage |
| `Strict-Transport-Security` | `max-age=31536000` | **Only sent over HTTPS** (when `withTls(...)` is set); pins HTTPS for a year |
Two safety rules keep it from breaking anything: **HSTS is emitted only on TLS connections** (a browser ignores it on plain HTTP), and a header a handler has **already set is never overwritten** — so per-route choices win.
For full control use the builder. Passing `null`/blank to a setter omits that header:
```java
SecurityHeaders headers = SecurityHeaders.builder()
.frameOptions("SAMEORIGIN") // or null to omit
.referrerPolicy("strict-origin-when-cross-origin")
.contentSecurityPolicy("default-src 'self'") // off by default (app-specific)
.hsts(Duration.ofDays(365), true, false) // maxAge, includeSubDomains, preload
.header("Permissions-Policy", "geolocation=()") // any extra header
.build();
HttpServer.builder(443, router)
.withTls(TlsConfig.fromPem(cert, key))
.withSecurityHeaders(headers)
.start();
```
> `includeSubDomains` and `preload` are hard to roll back — enable them only once every subdomain is reliably served over HTTPS.
---
## Rate Limiting ## Rate Limiting
### Algorithms ### Algorithms
@@ -543,6 +596,7 @@ WebSocketConfig wsConfig = WebSocketConfig.builder()
HttpServer.builder(8080, router) HttpServer.builder(8080, router)
.withCorsHandler(cors) .withCorsHandler(cors)
.withRateLimitGate(gate) .withRateLimitGate(gate)
.withSecurityHeaders(SecurityHeaders.defaults())
.withWebSockets(wsRouter, wsConfig) .withWebSockets(wsRouter, wsConfig)
.start(); .start();
``` ```
@@ -11,6 +11,7 @@ import dev.coph.nextusweb.server.router.Request;
import dev.coph.nextusweb.server.router.Response; import dev.coph.nextusweb.server.router.Response;
import dev.coph.nextusweb.server.router.Router; import dev.coph.nextusweb.server.router.Router;
import dev.coph.nextusweb.server.router.exception.BadRequestException; import dev.coph.nextusweb.server.router.exception.BadRequestException;
import dev.coph.nextusweb.server.security.SecurityHeaders;
import dev.coph.nextusweb.server.websocket.WebSocketConfig; import dev.coph.nextusweb.server.websocket.WebSocketConfig;
import dev.coph.nextusweb.server.websocket.WebSocketFrameHandlerFactory; import dev.coph.nextusweb.server.websocket.WebSocketFrameHandlerFactory;
import dev.coph.nextusweb.server.websocket.WebSocketRouter; import dev.coph.nextusweb.server.websocket.WebSocketRouter;
@@ -42,7 +43,8 @@ import java.util.stream.Collectors;
* <p>For each request it, in order: detects and performs WebSocket upgrades (when a WebSocket * <p>For each request it, in order: detects and performs WebSocket upgrades (when a WebSocket
* router is configured), answers CORS preflight requests, enforces rate limits, runs the * router is configured), answers CORS preflight requests, enforces rate limits, runs the
* authentication layer, resolves the route via the {@link Router}, runs middlewares and the * authentication layer, resolves the route via the {@link Router}, runs middlewares and the
* matched handler, and finally writes the response with CORS and rate-limit headers applied.</p> * matched handler, and finally writes the response with security, CORS and rate-limit headers
* applied.</p>
* *
* <p>Blocking handler logic runs on a virtual-thread executor rather than on the Netty event * <p>Blocking handler logic runs on a virtual-thread executor rather than on the Netty event
* loop, so handlers may perform blocking work without stalling I/O. To keep memory bounded the * loop, so handlers may perform blocking work without stalling I/O. To keep memory bounded the
@@ -74,6 +76,10 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
private final WebSocketRouter wsRouter; private final WebSocketRouter wsRouter;
/** WebSocket configuration; only consulted when {@link #wsRouter} is non-null. */ /** WebSocket configuration; only consulted when {@link #wsRouter} is non-null. */
private final WebSocketConfig wsConfig; private final WebSocketConfig wsConfig;
/** Security-header policy applied to every response, or {@code null} if disabled. */
private final SecurityHeaders securityHeaders;
/** Whether this server's connections are secured by TLS (gates HSTS emission). */
private final boolean secure;
/** /**
* Creates a handler without WebSocket support. * Creates a handler without WebSocket support.
@@ -86,7 +92,7 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
*/ */
public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit, public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit,
AuthGate authGate, TrustedProxies trustedProxies) { AuthGate authGate, TrustedProxies trustedProxies) {
this(router, cors, rateLimit, authGate, trustedProxies, null, null); this(router, cors, rateLimit, authGate, trustedProxies, null, null, null, false);
} }
/** /**
@@ -103,6 +109,26 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit, public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit,
AuthGate authGate, TrustedProxies trustedProxies, AuthGate authGate, TrustedProxies trustedProxies,
WebSocketRouter wsRouter, WebSocketConfig wsConfig) { WebSocketRouter wsRouter, WebSocketConfig wsConfig) {
this(router, cors, rateLimit, authGate, trustedProxies, wsRouter, wsConfig, null, false);
}
/**
* Creates a handler with WebSocket support and a security-header policy.
*
* @param router the router resolving requests
* @param cors the CORS handler, or {@code null} to disable CORS
* @param rateLimit the rate-limit gate, or {@code null} to disable rate limiting
* @param authGate the auth gate, or {@code null} to disable the auth layer
* @param trustedProxies the trusted-proxy policy, or {@code null} for {@link TrustedProxies#none()}
* @param wsRouter the WebSocket router, or {@code null} to disable WebSocket support
* @param wsConfig the WebSocket configuration, used only when {@code wsRouter} is non-null
* @param securityHeaders the security-header policy, or {@code null} to add no security headers
* @param secure whether the server's connections are secured by TLS (gates HSTS)
*/
public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit,
AuthGate authGate, TrustedProxies trustedProxies,
WebSocketRouter wsRouter, WebSocketConfig wsConfig,
SecurityHeaders securityHeaders, boolean secure) {
this.router = router; this.router = router;
this.cors = cors; this.cors = cors;
this.rateLimit = rateLimit; this.rateLimit = rateLimit;
@@ -110,6 +136,8 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
this.trustedProxies = trustedProxies == null ? TrustedProxies.none() : trustedProxies; this.trustedProxies = trustedProxies == null ? TrustedProxies.none() : trustedProxies;
this.wsRouter = wsRouter; this.wsRouter = wsRouter;
this.wsConfig = wsConfig; this.wsConfig = wsConfig;
this.securityHeaders = securityHeaders;
this.secure = secure;
} }
/** /**
@@ -369,6 +397,9 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
* @param keepAlive whether the client requested a persistent connection * @param keepAlive whether the client requested a persistent connection
*/ */
private void send(ChannelHandlerContext ctx, Response res, boolean keepAlive) { private void send(ChannelHandlerContext ctx, Response res, boolean keepAlive) {
if (securityHeaders != null) {
securityHeaders.apply(res, secure);
}
var nettyRes = new DefaultFullHttpResponse( var nettyRes = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1, HttpVersion.HTTP_1_1,
HttpResponseStatus.valueOf(res.status()), HttpResponseStatus.valueOf(res.status()),
@@ -5,6 +5,7 @@ import dev.coph.nextusweb.server.cores.CorsHandler;
import dev.coph.nextusweb.server.net.TrustedProxies; import dev.coph.nextusweb.server.net.TrustedProxies;
import dev.coph.nextusweb.server.ratelimit.RateLimitGate; import dev.coph.nextusweb.server.ratelimit.RateLimitGate;
import dev.coph.nextusweb.server.router.Router; import dev.coph.nextusweb.server.router.Router;
import dev.coph.nextusweb.server.security.SecurityHeaders;
import dev.coph.nextusweb.server.tls.TlsConfig; import dev.coph.nextusweb.server.tls.TlsConfig;
import dev.coph.nextusweb.server.websocket.WebSocketConfig; import dev.coph.nextusweb.server.websocket.WebSocketConfig;
import dev.coph.nextusweb.server.websocket.WebSocketRouter; import dev.coph.nextusweb.server.websocket.WebSocketRouter;
@@ -60,6 +61,8 @@ public final class HttpServer {
private RateLimitGate gate; private RateLimitGate gate;
/** Optional authentication gate; {@code null} disables the auth layer. */ /** Optional authentication gate; {@code null} disables the auth layer. */
private AuthGate authGate; private AuthGate authGate;
/** Optional security-header policy; {@code null} adds no security headers. */
private SecurityHeaders securityHeaders;
/** Optional WebSocket router; {@code null} disables WebSocket support. */ /** Optional WebSocket router; {@code null} disables WebSocket support. */
private WebSocketRouter wsRouter; private WebSocketRouter wsRouter;
/** WebSocket configuration; only used when {@link #wsRouter} is set. */ /** WebSocket configuration; only used when {@link #wsRouter} is set. */
@@ -133,6 +136,20 @@ public final class HttpServer {
return this; return this;
} }
/**
* Attaches a security-header policy whose headers are added to every response. HSTS, if
* configured, is emitted only when TLS is enabled on this server. Existing headers set by a
* handler are preserved.
*
* @param securityHeaders the security-header policy to apply
* @return this instance, for fluent chaining
* @see SecurityHeaders#defaults()
*/
public HttpServer withSecurityHeaders(SecurityHeaders securityHeaders) {
this.securityHeaders = securityHeaders;
return this;
}
/** /**
* Configures which transport peers are trusted reverse proxies, controlling whether * Configures which transport peers are trusted reverse proxies, controlling whether
* {@code X-Forwarded-For} is honoured when resolving the client IP. Defaults to * {@code X-Forwarded-For} is honoured when resolving the client IP. Defaults to
@@ -227,6 +244,8 @@ public final class HttpServer {
final AuthGate auth = this.authGate; final AuthGate auth = this.authGate;
final WebSocketRouter websocketRouter = this.wsRouter; final WebSocketRouter websocketRouter = this.wsRouter;
final WebSocketConfig websocketConfig = this.wsConfig; final WebSocketConfig websocketConfig = this.wsConfig;
final SecurityHeaders secHeaders = this.securityHeaders;
final boolean tlsEnabled = tlsCfg != null;
final TrustedProxies proxies = this.trustedProxies; final TrustedProxies proxies = this.trustedProxies;
final int maxContent = this.maxHttpContentLength; final int maxContent = this.maxHttpContentLength;
final long readTimeoutSeconds = (httpReadTimeout != null && !httpReadTimeout.isZero() final long readTimeoutSeconds = (httpReadTimeout != null && !httpReadTimeout.isZero()
@@ -253,7 +272,8 @@ public final class HttpServer {
pipeline.addLast(new HttpServerCodec()) pipeline.addLast(new HttpServerCodec())
.addLast(new HttpObjectAggregator(maxContent)) .addLast(new HttpObjectAggregator(maxContent))
.addLast(new HttpRequestHandler(router, corsHandler, rateLimitGate, .addLast(new HttpRequestHandler(router, corsHandler, rateLimitGate,
auth, proxies, websocketRouter, websocketConfig)); auth, proxies, websocketRouter, websocketConfig,
secHeaders, tlsEnabled));
} }
}) })
.bind(port).sync().channel().closeFuture().sync(); .bind(port).sync().channel().closeFuture().sync();
@@ -3,6 +3,7 @@ package dev.coph.nextusweb.server.auth;
import dev.coph.nextusweb.server.router.Request; import dev.coph.nextusweb.server.router.Request;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64; import java.util.Base64;
import java.util.function.BiFunction; import java.util.function.BiFunction;
import java.util.function.Function; import java.util.function.Function;
@@ -40,6 +41,7 @@ public interface Authenticator {
* @param headerName the header carrying the API key * @param headerName the header carrying the API key
* @param validator maps a presented key to a principal, or {@code null} if invalid * @param validator maps a presented key to a principal, or {@code null} if invalid
* @return an API-key authenticator * @return an API-key authenticator
* @see #constantTimeEquals(String, String)
*/ */
static Authenticator apiKey(String headerName, Function<String, Principal> validator) { static Authenticator apiKey(String headerName, Function<String, Principal> validator) {
return request -> { return request -> {
@@ -72,6 +74,7 @@ public interface Authenticator {
* *
* @param validator maps {@code (username, password)} to a principal, or {@code null} if invalid * @param validator maps {@code (username, password)} to a principal, or {@code null} if invalid
* @return a Basic-auth authenticator * @return a Basic-auth authenticator
* @see #constantTimeEquals(String, String)
*/ */
static Authenticator basic(BiFunction<String, String, Principal> validator) { static Authenticator basic(BiFunction<String, String, Principal> validator) {
return request -> { return request -> {
@@ -125,4 +128,26 @@ public interface Authenticator {
return null; return null;
}; };
} }
/**
* Compares two secrets (API keys, tokens, passwords, ...) in length-constant time, so the
* time taken does not reveal how many leading characters matched. Use this inside a validator
* instead of {@link String#equals(Object)} whenever the comparison guards a credential, to
* deny attackers a timing oracle for guessing the secret byte by byte.
*
* <p>The comparison is performed on the UTF-8 bytes of the inputs via
* {@link MessageDigest#isEqual(byte[], byte[])}. A {@code null} on either side yields
* {@code false}. Note that the <em>length</em> of the presented value is not hidden; keep
* secrets of a fixed length if even that must not leak.</p>
*
* @param a the first value (for example the presented credential), may be {@code null}
* @param b the second value (for example the expected secret), may be {@code null}
* @return {@code true} if both are non-{@code null} and byte-for-byte equal
*/
static boolean constantTimeEquals(String a, String b) {
if (a == null || b == null) return false;
return MessageDigest.isEqual(
a.getBytes(StandardCharsets.UTF_8),
b.getBytes(StandardCharsets.UTF_8));
}
} }
@@ -159,13 +159,16 @@ public final class Router {
* @return the resolution outcome, never {@code null} * @return the resolution outcome, never {@code null}
*/ */
public Resolution resolve(HttpMethod method, String path) { public Resolution resolve(HttpMethod method, String path) {
Map<String, String> params = new HashMap<>(4); // Most routes capture no path parameters; defer allocating the map until the first
// segment is actually captured so the common no-param case stays allocation-free.
Map<String, String> params = null;
Node node = root; Node node = root;
for (String segment : split(path)) { for (String segment : split(path)) {
Node next = node.children.get(segment); Node next = node.children.get(segment);
if (next != null) { if (next != null) {
node = next; node = next;
} else if (node.paramChild != null) { } else if (node.paramChild != null) {
if (params == null) params = new HashMap<>(4);
params.put(node.paramName, segment); params.put(node.paramName, segment);
node = node.paramChild; node = node.paramChild;
} else if (node.wildcardChild != null) { } else if (node.wildcardChild != null) {
@@ -177,7 +180,7 @@ public final class Router {
Handler h = node.handlers.get(method); Handler h = node.handlers.get(method);
if (h != null) { if (h != null) {
return new Resolution.Match(h, params); return new Resolution.Match(h, params == null ? Map.of() : params);
} }
if (!node.handlers.isEmpty()) { if (!node.handlers.isEmpty()) {
@@ -0,0 +1,247 @@
package dev.coph.nextusweb.server.security;
import dev.coph.nextusweb.server.router.Response;
import io.netty.handler.codec.http.HttpHeaders;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* A small, immutable policy of standard HTTP security response headers that the server adds to
* every response. It complements the transport- and authentication-level protections (TLS, CORS,
* the auth gate, rate limiting) with the browser-facing hardening headers that mitigate
* MIME-sniffing, click-jacking, referrer leakage and protocol-downgrade attacks.
*
* <p>Like {@code CorsHandler} the header strings are computed once at construction time and then
* reused for every response, so applying them is cheap. Attach an instance with
* {@code HttpServer.withSecurityHeaders(...)} and the request pipeline applies it to all
* responses.</p>
*
* <p>Two design choices keep the feature safe to switch on:</p>
* <ul>
* <li><strong>Existing headers are never overwritten.</strong> If a handler has already set a
* given header (say a route-specific {@code Content-Security-Policy}), that value is kept and
* the policy default is skipped — so enabling security headers cannot silently clobber
* deliberate per-route choices.</li>
* <li><strong>HSTS is emitted only over HTTPS.</strong> {@code Strict-Transport-Security} is
* added only when the connection is actually secured by TLS, because a browser ignores it on
* plain HTTP and sending it there is meaningless (and a footgun behind a misconfigured
* proxy).</li>
* </ul>
*/
public final class SecurityHeaders {
/** The {@code Strict-Transport-Security} header name, gated on a secure connection. */
private static final String HSTS = "Strict-Transport-Security";
/** Headers added to every response (subject to not already being present). */
private final List<Map.Entry<String, String>> always;
/** Pre-rendered HSTS header value, or {@code null} if HSTS is disabled. */
private final String hstsValue;
private SecurityHeaders(Builder b) {
List<Map.Entry<String, String>> list = new ArrayList<>();
if (b.contentTypeOptions) {
list.add(Map.entry("X-Content-Type-Options", "nosniff"));
}
if (notBlank(b.frameOptions)) {
list.add(Map.entry("X-Frame-Options", b.frameOptions));
}
if (notBlank(b.referrerPolicy)) {
list.add(Map.entry("Referrer-Policy", b.referrerPolicy));
}
if (notBlank(b.contentSecurityPolicy)) {
list.add(Map.entry("Content-Security-Policy", b.contentSecurityPolicy));
}
for (var e : b.custom.entrySet()) {
list.add(Map.entry(e.getKey(), e.getValue()));
}
this.always = List.copyOf(list);
if (b.hstsMaxAge != null && !b.hstsMaxAge.isZero() && !b.hstsMaxAge.isNegative()) {
StringBuilder sb = new StringBuilder("max-age=").append(b.hstsMaxAge.toSeconds());
if (b.hstsIncludeSubDomains) sb.append("; includeSubDomains");
if (b.hstsPreload) sb.append("; preload");
this.hstsValue = sb.toString();
} else {
this.hstsValue = null;
}
}
private static boolean notBlank(String s) {
return s != null && !s.isBlank();
}
/**
* A sensible, conservative default policy: {@code X-Content-Type-Options: nosniff},
* {@code X-Frame-Options: DENY}, {@code Referrer-Policy: no-referrer} and, on HTTPS
* connections, a one-year {@code Strict-Transport-Security} header (without
* {@code includeSubDomains}/{@code preload}, which are opt-in because of their wide blast
* radius). No {@code Content-Security-Policy} is set, since a useful CSP is application
* specific.
*
* @return the default security-header policy
*/
public static SecurityHeaders defaults() {
return builder()
.hsts(Duration.ofDays(365), false, false)
.build();
}
/**
* Creates a builder pre-populated with the conservative defaults (see {@link #defaults()}),
* except that HSTS is disabled until configured with {@link Builder#hsts}.
*
* @return a fresh builder
*/
public static Builder builder() {
return new Builder();
}
/**
* Adds the configured security headers to a response, skipping any header the handler has
* already set, and adding {@code Strict-Transport-Security} only when {@code secure} is
* {@code true}.
*
* @param res the response to decorate
* @param secure whether the underlying connection is secured by TLS
*/
public void apply(Response res, boolean secure) {
HttpHeaders headers = res.headers();
for (Map.Entry<String, String> e : always) {
if (!headers.contains(e.getKey())) {
headers.set(e.getKey(), e.getValue());
}
}
if (secure && hstsValue != null && !headers.contains(HSTS)) {
headers.set(HSTS, hstsValue);
}
}
/**
* Fluent builder for {@link SecurityHeaders}. Sensible defaults are pre-set; call the setters
* only to override them. Passing {@code null} (or a blank string) to a setter disables that
* particular header.
*/
public static final class Builder {
private boolean contentTypeOptions = true;
private String frameOptions = "DENY";
private String referrerPolicy = "no-referrer";
private String contentSecurityPolicy;
private Duration hstsMaxAge;
private boolean hstsIncludeSubDomains;
private boolean hstsPreload;
private final Map<String, String> custom = new LinkedHashMap<>();
private Builder() {
}
/**
* Enables or disables {@code X-Content-Type-Options: nosniff} (defends against MIME
* sniffing). Enabled by default.
*
* @param enabled {@code true} to emit the header
* @return this builder, for fluent chaining
*/
public Builder contentTypeOptions(boolean enabled) {
this.contentTypeOptions = enabled;
return this;
}
/**
* Sets the {@code X-Frame-Options} value (click-jacking defence); typical values are
* {@code "DENY"} (the default) or {@code "SAMEORIGIN"}. Pass {@code null} or a blank string
* to omit the header.
*
* @param value the header value, or {@code null}/blank to disable
* @return this builder, for fluent chaining
*/
public Builder frameOptions(String value) {
this.frameOptions = value;
return this;
}
/**
* Sets the {@code Referrer-Policy} value (defaults to {@code "no-referrer"}). Pass
* {@code null} or a blank string to omit the header.
*
* @param value the header value, or {@code null}/blank to disable
* @return this builder, for fluent chaining
*/
public Builder referrerPolicy(String value) {
this.referrerPolicy = value;
return this;
}
/**
* Sets a {@code Content-Security-Policy}. Disabled by default because a useful CSP is
* application specific; supply one tailored to your app. Pass {@code null} or a blank
* string to omit the header.
*
* @param value the policy string, or {@code null}/blank to disable
* @return this builder, for fluent chaining
*/
public Builder contentSecurityPolicy(String value) {
this.contentSecurityPolicy = value;
return this;
}
/**
* Enables {@code Strict-Transport-Security} (HSTS), which is emitted only on HTTPS
* connections. Be deliberate with {@code includeSubDomains} and {@code preload}: they are
* hard to roll back, so enable them only once every subdomain is reliably served over
* HTTPS.
*
* @param maxAge how long browsers should pin HTTPS; {@code null}/zero/negative
* disables HSTS
* @param includeSubDomains whether the policy also covers every subdomain
* @param preload whether to request inclusion in browser preload lists
* @return this builder, for fluent chaining
*/
public Builder hsts(Duration maxAge, boolean includeSubDomains, boolean preload) {
this.hstsMaxAge = maxAge;
this.hstsIncludeSubDomains = includeSubDomains;
this.hstsPreload = preload;
return this;
}
/**
* Disables {@code Strict-Transport-Security}.
*
* @return this builder, for fluent chaining
*/
public Builder noHsts() {
this.hstsMaxAge = null;
return this;
}
/**
* Adds an arbitrary additional response header (for example {@code Permissions-Policy} or
* {@code Cross-Origin-Opener-Policy}). Like the built-in headers it is only applied when the
* handler has not already set it.
*
* @param name the header name
* @param value the header value
* @return this builder, for fluent chaining
*/
public Builder header(String name, String value) {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(value, "value");
this.custom.put(name, value);
return this;
}
/**
* Builds the immutable {@link SecurityHeaders}.
*
* @return the configured instance
*/
public SecurityHeaders build() {
return new SecurityHeaders(this);
}
}
}
@@ -116,4 +116,18 @@ class AuthenticatorTest {
Authenticator auth = Authenticator.apiKey("X-API-Key", k -> Principal.of("never")); Authenticator auth = Authenticator.apiKey("X-API-Key", k -> Principal.of("never"));
assertNull(auth.authenticate(request("X-API-Key", ""))); assertNull(auth.authenticate(request("X-API-Key", "")));
} }
@Test
void constantTimeEqualsMatchesIdenticalValues() {
assertTrue(Authenticator.constantTimeEquals("s3cr3t", "s3cr3t"));
}
@Test
void constantTimeEqualsRejectsDifferentValuesAndNulls() {
assertFalse(Authenticator.constantTimeEquals("s3cr3t", "s3cr3T"));
assertFalse(Authenticator.constantTimeEquals("short", "longer-value"));
assertFalse(Authenticator.constantTimeEquals(null, "x"));
assertFalse(Authenticator.constantTimeEquals("x", null));
assertFalse(Authenticator.constantTimeEquals(null, null));
}
} }
@@ -0,0 +1,105 @@
package dev.coph.nextusweb.server.security;
import dev.coph.nextusweb.server.router.Response;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.*;
class SecurityHeadersTest {
@Test
void defaultsApplyConservativeHeaders() {
Response res = new Response();
SecurityHeaders.defaults().apply(res, false);
assertEquals("nosniff", res.headers().get("X-Content-Type-Options"));
assertEquals("DENY", res.headers().get("X-Frame-Options"));
assertEquals("no-referrer", res.headers().get("Referrer-Policy"));
assertNull(res.headers().get("Content-Security-Policy"));
}
@Test
void hstsIsOmittedOnInsecureConnections() {
Response res = new Response();
SecurityHeaders.defaults().apply(res, false);
assertNull(res.headers().get("Strict-Transport-Security"));
}
@Test
void hstsIsEmittedOnSecureConnections() {
Response res = new Response();
SecurityHeaders.defaults().apply(res, true);
assertEquals("max-age=31536000", res.headers().get("Strict-Transport-Security"));
}
@Test
void hstsRendersIncludeSubDomainsAndPreload() {
SecurityHeaders sh = SecurityHeaders.builder()
.hsts(Duration.ofDays(365), true, true)
.build();
Response res = new Response();
sh.apply(res, true);
assertEquals("max-age=31536000; includeSubDomains; preload",
res.headers().get("Strict-Transport-Security"));
}
@Test
void noHstsDisablesTheHeaderEvenWhenSecure() {
SecurityHeaders sh = SecurityHeaders.builder().noHsts().build();
Response res = new Response();
sh.apply(res, true);
assertNull(res.headers().get("Strict-Transport-Security"));
}
@Test
void existingHandlerHeadersAreNotOverwritten() {
Response res = new Response();
res.header("X-Frame-Options", "SAMEORIGIN");
SecurityHeaders.defaults().apply(res, true);
assertEquals("SAMEORIGIN", res.headers().get("X-Frame-Options"));
// Other headers the handler did not set are still added.
assertEquals("nosniff", res.headers().get("X-Content-Type-Options"));
}
@Test
void existingHstsHeaderIsNotOverwritten() {
Response res = new Response();
res.header("Strict-Transport-Security", "max-age=60");
SecurityHeaders.defaults().apply(res, true);
assertEquals("max-age=60", res.headers().get("Strict-Transport-Security"));
}
@Test
void disabledHeadersAreOmitted() {
SecurityHeaders sh = SecurityHeaders.builder()
.contentTypeOptions(false)
.frameOptions(null)
.referrerPolicy(" ")
.noHsts()
.build();
Response res = new Response();
sh.apply(res, true);
assertNull(res.headers().get("X-Content-Type-Options"));
assertNull(res.headers().get("X-Frame-Options"));
assertNull(res.headers().get("Referrer-Policy"));
assertNull(res.headers().get("Strict-Transport-Security"));
}
@Test
void contentSecurityPolicyAndCustomHeaderAreApplied() {
SecurityHeaders sh = SecurityHeaders.builder()
.contentSecurityPolicy("default-src 'self'")
.header("Permissions-Policy", "geolocation=()")
.build();
Response res = new Response();
sh.apply(res, false);
assertEquals("default-src 'self'", res.headers().get("Content-Security-Policy"));
assertEquals("geolocation=()", res.headers().get("Permissions-Policy"));
}
}