From a0790400e201e7e7d791e67edc923cd4a1ba89ca Mon Sep 17 00:00:00 2001 From: CodingPhoenixx Date: Mon, 15 Jun 2026 07:17:35 +0200 Subject: [PATCH] Add security headers functionality with opt-in HSTS, CSP, and other browser-hardening features --- README.md | 54 ++++ .../nextusweb/server/HttpRequestHandler.java | 35 ++- .../dev/coph/nextusweb/server/HttpServer.java | 22 +- .../nextusweb/server/auth/Authenticator.java | 25 ++ .../coph/nextusweb/server/router/Router.java | 7 +- .../server/security/SecurityHeaders.java | 247 ++++++++++++++++++ .../server/auth/AuthenticatorTest.java | 14 + .../server/security/SecurityHeadersTest.java | 105 ++++++++ 8 files changed, 504 insertions(+), 5 deletions(-) create mode 100644 src/main/java/dev/coph/nextusweb/server/security/SecurityHeaders.java create mode 100644 src/test/java/dev/coph/nextusweb/server/security/SecurityHeadersTest.java diff --git a/README.md b/README.md index 6f3a835..d881ab0 100644 --- a/README.md +++ b/README.md @@ -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. - **Middleware chain** — attach cross-cutting logic to all routes - **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 - **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 @@ -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. +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()`. --- @@ -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 ### Algorithms @@ -543,6 +596,7 @@ WebSocketConfig wsConfig = WebSocketConfig.builder() HttpServer.builder(8080, router) .withCorsHandler(cors) .withRateLimitGate(gate) + .withSecurityHeaders(SecurityHeaders.defaults()) .withWebSockets(wsRouter, wsConfig) .start(); ``` diff --git a/src/main/java/dev/coph/nextusweb/server/HttpRequestHandler.java b/src/main/java/dev/coph/nextusweb/server/HttpRequestHandler.java index ca84182..8c2e93e 100644 --- a/src/main/java/dev/coph/nextusweb/server/HttpRequestHandler.java +++ b/src/main/java/dev/coph/nextusweb/server/HttpRequestHandler.java @@ -11,6 +11,7 @@ import dev.coph.nextusweb.server.router.Request; import dev.coph.nextusweb.server.router.Response; import dev.coph.nextusweb.server.router.Router; 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.WebSocketFrameHandlerFactory; import dev.coph.nextusweb.server.websocket.WebSocketRouter; @@ -42,7 +43,8 @@ import java.util.stream.Collectors; *

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 * 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.

+ * matched handler, and finally writes the response with security, CORS and rate-limit headers + * applied.

* *

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 @@ -74,6 +76,10 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler validator) { return request -> { @@ -72,6 +74,7 @@ public interface Authenticator { * * @param validator maps {@code (username, password)} to a principal, or {@code null} if invalid * @return a Basic-auth authenticator + * @see #constantTimeEquals(String, String) */ static Authenticator basic(BiFunction validator) { return request -> { @@ -125,4 +128,26 @@ public interface Authenticator { 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. + * + *

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 length of the presented value is not hidden; keep + * secrets of a fixed length if even that must not leak.

+ * + * @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)); + } } diff --git a/src/main/java/dev/coph/nextusweb/server/router/Router.java b/src/main/java/dev/coph/nextusweb/server/router/Router.java index 364eee9..be6e88a 100644 --- a/src/main/java/dev/coph/nextusweb/server/router/Router.java +++ b/src/main/java/dev/coph/nextusweb/server/router/Router.java @@ -159,13 +159,16 @@ public final class Router { * @return the resolution outcome, never {@code null} */ public Resolution resolve(HttpMethod method, String path) { - Map 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 params = null; Node node = root; for (String segment : split(path)) { Node next = node.children.get(segment); if (next != null) { node = next; } else if (node.paramChild != null) { + if (params == null) params = new HashMap<>(4); params.put(node.paramName, segment); node = node.paramChild; } else if (node.wildcardChild != null) { @@ -177,7 +180,7 @@ public final class Router { Handler h = node.handlers.get(method); if (h != null) { - return new Resolution.Match(h, params); + return new Resolution.Match(h, params == null ? Map.of() : params); } if (!node.handlers.isEmpty()) { diff --git a/src/main/java/dev/coph/nextusweb/server/security/SecurityHeaders.java b/src/main/java/dev/coph/nextusweb/server/security/SecurityHeaders.java new file mode 100644 index 0000000..94695f9 --- /dev/null +++ b/src/main/java/dev/coph/nextusweb/server/security/SecurityHeaders.java @@ -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. + * + *

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.

+ * + *

Two design choices keep the feature safe to switch on:

+ *
    + *
  • Existing headers are never overwritten. 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.
  • + *
  • HSTS is emitted only over HTTPS. {@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).
  • + *
+ */ +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> always; + /** Pre-rendered HSTS header value, or {@code null} if HSTS is disabled. */ + private final String hstsValue; + + private SecurityHeaders(Builder b) { + List> 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 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 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); + } + } +} diff --git a/src/test/java/dev/coph/nextusweb/server/auth/AuthenticatorTest.java b/src/test/java/dev/coph/nextusweb/server/auth/AuthenticatorTest.java index cd6babc..809843d 100644 --- a/src/test/java/dev/coph/nextusweb/server/auth/AuthenticatorTest.java +++ b/src/test/java/dev/coph/nextusweb/server/auth/AuthenticatorTest.java @@ -116,4 +116,18 @@ class AuthenticatorTest { Authenticator auth = Authenticator.apiKey("X-API-Key", k -> Principal.of("never")); 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)); + } } diff --git a/src/test/java/dev/coph/nextusweb/server/security/SecurityHeadersTest.java b/src/test/java/dev/coph/nextusweb/server/security/SecurityHeadersTest.java new file mode 100644 index 0000000..150af58 --- /dev/null +++ b/src/test/java/dev/coph/nextusweb/server/security/SecurityHeadersTest.java @@ -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")); + } +}