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.
- **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();
```