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"));
+ }
+}