Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d5d57a367 | |||
| 4a7167ed7b | |||
| 02688a2f47 | |||
| 893bb0b7bd | |||
| 6de7e26f33 | |||
| a0790400e2 |
Generated
+1
-1
@@ -4,7 +4,7 @@
|
||||
<component name="FrameworkDetectionExcludesConfiguration">
|
||||
<file type="web" url="file://$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_26" default="true" project-jdk-name="openjdk-26" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_26" default="true" project-jdk-name="26" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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();
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
* <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
|
||||
* 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
|
||||
* loop, so handlers may perform blocking work without stalling I/O. To keep memory bounded the
|
||||
@@ -53,27 +55,52 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
|
||||
|
||||
/** Logger used for server-side error diagnostics (never leaked to clients). */
|
||||
/**
|
||||
* Logger used for server-side error diagnostics (never leaked to clients).
|
||||
*/
|
||||
private static final Logger LOG = System.getLogger(HttpRequestHandler.class.getName());
|
||||
|
||||
/** Executor running one virtual thread per task, used to offload blocking handler work. */
|
||||
private static final Executor VT_EXECUTOR =
|
||||
Executors.newVirtualThreadPerTaskExecutor();
|
||||
/**
|
||||
* Executor running one virtual thread per task, used to offload blocking handler work.
|
||||
*/
|
||||
private static final Executor VT_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
/** Router resolving requests to handlers. */
|
||||
/**
|
||||
* Router resolving requests to handlers.
|
||||
*/
|
||||
private final Router router;
|
||||
/** CORS handler, or {@code null} if CORS is disabled. */
|
||||
/**
|
||||
* CORS handler, or {@code null} if CORS is disabled.
|
||||
*/
|
||||
private final CorsHandler cors;
|
||||
/** Rate-limit gate, or {@code null} if rate limiting is disabled. */
|
||||
/**
|
||||
* Rate-limit gate, or {@code null} if rate limiting is disabled.
|
||||
*/
|
||||
private final RateLimitGate rateLimit;
|
||||
/** Authentication gate, or {@code null} if the auth layer is disabled. */
|
||||
/**
|
||||
* Authentication gate, or {@code null} if the auth layer is disabled.
|
||||
*/
|
||||
private final AuthGate authGate;
|
||||
/** Trusted-proxy policy used to resolve the client IP; never {@code null}. */
|
||||
/**
|
||||
* Trusted-proxy policy used to resolve the client IP; never {@code null}.
|
||||
*/
|
||||
private final TrustedProxies trustedProxies;
|
||||
/** WebSocket router, or {@code null} if WebSocket support is disabled. */
|
||||
/**
|
||||
* WebSocket router, or {@code null} if WebSocket support is disabled.
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* 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.
|
||||
@@ -84,9 +111,33 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
||||
* @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()}
|
||||
*/
|
||||
public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit,
|
||||
AuthGate authGate, TrustedProxies trustedProxies) {
|
||||
this(router, cors, rateLimit, authGate, trustedProxies, null, null);
|
||||
public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit, AuthGate authGate, TrustedProxies trustedProxies) {
|
||||
this(router, cors, rateLimit, authGate, trustedProxies, null, null, 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.cors = cors;
|
||||
this.rateLimit = rateLimit;
|
||||
this.authGate = authGate;
|
||||
this.trustedProxies = trustedProxies == null ? TrustedProxies.none() : trustedProxies;
|
||||
this.wsRouter = wsRouter;
|
||||
this.wsConfig = wsConfig;
|
||||
this.securityHeaders = securityHeaders;
|
||||
this.secure = secure;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,16 +151,8 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
||||
* @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
|
||||
*/
|
||||
public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit,
|
||||
AuthGate authGate, TrustedProxies trustedProxies,
|
||||
WebSocketRouter wsRouter, WebSocketConfig wsConfig) {
|
||||
this.router = router;
|
||||
this.cors = cors;
|
||||
this.rateLimit = rateLimit;
|
||||
this.authGate = authGate;
|
||||
this.trustedProxies = trustedProxies == null ? TrustedProxies.none() : trustedProxies;
|
||||
this.wsRouter = wsRouter;
|
||||
this.wsConfig = wsConfig;
|
||||
public HttpRequestHandler(Router router, CorsHandler cors, RateLimitGate rateLimit, AuthGate authGate, TrustedProxies trustedProxies, WebSocketRouter wsRouter, WebSocketConfig wsConfig) {
|
||||
this(router, cors, rateLimit, authGate, trustedProxies, wsRouter, wsConfig, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,8 +170,6 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
||||
if (handleWebSocketUpgrade(ctx, req)) return;
|
||||
}
|
||||
|
||||
// Backpressure: stop pulling further requests off this connection until we have answered
|
||||
// the current one, bounding buffered request memory to one aggregated body per connection.
|
||||
ctx.channel().config().setAutoRead(false);
|
||||
|
||||
req.retain();
|
||||
@@ -152,7 +193,7 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
||||
private static boolean isWebSocketUpgrade(FullHttpRequest req) {
|
||||
if (req.method() != HttpMethod.GET) return false;
|
||||
String upgrade = req.headers().get(HttpHeaderNames.UPGRADE);
|
||||
if (upgrade == null || !"websocket".equalsIgnoreCase(upgrade)) return false;
|
||||
if (!"websocket".equalsIgnoreCase(upgrade)) return false;
|
||||
String connection = req.headers().get(HttpHeaderNames.CONNECTION);
|
||||
if (connection == null) return false;
|
||||
for (String token : connection.split(",")) {
|
||||
@@ -173,7 +214,7 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
||||
* @param ctx the channel context
|
||||
* @param req the upgrade request
|
||||
* @return {@code true} if the request was consumed (handshake started or rejected),
|
||||
* {@code false} if no WebSocket route matched and normal HTTP handling should continue
|
||||
* {@code false} if no WebSocket route matched and normal HTTP handling should continue
|
||||
*/
|
||||
private boolean handleWebSocketUpgrade(ChannelHandlerContext ctx, FullHttpRequest req) {
|
||||
String path = new QueryStringDecoder(req.uri()).path();
|
||||
@@ -186,8 +227,7 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
||||
return true;
|
||||
}
|
||||
|
||||
// Authenticate the upgrade if an auth layer is configured. WebSocket authenticators should
|
||||
// be cheap (cookie/token validation) since the handshake runs on the event loop.
|
||||
|
||||
Principal principal = null;
|
||||
if (authGate != null) {
|
||||
Request upgradeReq = new Request(req, resolution.pathParams());
|
||||
@@ -211,30 +251,21 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
||||
ChannelPipeline pipeline = ctx.pipeline();
|
||||
String myName = ctx.name();
|
||||
|
||||
// The plain-HTTP read timeout would close idle WebSocket connections (which legitimately
|
||||
// stay quiet between frames), so drop it; the WebSocket idle handler governs liveness.
|
||||
if (pipeline.get("read-timeout") != null) {
|
||||
pipeline.remove("read-timeout");
|
||||
}
|
||||
|
||||
if (wsConfig.idleTimeout() != null) {
|
||||
long secs = Math.max(1, wsConfig.idleTimeout().toSeconds());
|
||||
pipeline.addBefore(myName, "ws-idle",
|
||||
new IdleStateHandler(0, 0, secs, TimeUnit.SECONDS));
|
||||
pipeline.addBefore(myName, "ws-idle", new IdleStateHandler(0, 0, secs, TimeUnit.SECONDS));
|
||||
}
|
||||
if (wsConfig.compression()) {
|
||||
pipeline.addBefore(myName, "ws-deflate",
|
||||
new WebSocketServerCompressionHandler());
|
||||
pipeline.addBefore(myName, "ws-deflate", new WebSocketServerCompressionHandler());
|
||||
}
|
||||
pipeline.addBefore(myName, "ws-proto",
|
||||
new WebSocketServerProtocolHandler(protoCfg));
|
||||
// Reassemble fragmented (continuation) frames into a single logical message, bounded by
|
||||
// the configured aggregate size so a stream of fragments cannot amplify memory use.
|
||||
pipeline.addBefore(myName, "ws-aggregator",
|
||||
new WebSocketFrameAggregator(wsConfig.maxAggregatedMessageSize()));
|
||||
pipeline.addBefore(myName, "ws-frames",
|
||||
WebSocketFrameHandlerFactory.create(resolution.handler(), path, resolution.pathParams(),
|
||||
principal, wsConfig.maxQueuedMessages()));
|
||||
pipeline.addBefore(myName, "ws-proto", new WebSocketServerProtocolHandler(protoCfg));
|
||||
|
||||
pipeline.addBefore(myName, "ws-aggregator", new WebSocketFrameAggregator(wsConfig.maxAggregatedMessageSize()));
|
||||
pipeline.addBefore(myName, "ws-frames", WebSocketFrameHandlerFactory.create(resolution.handler(), path, resolution.pathParams(), principal, wsConfig.maxQueuedMessages()));
|
||||
|
||||
ChannelHandlerContext anchor = pipeline.context(HttpObjectAggregator.class);
|
||||
if (anchor == null) anchor = pipeline.firstContext();
|
||||
@@ -271,13 +302,10 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
||||
String clientIp = resolveClientIp(ctx, raw);
|
||||
|
||||
Router.Resolution resolution = router.resolve(raw.method(), path);
|
||||
Map<String, String> params = resolution instanceof Router.Resolution.Match m
|
||||
? m.pathParams() : Map.of();
|
||||
Map<String, String> params = resolution instanceof Router.Resolution.Match m ? m.pathParams() : Map.of();
|
||||
Request request = new Request(raw, params);
|
||||
request.clientIp(clientIp);
|
||||
|
||||
// Rate limiting runs before authentication so an unauthenticated flood is shed before
|
||||
// reaching the (potentially expensive) authenticator.
|
||||
RateLimiter.Result rlResult = null;
|
||||
if (rateLimit != null) {
|
||||
rlResult = rateLimit.check(request, path, clientIp);
|
||||
@@ -290,8 +318,6 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication layer: attaches the principal on success, or short-circuits with a
|
||||
// rejection response (401/500) for protected paths.
|
||||
if (authGate != null) {
|
||||
Response rejection = authGate.authenticate(request, path);
|
||||
if (rejection != null) {
|
||||
@@ -306,11 +332,11 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
||||
switch (resolution) {
|
||||
case Router.Resolution.Match m -> {
|
||||
try {
|
||||
for (var mw : router.middlewares()) mw.accept(request, res);
|
||||
for (var mw : router.middlewares())
|
||||
mw.accept(request, res);
|
||||
m.handler().handle(request, res);
|
||||
} catch (BadRequestException e) {
|
||||
res.status(400).json(Map.of("error",
|
||||
e.getMessage() == null ? "Bad Request" : e.getMessage()));
|
||||
res.status(400).json(Map.of("error", e.getMessage() == null ? "Bad Request" : e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
LOG.log(Level.ERROR, "Handler failed for " + raw.method() + " " + path, e);
|
||||
res.status(500).json("{\"error\":\"Internal Server Error\"}");
|
||||
@@ -332,8 +358,6 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
||||
if (cors != null) cors.applyHeaders(origin, res);
|
||||
send(ctx, res, keepAlive);
|
||||
} catch (Throwable t) {
|
||||
// Last-resort guard: anything escaping the stages above must still produce a response,
|
||||
// otherwise the connection would hang with auto-read disabled. Close it to be safe.
|
||||
LOG.log(Level.ERROR, "Unexpected failure while handling request", t);
|
||||
try {
|
||||
send(ctx, new Response().status(500).json("{\"error\":\"Internal Server Error\"}"), false);
|
||||
@@ -343,6 +367,19 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an empty-bodied response with the given status and closes the connection, used for
|
||||
* rejected WebSocket upgrades.
|
||||
*
|
||||
* @param ctx the channel context
|
||||
* @param status the HTTP status to send
|
||||
*/
|
||||
private static void sendStatusAndClose(ChannelHandlerContext ctx, HttpResponseStatus status) {
|
||||
FullHttpResponse res = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status);
|
||||
res.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 0);
|
||||
ctx.writeAndFlush(res).addListener(ChannelFutureListener.CLOSE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective client IP for a request, honouring the configured trusted proxies.
|
||||
*
|
||||
@@ -352,8 +389,7 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
||||
*/
|
||||
private String resolveClientIp(ChannelHandlerContext ctx, FullHttpRequest raw) {
|
||||
SocketAddress addr = ctx.channel().remoteAddress();
|
||||
String socketIp = (addr instanceof InetSocketAddress isa && isa.getAddress() != null)
|
||||
? isa.getAddress().getHostAddress() : "unknown";
|
||||
String socketIp = (addr instanceof InetSocketAddress isa && isa.getAddress() != null) ? isa.getAddress().getHostAddress() : "unknown";
|
||||
String forwarded = raw.headers().get(ClientIp.FORWARDED_FOR_HEADER);
|
||||
return ClientIp.resolve(socketIp, forwarded, trustedProxies);
|
||||
}
|
||||
@@ -369,6 +405,9 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
||||
* @param keepAlive whether the client requested a persistent connection
|
||||
*/
|
||||
private void send(ChannelHandlerContext ctx, Response res, boolean keepAlive) {
|
||||
if (securityHeaders != null) {
|
||||
securityHeaders.apply(res, secure);
|
||||
}
|
||||
var nettyRes = new DefaultFullHttpResponse(
|
||||
HttpVersion.HTTP_1_1,
|
||||
HttpResponseStatus.valueOf(res.status()),
|
||||
@@ -399,19 +438,6 @@ public final class HttpRequestHandler extends SimpleChannelInboundHandler<FullHt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an empty-bodied response with the given status and closes the connection, used for
|
||||
* rejected WebSocket upgrades.
|
||||
*
|
||||
* @param ctx the channel context
|
||||
* @param status the HTTP status to send
|
||||
*/
|
||||
private static void sendStatusAndClose(ChannelHandlerContext ctx, HttpResponseStatus status) {
|
||||
FullHttpResponse res = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status);
|
||||
res.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 0);
|
||||
ctx.writeAndFlush(res).addListener(ChannelFutureListener.CLOSE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the channel on any unhandled pipeline exception (including read timeouts).
|
||||
*
|
||||
|
||||
@@ -5,6 +5,7 @@ import dev.coph.nextusweb.server.cores.CorsHandler;
|
||||
import dev.coph.nextusweb.server.net.TrustedProxies;
|
||||
import dev.coph.nextusweb.server.ratelimit.RateLimitGate;
|
||||
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.websocket.WebSocketConfig;
|
||||
import dev.coph.nextusweb.server.websocket.WebSocketRouter;
|
||||
@@ -43,32 +44,62 @@ import java.util.concurrent.TimeUnit;
|
||||
*/
|
||||
public final class HttpServer {
|
||||
|
||||
/** Default cap on aggregated HTTP request bodies: 1 MiB. */
|
||||
/**
|
||||
* Default cap on aggregated HTTP request bodies: 1 MiB.
|
||||
*/
|
||||
private static final int DEFAULT_MAX_HTTP_CONTENT_LENGTH = 1_048_576;
|
||||
/** Default per-connection read timeout that reaps slow/idle clients. */
|
||||
/**
|
||||
* Default per-connection read timeout that reaps slow/idle clients.
|
||||
*/
|
||||
private static final Duration DEFAULT_HTTP_READ_TIMEOUT = Duration.ofSeconds(30);
|
||||
|
||||
/** TCP port the server binds to. */
|
||||
/**
|
||||
* TCP port the server binds to.
|
||||
*/
|
||||
private final int port;
|
||||
/** Router resolving requests to handlers. */
|
||||
/**
|
||||
* Router resolving requests to handlers.
|
||||
*/
|
||||
private final Router router;
|
||||
/** Optional TLS configuration; {@code null} serves plain HTTP. */
|
||||
/**
|
||||
* Optional TLS configuration; {@code null} serves plain HTTP.
|
||||
*/
|
||||
private TlsConfig tls;
|
||||
/** Optional CORS handler; {@code null} disables CORS handling. */
|
||||
/**
|
||||
* Optional CORS handler; {@code null} disables CORS handling.
|
||||
*/
|
||||
private CorsHandler cors;
|
||||
/** Optional rate-limit gate; {@code null} disables rate limiting. */
|
||||
/**
|
||||
* Optional rate-limit gate; {@code null} disables rate limiting.
|
||||
*/
|
||||
private RateLimitGate gate;
|
||||
/** Optional authentication gate; {@code null} disables the auth layer. */
|
||||
/**
|
||||
* Optional authentication gate; {@code null} disables the auth layer.
|
||||
*/
|
||||
private AuthGate authGate;
|
||||
/** Optional WebSocket router; {@code null} disables WebSocket support. */
|
||||
/**
|
||||
* Optional security-header policy; {@code null} adds no security headers.
|
||||
*/
|
||||
private SecurityHeaders securityHeaders;
|
||||
/**
|
||||
* Optional WebSocket router; {@code null} disables WebSocket support.
|
||||
*/
|
||||
private WebSocketRouter wsRouter;
|
||||
/** WebSocket configuration; only used when {@link #wsRouter} is set. */
|
||||
/**
|
||||
* WebSocket configuration; only used when {@link #wsRouter} is set.
|
||||
*/
|
||||
private WebSocketConfig wsConfig;
|
||||
/** Trusted-proxy policy for resolving the client IP; never {@code null}. */
|
||||
/**
|
||||
* Trusted-proxy policy for resolving the client IP; never {@code null}.
|
||||
*/
|
||||
private TrustedProxies trustedProxies = TrustedProxies.none();
|
||||
/** Maximum aggregated HTTP request body size in bytes. */
|
||||
/**
|
||||
* Maximum aggregated HTTP request body size in bytes.
|
||||
*/
|
||||
private int maxHttpContentLength = DEFAULT_MAX_HTTP_CONTENT_LENGTH;
|
||||
/** Per-connection HTTP read timeout; {@code null} or non-positive disables it. */
|
||||
/**
|
||||
* Per-connection HTTP read timeout; {@code null} or non-positive disables it.
|
||||
*/
|
||||
private Duration httpReadTimeout = DEFAULT_HTTP_READ_TIMEOUT;
|
||||
|
||||
private HttpServer(int port, Router router) {
|
||||
@@ -133,6 +164,20 @@ public final class HttpServer {
|
||||
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
|
||||
* {@code X-Forwarded-For} is honoured when resolving the client IP. Defaults to
|
||||
@@ -154,7 +199,8 @@ public final class HttpServer {
|
||||
* @return this instance, for fluent chaining
|
||||
*/
|
||||
public HttpServer maxHttpContentLength(int bytes) {
|
||||
if (bytes <= 0) throw new IllegalArgumentException("maxHttpContentLength must be > 0");
|
||||
if (bytes <= 0)
|
||||
throw new IllegalArgumentException("maxHttpContentLength must be > 0");
|
||||
this.maxHttpContentLength = bytes;
|
||||
return this;
|
||||
}
|
||||
@@ -220,17 +266,17 @@ public final class HttpServer {
|
||||
channelClass = NioServerSocketChannel.class;
|
||||
}
|
||||
|
||||
// Capture configuration into effectively-final locals for the channel initializer.
|
||||
final TlsConfig tlsCfg = this.tls;
|
||||
final CorsHandler corsHandler = this.cors;
|
||||
final RateLimitGate rateLimitGate = this.gate;
|
||||
final AuthGate auth = this.authGate;
|
||||
final WebSocketRouter websocketRouter = this.wsRouter;
|
||||
final WebSocketConfig websocketConfig = this.wsConfig;
|
||||
final SecurityHeaders secHeaders = this.securityHeaders;
|
||||
final boolean tlsEnabled = tlsCfg != null;
|
||||
final TrustedProxies proxies = this.trustedProxies;
|
||||
final int maxContent = this.maxHttpContentLength;
|
||||
final long readTimeoutSeconds = (httpReadTimeout != null && !httpReadTimeout.isZero()
|
||||
&& !httpReadTimeout.isNegative()) ? Math.max(1, httpReadTimeout.toSeconds()) : 0;
|
||||
final long readTimeoutSeconds = (httpReadTimeout != null && !httpReadTimeout.isZero() && !httpReadTimeout.isNegative()) ? Math.max(1, httpReadTimeout.toSeconds()) : 0;
|
||||
|
||||
try {
|
||||
new ServerBootstrap()
|
||||
@@ -247,13 +293,11 @@ public final class HttpServer {
|
||||
pipeline.addLast("ssl", tlsCfg.newHandler(ch.alloc()));
|
||||
}
|
||||
if (readTimeoutSeconds > 0) {
|
||||
pipeline.addLast("read-timeout",
|
||||
new ReadTimeoutHandler(readTimeoutSeconds, TimeUnit.SECONDS));
|
||||
pipeline.addLast("read-timeout", new ReadTimeoutHandler(readTimeoutSeconds, TimeUnit.SECONDS));
|
||||
}
|
||||
pipeline.addLast(new HttpServerCodec())
|
||||
.addLast(new HttpObjectAggregator(maxContent))
|
||||
.addLast(new HttpRequestHandler(router, corsHandler, rateLimitGate,
|
||||
auth, proxies, websocketRouter, websocketConfig));
|
||||
.addLast(new HttpRequestHandler(router, corsHandler, rateLimitGate, auth, proxies, websocketRouter, websocketConfig, secHeaders, tlsEnabled));
|
||||
}
|
||||
})
|
||||
.bind(port).sync().channel().closeFuture().sync();
|
||||
|
||||
@@ -4,6 +4,7 @@ import dev.coph.nextusweb.server.router.Request;
|
||||
import dev.coph.nextusweb.server.router.Response;
|
||||
import dev.coph.nextusweb.server.router.Router;
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
@@ -122,7 +123,7 @@ public final class AnnotationScanner {
|
||||
*
|
||||
* @param m the method to inspect
|
||||
* @return a {@link RouteInfo} describing the route, or {@code null} if the method carries
|
||||
* no recognised route annotation
|
||||
* no recognised route annotation
|
||||
*/
|
||||
private static RouteInfo extractRoute(Method m) {
|
||||
Route r = m.getAnnotation(Route.class);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package dev.coph.nextusweb.server.auth;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Immutable mapping from request paths to the authentication requirement that applies to them,
|
||||
@@ -25,21 +21,21 @@ import java.util.Objects;
|
||||
*/
|
||||
public final class AuthConfig {
|
||||
|
||||
/** Whether a matched rule rejects unauthenticated requests or merely annotates them. */
|
||||
public enum Mode {
|
||||
/** Authentication is mandatory; failure yields {@code 401 Unauthorized}. */
|
||||
REQUIRED,
|
||||
/** Authentication is best-effort; the principal is attached if present, never rejected. */
|
||||
OPTIONAL
|
||||
}
|
||||
|
||||
/** Rule applied to every path with no more specific match, or {@code null} if none. */
|
||||
/**
|
||||
* Rule applied to every path with no more specific match, or {@code null} if none.
|
||||
*/
|
||||
private final Rule globalRule;
|
||||
/** Rules matched by exact path equality. */
|
||||
/**
|
||||
* Rules matched by exact path equality.
|
||||
*/
|
||||
private final Map<String, Rule> exactPathRules;
|
||||
/** Prefix rules, pre-sorted longest-prefix-first. */
|
||||
/**
|
||||
* Prefix rules, pre-sorted longest-prefix-first.
|
||||
*/
|
||||
private final List<PrefixRule> prefixRules;
|
||||
/** Optional {@code WWW-Authenticate} challenge sent with {@code 401} responses. */
|
||||
/**
|
||||
* Optional {@code WWW-Authenticate} challenge sent with {@code 401} responses.
|
||||
*/
|
||||
private final String challenge;
|
||||
|
||||
private AuthConfig(Builder b) {
|
||||
@@ -87,6 +83,20 @@ public final class AuthConfig {
|
||||
return challenge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a matched rule rejects unauthenticated requests or merely annotates them.
|
||||
*/
|
||||
public enum Mode {
|
||||
/**
|
||||
* Authentication is mandatory; failure yields {@code 401 Unauthorized}.
|
||||
*/
|
||||
REQUIRED,
|
||||
/**
|
||||
* Authentication is best-effort; the principal is attached if present, never rejected.
|
||||
*/
|
||||
OPTIONAL
|
||||
}
|
||||
|
||||
/**
|
||||
* An authentication rule: which authenticator to use and whether it is mandatory.
|
||||
*
|
||||
@@ -96,7 +106,9 @@ public final class AuthConfig {
|
||||
public record Rule(Authenticator authenticator, Mode mode) {
|
||||
}
|
||||
|
||||
/** Internal pairing of a path prefix with its rule. */
|
||||
/**
|
||||
* Internal pairing of a path prefix with its rule.
|
||||
*/
|
||||
private record PrefixRule(String prefix, Rule rule) {
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ import dev.coph.nextusweb.server.router.Response;
|
||||
*/
|
||||
public final class AuthGate {
|
||||
|
||||
/** The policy this gate enforces. */
|
||||
/**
|
||||
* The policy this gate enforces.
|
||||
*/
|
||||
private final AuthConfig config;
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ package dev.coph.nextusweb.server.auth;
|
||||
import dev.coph.nextusweb.server.router.Request;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Function;
|
||||
@@ -22,16 +23,6 @@ import java.util.function.Function;
|
||||
@FunctionalInterface
|
||||
public interface Authenticator {
|
||||
|
||||
/**
|
||||
* Attempts to authenticate a request.
|
||||
*
|
||||
* @param request the incoming request
|
||||
* @return the authenticated principal, or {@code null} if the request is unauthenticated
|
||||
* @throws Exception if an unexpected error occurs while validating the credential (treated as
|
||||
* an internal error, not an authentication failure)
|
||||
*/
|
||||
Principal authenticate(Request request) throws Exception;
|
||||
|
||||
/**
|
||||
* Authenticates via an API key carried in a request header (for example {@code X-API-Key}).
|
||||
* The validator maps a presented key to a {@link Principal}, or to {@code null} if the key is
|
||||
@@ -40,6 +31,7 @@ public interface Authenticator {
|
||||
* @param headerName the header carrying the API key
|
||||
* @param validator maps a presented key to a principal, or {@code null} if invalid
|
||||
* @return an API-key authenticator
|
||||
* @see #constantTimeEquals(String, String)
|
||||
*/
|
||||
static Authenticator apiKey(String headerName, Function<String, Principal> validator) {
|
||||
return request -> {
|
||||
@@ -72,6 +64,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<String, String, Principal> validator) {
|
||||
return request -> {
|
||||
@@ -125,4 +118,36 @@ public interface Authenticator {
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to authenticate a request.
|
||||
*
|
||||
* @param request the incoming request
|
||||
* @return the authenticated principal, or {@code null} if the request is unauthenticated
|
||||
* @throws Exception if an unexpected error occurs while validating the credential (treated as
|
||||
* an internal error, not an authentication failure)
|
||||
*/
|
||||
Principal authenticate(Request request) throws Exception;
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,44 +19,6 @@ import java.util.Set;
|
||||
*/
|
||||
public interface Principal {
|
||||
|
||||
/**
|
||||
* Returns the stable, unique identifier of this principal (for example a user id, an account
|
||||
* name or an API-key id). Used wherever the identity must be reduced to a single string, such
|
||||
* as principal-based rate limiting.
|
||||
*
|
||||
* @return the principal identifier; never {@code null}
|
||||
*/
|
||||
String id();
|
||||
|
||||
/**
|
||||
* Returns the roles granted to this principal, for coarse-grained authorization checks.
|
||||
*
|
||||
* @return the (possibly empty) set of roles; never {@code null}
|
||||
*/
|
||||
default Set<String> roles() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether this principal holds the given role.
|
||||
*
|
||||
* @param role the role to test for
|
||||
* @return {@code true} if {@link #roles()} contains {@code role}
|
||||
*/
|
||||
default boolean hasRole(String role) {
|
||||
return roles().contains(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns arbitrary additional attributes describing this principal (for example token
|
||||
* scopes, an email address or tenant information).
|
||||
*
|
||||
* @return the (possibly empty) claim map; never {@code null}
|
||||
*/
|
||||
default Map<String, Object> claims() {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a simple immutable principal with no roles.
|
||||
*
|
||||
@@ -93,4 +55,42 @@ public interface Principal {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the stable, unique identifier of this principal (for example a user id, an account
|
||||
* name or an API-key id). Used wherever the identity must be reduced to a single string, such
|
||||
* as principal-based rate limiting.
|
||||
*
|
||||
* @return the principal identifier; never {@code null}
|
||||
*/
|
||||
String id();
|
||||
|
||||
/**
|
||||
* Indicates whether this principal holds the given role.
|
||||
*
|
||||
* @param role the role to test for
|
||||
* @return {@code true} if {@link #roles()} contains {@code role}
|
||||
*/
|
||||
default boolean hasRole(String role) {
|
||||
return roles().contains(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the roles granted to this principal, for coarse-grained authorization checks.
|
||||
*
|
||||
* @return the (possibly empty) set of roles; never {@code null}
|
||||
*/
|
||||
default Set<String> roles() {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns arbitrary additional attributes describing this principal (for example token
|
||||
* scopes, an email address or tenant information).
|
||||
*
|
||||
* @return the (possibly empty) claim map; never {@code null}
|
||||
*/
|
||||
default Map<String, Object> claims() {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,19 +19,33 @@ import java.util.Set;
|
||||
*/
|
||||
public final class CorsConfig {
|
||||
|
||||
/** Explicit set of allowed origins; ignored when {@link #allowAnyOrigin} is {@code true}. */
|
||||
/**
|
||||
* Explicit set of allowed origins; ignored when {@link #allowAnyOrigin} is {@code true}.
|
||||
*/
|
||||
private final Set<String> allowedOrigins;
|
||||
/** HTTP methods advertised as allowed in preflight responses. */
|
||||
/**
|
||||
* HTTP methods advertised as allowed in preflight responses.
|
||||
*/
|
||||
private final Set<HttpMethod> allowedMethods;
|
||||
/** Request headers advertised as allowed in preflight responses. */
|
||||
/**
|
||||
* Request headers advertised as allowed in preflight responses.
|
||||
*/
|
||||
private final Set<String> allowedHeaders;
|
||||
/** Response headers exposed to the browser via {@code Access-Control-Expose-Headers}. */
|
||||
/**
|
||||
* Response headers exposed to the browser via {@code Access-Control-Expose-Headers}.
|
||||
*/
|
||||
private final Set<String> exposedHeaders;
|
||||
/** Whether credentialed (cookie/authorization) requests are permitted. */
|
||||
/**
|
||||
* Whether credentialed (cookie/authorization) requests are permitted.
|
||||
*/
|
||||
private final boolean allowCredentials;
|
||||
/** How long (in seconds) a preflight response may be cached by the browser. */
|
||||
/**
|
||||
* How long (in seconds) a preflight response may be cached by the browser.
|
||||
*/
|
||||
private final long maxAgeSeconds;
|
||||
/** Whether any origin is allowed (the {@code *} wildcard). */
|
||||
/**
|
||||
* Whether any origin is allowed (the {@code *} wildcard).
|
||||
*/
|
||||
private final boolean allowAnyOrigin;
|
||||
|
||||
/**
|
||||
@@ -90,7 +104,7 @@ public final class CorsConfig {
|
||||
*
|
||||
* @param origin the {@code Origin} header value, may be {@code null}
|
||||
* @return {@code true} if the origin is allowed; {@code false} for a {@code null} origin or
|
||||
* one not in the allow-list (unless any origin is permitted)
|
||||
* one not in the allow-list (unless any origin is permitted)
|
||||
*/
|
||||
public boolean isOriginAllowed(String origin) {
|
||||
if (origin == null) return false;
|
||||
@@ -157,19 +171,33 @@ public final class CorsConfig {
|
||||
* be called multiple times to accumulate values.
|
||||
*/
|
||||
public static final class Builder {
|
||||
/** Accumulated explicit origins. */
|
||||
/**
|
||||
* Accumulated explicit origins.
|
||||
*/
|
||||
private final Set<String> allowedOrigins = new HashSet<>();
|
||||
/** Accumulated allowed methods. */
|
||||
/**
|
||||
* Accumulated allowed methods.
|
||||
*/
|
||||
private final Set<HttpMethod> allowedMethods = new HashSet<>();
|
||||
/** Accumulated allowed request headers. */
|
||||
/**
|
||||
* Accumulated allowed request headers.
|
||||
*/
|
||||
private final Set<String> allowedHeaders = new HashSet<>();
|
||||
/** Accumulated exposed response headers. */
|
||||
/**
|
||||
* Accumulated exposed response headers.
|
||||
*/
|
||||
private final Set<String> exposedHeaders = new HashSet<>();
|
||||
/** Whether credentialed requests are permitted; defaults to {@code false}. */
|
||||
/**
|
||||
* Whether credentialed requests are permitted; defaults to {@code false}.
|
||||
*/
|
||||
private boolean allowCredentials = false;
|
||||
/** Preflight cache lifetime in seconds; defaults to {@code 0} (disabled). */
|
||||
/**
|
||||
* Preflight cache lifetime in seconds; defaults to {@code 0} (disabled).
|
||||
*/
|
||||
private long maxAgeSeconds = 0;
|
||||
/** Whether any origin is permitted; defaults to {@code false}. */
|
||||
/**
|
||||
* Whether any origin is permitted; defaults to {@code false}.
|
||||
*/
|
||||
private boolean allowAnyOrigin = false;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package dev.coph.nextusweb.server.cores;
|
||||
|
||||
import dev.coph.nextusweb.server.router.Response;
|
||||
import io.netty.handler.codec.http.*;
|
||||
import io.netty.handler.codec.http.HttpHeaders;
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -22,13 +23,21 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
public final class CorsHandler {
|
||||
|
||||
/** The policy this handler enforces. */
|
||||
/**
|
||||
* The policy this handler enforces.
|
||||
*/
|
||||
private final CorsConfig config;
|
||||
/** Pre-joined {@code Access-Control-Allow-Methods} value. */
|
||||
/**
|
||||
* Pre-joined {@code Access-Control-Allow-Methods} value.
|
||||
*/
|
||||
private final String allowedMethodsHeader;
|
||||
/** Pre-joined {@code Access-Control-Allow-Headers} value. */
|
||||
/**
|
||||
* Pre-joined {@code Access-Control-Allow-Headers} value.
|
||||
*/
|
||||
private final String allowedHeadersHeader;
|
||||
/** Pre-joined {@code Access-Control-Expose-Headers} value. */
|
||||
/**
|
||||
* Pre-joined {@code Access-Control-Expose-Headers} value.
|
||||
*/
|
||||
private final String exposedHeadersHeader;
|
||||
|
||||
/**
|
||||
@@ -44,6 +53,55 @@ public final class CorsHandler {
|
||||
this.exposedHeadersHeader = String.join(", ", config.exposedHeaders());
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a request is a CORS preflight request, i.e. an {@code OPTIONS}
|
||||
* request carrying an {@code Access-Control-Request-Method} header.
|
||||
*
|
||||
* @param method the request method
|
||||
* @param headers the request headers
|
||||
* @return {@code true} if the request is a preflight request
|
||||
*/
|
||||
public boolean isPreflight(HttpMethod method, HttpHeaders headers) {
|
||||
return method.equals(HttpMethod.OPTIONS)
|
||||
&& headers.contains("Access-Control-Request-Method");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the response to a CORS preflight request.
|
||||
*
|
||||
* <p>If the origin is missing or disallowed the response is a {@code 403 Forbidden};
|
||||
* otherwise it is a {@code 204 No Content} carrying the allowed methods and headers, the
|
||||
* requested headers echoed back when no explicit allow-list is configured, and the
|
||||
* {@code Access-Control-Max-Age} cache hint when configured.</p>
|
||||
*
|
||||
* @param origin the request's {@code Origin} header, may be {@code null}
|
||||
* @param requestHeaders the request's headers (used to read
|
||||
* {@code Access-Control-Request-Headers})
|
||||
* @return the fully populated preflight response
|
||||
*/
|
||||
public Response handlePreflight(String origin, HttpHeaders requestHeaders) {
|
||||
Response res = new Response().status(204);
|
||||
|
||||
if (!config.isOriginAllowed(origin)) {
|
||||
return res.status(403);
|
||||
}
|
||||
|
||||
applyHeaders(origin, res);
|
||||
res.header("Access-Control-Allow-Methods", allowedMethodsHeader);
|
||||
|
||||
String requestedHeaders = requestHeaders.get("Access-Control-Request-Headers");
|
||||
if (!allowedHeadersHeader.isEmpty()) {
|
||||
res.header("Access-Control-Allow-Headers", allowedHeadersHeader);
|
||||
} else if (requestedHeaders != null) {
|
||||
res.header("Access-Control-Allow-Headers", requestedHeaders);
|
||||
}
|
||||
|
||||
if (config.maxAgeSeconds() > 0) {
|
||||
res.header("Access-Control-Max-Age", String.valueOf(config.maxAgeSeconds()));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the {@code Access-Control-Allow-Origin} (and related) headers to a response, if and
|
||||
@@ -77,54 +135,4 @@ public final class CorsHandler {
|
||||
res.header("Access-Control-Expose-Headers", exposedHeadersHeader);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a request is a CORS preflight request, i.e. an {@code OPTIONS}
|
||||
* request carrying an {@code Access-Control-Request-Method} header.
|
||||
*
|
||||
* @param method the request method
|
||||
* @param headers the request headers
|
||||
* @return {@code true} if the request is a preflight request
|
||||
*/
|
||||
public boolean isPreflight(HttpMethod method, HttpHeaders headers) {
|
||||
return method.equals(HttpMethod.OPTIONS)
|
||||
&& headers.contains("Access-Control-Request-Method");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the response to a CORS preflight request.
|
||||
*
|
||||
* <p>If the origin is missing or disallowed the response is a {@code 403 Forbidden};
|
||||
* otherwise it is a {@code 204 No Content} carrying the allowed methods and headers, the
|
||||
* requested headers echoed back when no explicit allow-list is configured, and the
|
||||
* {@code Access-Control-Max-Age} cache hint when configured.</p>
|
||||
*
|
||||
* @param origin the request's {@code Origin} header, may be {@code null}
|
||||
* @param requestHeaders the request's headers (used to read
|
||||
* {@code Access-Control-Request-Headers})
|
||||
* @return the fully populated preflight response
|
||||
*/
|
||||
public Response handlePreflight(String origin, HttpHeaders requestHeaders) {
|
||||
Response res = new Response().status(204);
|
||||
|
||||
if (origin == null || !config.isOriginAllowed(origin)) {
|
||||
return res.status(403);
|
||||
}
|
||||
|
||||
applyHeaders(origin, res);
|
||||
res.header("Access-Control-Allow-Methods", allowedMethodsHeader);
|
||||
|
||||
String requestedHeaders = requestHeaders.get("Access-Control-Request-Headers");
|
||||
if (!allowedHeadersHeader.isEmpty()) {
|
||||
res.header("Access-Control-Allow-Headers", allowedHeadersHeader);
|
||||
} else if (requestedHeaders != null) {
|
||||
res.header("Access-Control-Allow-Headers", requestedHeaders);
|
||||
}
|
||||
|
||||
if (config.maxAgeSeconds() > 0) {
|
||||
res.header("Access-Control-Max-Age", String.valueOf(config.maxAgeSeconds()));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,5 +26,6 @@ public final class JsonMapper {
|
||||
/**
|
||||
* Private constructor preventing instantiation of this static holder class.
|
||||
*/
|
||||
private JsonMapper() {}
|
||||
private JsonMapper() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ package dev.coph.nextusweb.server.net;
|
||||
*/
|
||||
public final class ClientIp {
|
||||
|
||||
/** The de-facto standard header proxies use to record the originating client chain. */
|
||||
/**
|
||||
* The de-facto standard header proxies use to record the originating client chain.
|
||||
*/
|
||||
public static final String FORWARDED_FOR_HEADER = "X-Forwarded-For";
|
||||
|
||||
private ClientIp() {
|
||||
@@ -31,8 +33,7 @@ public final class ClientIp {
|
||||
* @return the resolved client IP
|
||||
*/
|
||||
public static String resolve(String socketIp, String forwardedForHeader, TrustedProxies trusted) {
|
||||
if (forwardedForHeader == null || forwardedForHeader.isBlank()
|
||||
|| !trusted.isTrusted(socketIp)) {
|
||||
if (forwardedForHeader == null || forwardedForHeader.isBlank() || !trusted.isTrusted(socketIp)) {
|
||||
return socketIp;
|
||||
}
|
||||
|
||||
@@ -45,8 +46,6 @@ public final class ClientIp {
|
||||
}
|
||||
}
|
||||
|
||||
// Every hop in the chain is a trusted proxy; fall back to the originating (left-most)
|
||||
// entry, or the socket address if the header was effectively empty.
|
||||
String first = hops[0].trim();
|
||||
return first.isEmpty() ? socketIp : first;
|
||||
}
|
||||
|
||||
@@ -22,14 +22,22 @@ import java.util.List;
|
||||
*/
|
||||
public final class TrustedProxies {
|
||||
|
||||
/** Shared instance that trusts no peer; forwarded headers are always ignored. */
|
||||
/**
|
||||
* Shared instance that trusts no peer; forwarded headers are always ignored.
|
||||
*/
|
||||
private static final TrustedProxies NONE = new TrustedProxies(List.of(), false);
|
||||
/** Shared instance that trusts every peer; forwarded headers are always honoured. */
|
||||
/**
|
||||
* Shared instance that trusts every peer; forwarded headers are always honoured.
|
||||
*/
|
||||
private static final TrustedProxies ALL = new TrustedProxies(List.of(), true);
|
||||
|
||||
/** Parsed CIDR ranges of trusted proxies. */
|
||||
/**
|
||||
* Parsed CIDR ranges of trusted proxies.
|
||||
*/
|
||||
private final List<Cidr> cidrs;
|
||||
/** When {@code true}, every peer is trusted regardless of {@link #cidrs}. */
|
||||
/**
|
||||
* When {@code true}, every peer is trusted regardless of {@link #cidrs}.
|
||||
*/
|
||||
private final boolean trustAll;
|
||||
|
||||
private TrustedProxies(List<Cidr> cidrs, boolean trustAll) {
|
||||
@@ -129,7 +137,7 @@ public final class TrustedProxies {
|
||||
}
|
||||
|
||||
boolean contains(byte[] addr) {
|
||||
if (addr.length != base.length) return false; // different address family
|
||||
if (addr.length != base.length) return false;
|
||||
int fullBytes = prefixBits / 8;
|
||||
for (int i = 0; i < fullBytes; i++) {
|
||||
if (addr[i] != base[i]) return false;
|
||||
@@ -137,16 +145,14 @@ public final class TrustedProxies {
|
||||
int remainingBits = prefixBits % 8;
|
||||
if (remainingBits != 0) {
|
||||
int mask = 0xFF << (8 - remainingBits) & 0xFF;
|
||||
if ((addr[fullBytes] & mask) != (base[fullBytes] & mask)) return false;
|
||||
return (addr[fullBytes] & mask) == (base[fullBytes] & mask);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Records with array components get identity-based equals/hashCode by default; provide
|
||||
// value semantics so deduplication and tests behave intuitively.
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
return o instanceof Cidr c && prefixBits == c.prefixBits && Arrays.equals(base, c.base);
|
||||
return o instanceof Cidr(byte[] base1, int bits) && prefixBits == bits && Arrays.equals(base, base1);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -17,11 +17,17 @@ import java.util.concurrent.atomic.AtomicLong;
|
||||
*/
|
||||
public final class FixedWindowLimiter implements RateLimiter {
|
||||
|
||||
/** Maximum number of requests permitted per window. */
|
||||
/**
|
||||
* Maximum number of requests permitted per window.
|
||||
*/
|
||||
private final long limit;
|
||||
/** Window length in nanoseconds. */
|
||||
/**
|
||||
* Window length in nanoseconds.
|
||||
*/
|
||||
private final long windowNanos;
|
||||
/** Per-key windows, created on demand. */
|
||||
/**
|
||||
* Per-key windows, created on demand.
|
||||
*/
|
||||
private final ConcurrentHashMap<String, Window> windows = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
@@ -62,9 +68,13 @@ public final class FixedWindowLimiter implements RateLimiter {
|
||||
* within it.
|
||||
*/
|
||||
private static final class Window {
|
||||
/** Start timestamp of the current window, in nanoseconds. */
|
||||
/**
|
||||
* Start timestamp of the current window, in nanoseconds.
|
||||
*/
|
||||
final AtomicLong windowStart;
|
||||
/** Number of requests counted in the current window. */
|
||||
/**
|
||||
* Number of requests counted in the current window.
|
||||
*/
|
||||
final AtomicLong count;
|
||||
|
||||
/**
|
||||
@@ -92,7 +102,7 @@ public final class FixedWindowLimiter implements RateLimiter {
|
||||
* @param limit the per-window request limit
|
||||
* @param windowNanos the window length in nanoseconds
|
||||
* @return an allow result with the remaining quota, or a deny result with the time until
|
||||
* the window resets
|
||||
* the window resets
|
||||
*/
|
||||
synchronized Result tryAcquire(long now, long limit, long windowNanos) {
|
||||
long start = windowStart.get();
|
||||
|
||||
@@ -21,15 +21,6 @@ import dev.coph.nextusweb.server.router.Request;
|
||||
@FunctionalInterface
|
||||
public interface KeyResolver {
|
||||
|
||||
/**
|
||||
* Resolves the rate-limit key for a request.
|
||||
*
|
||||
* @param request the incoming request (headers, cookies, attached principal, ...)
|
||||
* @param clientIp the resolved client IP, honouring trusted proxies
|
||||
* @return the key the request should be counted against; never {@code null}
|
||||
*/
|
||||
String resolve(Request request, String clientIp);
|
||||
|
||||
/**
|
||||
* Returns a resolver that keys purely on the resolved client IP. This is the spoofing-safe
|
||||
* replacement for the old header-trusting behaviour: the IP has already been derived through
|
||||
@@ -85,4 +76,13 @@ public interface KeyResolver {
|
||||
return p != null ? "p:" + p.id() : "ip:" + clientIp;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the rate-limit key for a request.
|
||||
*
|
||||
* @param request the incoming request (headers, cookies, attached principal, ...)
|
||||
* @param clientIp the resolved client IP, honouring trusted proxies
|
||||
* @return the key the request should be counted against; never {@code null}
|
||||
*/
|
||||
String resolve(Request request, String clientIp);
|
||||
}
|
||||
|
||||
@@ -19,11 +19,17 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
*/
|
||||
public final class LeakyBucketLimiter implements RateLimiter {
|
||||
|
||||
/** Maximum water level (number of queued units) the bucket tolerates. */
|
||||
/**
|
||||
* Maximum water level (number of queued units) the bucket tolerates.
|
||||
*/
|
||||
private final long capacity;
|
||||
/** Nanoseconds it takes for exactly one unit to leak out. */
|
||||
/**
|
||||
* Nanoseconds it takes for exactly one unit to leak out.
|
||||
*/
|
||||
private final long leakIntervalNanos;
|
||||
/** Per-key buckets, created on demand. */
|
||||
/**
|
||||
* Per-key buckets, created on demand.
|
||||
*/
|
||||
private final ConcurrentHashMap<String, LeakyBucket> buckets = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
@@ -62,18 +68,17 @@ public final class LeakyBucketLimiter implements RateLimiter {
|
||||
/**
|
||||
* A single client's leaky bucket, tracking the current water level and the timestamp up to
|
||||
* which leakage has been accounted for as one atomic unit.
|
||||
*
|
||||
* @param state Holds the current {@code (waterLevel, lastLeakNanos)} pair as one atomic unit.
|
||||
*/
|
||||
private static final class LeakyBucket {
|
||||
/** Holds the current {@code (waterLevel, lastLeakNanos)} pair as one atomic unit. */
|
||||
private final AtomicReference<State> state;
|
||||
|
||||
private record LeakyBucket(AtomicReference<State> state) {
|
||||
/**
|
||||
* Creates an empty bucket.
|
||||
*
|
||||
* @param now the creation timestamp in nanoseconds
|
||||
* @param nowNanos the creation timestamp in nanoseconds
|
||||
*/
|
||||
LeakyBucket(long now) {
|
||||
this.state = new AtomicReference<>(new State(0, now));
|
||||
private LeakyBucket(long nowNanos) {
|
||||
this(new AtomicReference<>(new State(0, nowNanos)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,7 +100,7 @@ public final class LeakyBucketLimiter implements RateLimiter {
|
||||
* @param capacity the bucket capacity
|
||||
* @param leakIntervalNanos the nanoseconds per leaked unit
|
||||
* @return an allow result with the remaining headroom, or a deny result with a retry
|
||||
* hint when the bucket is full
|
||||
* hint when the bucket is full
|
||||
*/
|
||||
Result tryAcquire(long now, long capacity, long leakIntervalNanos) {
|
||||
while (true) {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
package dev.coph.nextusweb.server.ratelimit;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Immutable mapping from request paths to the {@link Rule rate-limit rules} that apply to them.
|
||||
@@ -24,13 +18,21 @@ import java.util.Set;
|
||||
*/
|
||||
public final class RateLimitConfig {
|
||||
|
||||
/** Rule applied to every request, or {@code null} if no global rule is configured. */
|
||||
/**
|
||||
* Rule applied to every request, or {@code null} if no global rule is configured.
|
||||
*/
|
||||
private final Rule globalRule;
|
||||
/** Rules matched by exact path equality, keyed by path. */
|
||||
/**
|
||||
* Rules matched by exact path equality, keyed by path.
|
||||
*/
|
||||
private final Map<String, Rule> exactPathRules;
|
||||
/** Prefix rules, pre-sorted longest-prefix-first so the most specific match wins. */
|
||||
/**
|
||||
* Prefix rules, pre-sorted longest-prefix-first so the most specific match wins.
|
||||
*/
|
||||
private final List<PrefixRule> prefixRules;
|
||||
/** Every distinct limiter referenced by any rule, by identity; used for periodic cleanup. */
|
||||
/**
|
||||
* Every distinct limiter referenced by any rule, by identity; used for periodic cleanup.
|
||||
*/
|
||||
private final Set<RateLimiter> allLimiters;
|
||||
|
||||
/**
|
||||
@@ -46,9 +48,6 @@ public final class RateLimitConfig {
|
||||
.sorted((a, c) -> Integer.compare(c.prefix.length(), a.prefix.length()))
|
||||
.toList();
|
||||
|
||||
// Collect the distinct limiter instances once so the gate's periodic cleanup can iterate
|
||||
// them. Identity-based de-duplication keeps a limiter shared across several rules from
|
||||
// being cleaned multiple times per pass.
|
||||
Set<RateLimiter> limiters = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
if (globalRule != null) limiters.add(globalRule.limiter());
|
||||
for (Rule r : exactPathRules.values()) limiters.add(r.limiter());
|
||||
@@ -127,11 +126,17 @@ public final class RateLimitConfig {
|
||||
* Fluent builder for {@link RateLimitConfig}.
|
||||
*/
|
||||
public static final class Builder {
|
||||
/** Accumulated exact-path rules, keyed by path. */
|
||||
/**
|
||||
* Accumulated exact-path rules, keyed by path.
|
||||
*/
|
||||
private final Map<String, Rule> exactPathRules = new HashMap<>();
|
||||
/** Accumulated prefix rules. */
|
||||
/**
|
||||
* Accumulated prefix rules.
|
||||
*/
|
||||
private final List<PrefixRule> prefixRules = new ArrayList<>();
|
||||
/** The global rule, if configured. */
|
||||
/**
|
||||
* The global rule, if configured.
|
||||
*/
|
||||
private Rule globalRule;
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,14 +22,22 @@ import java.util.concurrent.TimeUnit;
|
||||
*/
|
||||
public final class RateLimitGate {
|
||||
|
||||
/** Default idle age after which per-key limiter state is eligible for eviction. */
|
||||
/**
|
||||
* Default idle age after which per-key limiter state is eligible for eviction.
|
||||
*/
|
||||
private static final long DEFAULT_STALE_AFTER_NANOS = 10L * 60 * 1_000_000_000L;
|
||||
|
||||
/** The rule set this gate enforces. */
|
||||
/**
|
||||
* The rule set this gate enforces.
|
||||
*/
|
||||
private final RateLimitConfig config;
|
||||
/** Idle age (nanoseconds) after which a limiter's per-key state may be evicted. */
|
||||
/**
|
||||
* Idle age (nanoseconds) after which a limiter's per-key state may be evicted.
|
||||
*/
|
||||
private final long staleAfterNanos;
|
||||
/** Single-threaded scheduler driving periodic cleanup of stale buckets. */
|
||||
/**
|
||||
* Single-threaded scheduler driving periodic cleanup of stale buckets.
|
||||
*/
|
||||
private final ScheduledExecutorService cleanup;
|
||||
|
||||
/**
|
||||
@@ -62,6 +70,38 @@ public final class RateLimitGate {
|
||||
cleanup.scheduleAtFixedRate(this::doCleanup, 5, 5, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic cleanup hook invoked by the background scheduler. Asks every configured limiter to
|
||||
* evict per-key state idle for longer than {@link #staleAfterNanos}. A failure cleaning one
|
||||
* limiter must not abort the others or kill the scheduler, so each call is guarded.
|
||||
*/
|
||||
private void doCleanup() {
|
||||
for (RateLimiter limiter : config.allLimiters()) {
|
||||
try {
|
||||
limiter.cleanup(staleAfterNanos);
|
||||
} catch (RuntimeException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the standard rate-limit headers ({@code X-RateLimit-Limit},
|
||||
* {@code X-RateLimit-Remaining}, and {@code Retry-After} when denied) onto a response.
|
||||
*
|
||||
* <p>Does nothing when {@code result} is {@code null} (no rule applied). The retry hint is
|
||||
* rounded up to whole seconds as required by the {@code Retry-After} header.</p>
|
||||
*
|
||||
* @param result the limiting result, may be {@code null}
|
||||
* @param res the response to decorate
|
||||
*/
|
||||
public static void applyHeaders(RateLimiter.Result result, Response res) {
|
||||
if (result == null) return;
|
||||
res.header("X-RateLimit-Limit", String.valueOf(result.limit()));
|
||||
res.header("X-RateLimit-Remaining", String.valueOf(Math.max(0, result.remaining())));
|
||||
if (!result.allowed()) {
|
||||
res.header("Retry-After", String.valueOf((result.retryAfterMillis() + 999) / 1000));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates all rules applicable to the given path and decides whether the request may
|
||||
@@ -97,42 +137,10 @@ public final class RateLimitGate {
|
||||
return strictest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the standard rate-limit headers ({@code X-RateLimit-Limit},
|
||||
* {@code X-RateLimit-Remaining}, and {@code Retry-After} when denied) onto a response.
|
||||
*
|
||||
* <p>Does nothing when {@code result} is {@code null} (no rule applied). The retry hint is
|
||||
* rounded up to whole seconds as required by the {@code Retry-After} header.</p>
|
||||
*
|
||||
* @param result the limiting result, may be {@code null}
|
||||
* @param res the response to decorate
|
||||
*/
|
||||
public static void applyHeaders(RateLimiter.Result result, Response res) {
|
||||
if (result == null) return;
|
||||
res.header("X-RateLimit-Limit", String.valueOf(result.limit()));
|
||||
res.header("X-RateLimit-Remaining", String.valueOf(Math.max(0, result.remaining())));
|
||||
if (!result.allowed()) {
|
||||
res.header("Retry-After", String.valueOf((result.retryAfterMillis() + 999) / 1000));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic cleanup hook invoked by the background scheduler. Asks every configured limiter to
|
||||
* evict per-key state idle for longer than {@link #staleAfterNanos}. A failure cleaning one
|
||||
* limiter must not abort the others or kill the scheduler, so each call is guarded.
|
||||
*/
|
||||
private void doCleanup() {
|
||||
for (RateLimiter limiter : config.allLimiters()) {
|
||||
try {
|
||||
limiter.cleanup(staleAfterNanos);
|
||||
} catch (RuntimeException ignored) {
|
||||
// Best-effort eviction; never let one limiter break the cleanup cycle.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the background cleanup scheduler. Should be called when the server shuts down.
|
||||
*/
|
||||
public void shutdown() { cleanup.shutdown(); }
|
||||
public void shutdown() {
|
||||
cleanup.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ public interface RateLimiter {
|
||||
* @param key the logical bucket key (for example a client IP or user identifier)
|
||||
* @param nowNanos the current time in nanoseconds, typically {@link System#nanoTime()}
|
||||
* @return a {@link Result} describing whether the request was allowed and the remaining
|
||||
* quota
|
||||
* quota
|
||||
*/
|
||||
Result tryAcquire(String key, long nowNanos);
|
||||
|
||||
|
||||
@@ -18,11 +18,17 @@ import java.util.concurrent.atomic.AtomicLong;
|
||||
*/
|
||||
public final class SlidingWindowLimiter implements RateLimiter {
|
||||
|
||||
/** Maximum effective (weighted) number of requests per window. */
|
||||
/**
|
||||
* Maximum effective (weighted) number of requests per window.
|
||||
*/
|
||||
private final long limit;
|
||||
/** Window length in nanoseconds. */
|
||||
/**
|
||||
* Window length in nanoseconds.
|
||||
*/
|
||||
private final long windowNanos;
|
||||
/** Per-key sliding windows, created on demand. */
|
||||
/**
|
||||
* Per-key sliding windows, created on demand.
|
||||
*/
|
||||
private final ConcurrentHashMap<String, SlidingWindow> windows = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
@@ -64,11 +70,17 @@ public final class SlidingWindowLimiter implements RateLimiter {
|
||||
* previous window counts.
|
||||
*/
|
||||
private static final class SlidingWindow {
|
||||
/** Start timestamp of the current window, in nanoseconds. */
|
||||
/**
|
||||
* Start timestamp of the current window, in nanoseconds.
|
||||
*/
|
||||
final AtomicLong windowStart;
|
||||
/** Request count accumulated in the current window. */
|
||||
/**
|
||||
* Request count accumulated in the current window.
|
||||
*/
|
||||
final AtomicLong currentCount;
|
||||
/** Request count carried over from the immediately preceding window. */
|
||||
/**
|
||||
* Request count carried over from the immediately preceding window.
|
||||
*/
|
||||
final AtomicLong previousCount;
|
||||
|
||||
/**
|
||||
@@ -95,7 +107,7 @@ public final class SlidingWindowLimiter implements RateLimiter {
|
||||
* @param limit the per-window effective limit
|
||||
* @param windowNanos the window length in nanoseconds
|
||||
* @return an allow result with the remaining quota, or a deny result with the time until
|
||||
* the window slides far enough to admit the request
|
||||
* the window slides far enough to admit the request
|
||||
*/
|
||||
synchronized Result tryAcquire(long now, long limit, long windowNanos) {
|
||||
long start = windowStart.get();
|
||||
|
||||
@@ -20,13 +20,21 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
*/
|
||||
public final class TokenBucketLimiter implements RateLimiter {
|
||||
|
||||
/** Maximum number of tokens a bucket can hold (the burst allowance). */
|
||||
/**
|
||||
* Maximum number of tokens a bucket can hold (the burst allowance).
|
||||
*/
|
||||
private final long capacity;
|
||||
/** Refill rate expressed as tokens added per nanosecond. */
|
||||
/**
|
||||
* Refill rate expressed as tokens added per nanosecond.
|
||||
*/
|
||||
private final double tokensPerNano;
|
||||
/** Approximate nanoseconds between single-token refills, used for retry hints. */
|
||||
/**
|
||||
* Approximate nanoseconds between single-token refills, used for retry hints.
|
||||
*/
|
||||
private final long refillIntervalNs;
|
||||
/** Per-key buckets, created on demand. */
|
||||
/**
|
||||
* Per-key buckets, created on demand.
|
||||
*/
|
||||
private final ConcurrentHashMap<String, Bucket> buckets = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
@@ -71,7 +79,9 @@ public final class TokenBucketLimiter implements RateLimiter {
|
||||
* single {@link AtomicReference} so updates are atomic as a unit.
|
||||
*/
|
||||
private static final class Bucket {
|
||||
/** Holds the current {@code (tokensFixed, lastRefillNanos)} pair as one atomic unit. */
|
||||
/**
|
||||
* Holds the current {@code (tokensFixed, lastRefillNanos)} pair as one atomic unit.
|
||||
*/
|
||||
private final AtomicReference<State> state;
|
||||
|
||||
/**
|
||||
@@ -104,7 +114,7 @@ public final class TokenBucketLimiter implements RateLimiter {
|
||||
* @param tokensPerNano the refill rate in tokens per nanosecond
|
||||
* @param refillIntervalNs the nominal nanoseconds per token (kept for retry computation)
|
||||
* @return an allow result with the remaining tokens, or a deny result with a retry
|
||||
* hint when fewer than one token is available
|
||||
* hint when fewer than one token is available
|
||||
*/
|
||||
Result tryAcquire(long now, long capacity, double tokensPerNano, long refillIntervalNs) {
|
||||
long oneTokenFixed = 1_000_000_000L;
|
||||
|
||||
@@ -3,14 +3,19 @@ package dev.coph.nextusweb.server.router;
|
||||
import dev.coph.nextusweb.server.auth.Principal;
|
||||
import dev.coph.nextusweb.server.json.JsonMapper;
|
||||
import dev.coph.nextusweb.server.router.exception.BadRequestException;
|
||||
import io.netty.handler.codec.http.*;
|
||||
import io.netty.handler.codec.http.FullHttpRequest;
|
||||
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||
import io.netty.handler.codec.http.HttpMethod;
|
||||
import io.netty.handler.codec.http.QueryStringDecoder;
|
||||
import io.netty.handler.codec.http.cookie.Cookie;
|
||||
import io.netty.handler.codec.http.cookie.ServerCookieDecoder;
|
||||
import io.netty.util.CharsetUtil;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A convenience wrapper around a Netty {@link FullHttpRequest} that exposes the parts of an
|
||||
@@ -23,28 +28,44 @@ import java.util.*;
|
||||
*/
|
||||
public final class Request {
|
||||
|
||||
/** The underlying Netty request this wrapper delegates to. */
|
||||
/**
|
||||
* The underlying Netty request this wrapper delegates to.
|
||||
*/
|
||||
private final FullHttpRequest raw;
|
||||
|
||||
/** Path parameters captured by the router while matching, keyed by name. */
|
||||
/**
|
||||
* Path parameters captured by the router while matching, keyed by name.
|
||||
*/
|
||||
private final Map<String, String> pathParams;
|
||||
|
||||
/** Lazily decoded query-string parameters; {@code null} until first accessed. */
|
||||
/**
|
||||
* Lazily decoded query-string parameters; {@code null} until first accessed.
|
||||
*/
|
||||
private Map<String, List<String>> queryParams;
|
||||
|
||||
/** Lazily parsed JSON body; {@code null} until {@link #json()} is first called. */
|
||||
/**
|
||||
* Lazily parsed JSON body; {@code null} until {@link #json()} is first called.
|
||||
*/
|
||||
private JsonNode jsonCache;
|
||||
|
||||
/** Lazily decoded request cookies, keyed by name; {@code null} until first accessed. */
|
||||
/**
|
||||
* Lazily decoded request cookies, keyed by name; {@code null} until first accessed.
|
||||
*/
|
||||
private Map<String, String> cookies;
|
||||
|
||||
/** Lazily created bag of per-request attributes set by middlewares/handlers. */
|
||||
/**
|
||||
* Lazily created bag of per-request attributes set by middlewares/handlers.
|
||||
*/
|
||||
private Map<String, Object> attributes;
|
||||
|
||||
/** Resolved client IP (honouring trusted proxies); {@code null} until set by the pipeline. */
|
||||
/**
|
||||
* Resolved client IP (honouring trusted proxies); {@code null} until set by the pipeline.
|
||||
*/
|
||||
private String clientIp;
|
||||
|
||||
/** Authenticated principal attached by the auth layer, or {@code null} if unauthenticated. */
|
||||
/**
|
||||
* Authenticated principal attached by the auth layer, or {@code null} if unauthenticated.
|
||||
*/
|
||||
private Principal principal;
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package dev.coph.nextusweb.server.router;
|
||||
|
||||
import dev.coph.nextusweb.server.json.JsonMapper;
|
||||
import io.netty.handler.codec.http.*;
|
||||
import io.netty.handler.codec.http.DefaultHttpHeaders;
|
||||
import io.netty.handler.codec.http.HttpHeaderNames;
|
||||
import io.netty.handler.codec.http.HttpHeaders;
|
||||
import io.netty.util.CharsetUtil;
|
||||
import tools.jackson.core.JacksonException;
|
||||
|
||||
@@ -16,13 +18,17 @@ import tools.jackson.core.JacksonException;
|
||||
*/
|
||||
public final class Response {
|
||||
|
||||
/** HTTP status code; defaults to {@code 200}. */
|
||||
private int status = 200;
|
||||
|
||||
/** Response headers accumulated by the handler. */
|
||||
/**
|
||||
* Response headers accumulated by the handler.
|
||||
*/
|
||||
private final HttpHeaders headers = new DefaultHttpHeaders();
|
||||
|
||||
/** Response body bytes; defaults to an empty array. */
|
||||
/**
|
||||
* HTTP status code; defaults to {@code 200}.
|
||||
*/
|
||||
private int status = 200;
|
||||
/**
|
||||
* Response body bytes; defaults to an empty array.
|
||||
*/
|
||||
private byte[] body = new byte[0];
|
||||
|
||||
/**
|
||||
@@ -38,7 +44,10 @@ public final class Response {
|
||||
* @param s the status code
|
||||
* @return this response, for fluent chaining
|
||||
*/
|
||||
public Response status(int s) { this.status = s; return this; }
|
||||
public Response status(int s) {
|
||||
this.status = s;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a response header, replacing any existing value for the same name.
|
||||
@@ -101,19 +110,25 @@ public final class Response {
|
||||
*
|
||||
* @return the status code
|
||||
*/
|
||||
public int status() { return status; }
|
||||
public int status() {
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the accumulated response headers.
|
||||
*
|
||||
* @return the headers
|
||||
*/
|
||||
public HttpHeaders headers() { return headers; }
|
||||
public HttpHeaders headers() {
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the response body bytes.
|
||||
*
|
||||
* @return the body bytes
|
||||
*/
|
||||
public byte[] body() { return body; }
|
||||
public byte[] body() {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +28,14 @@ import java.util.function.BiConsumer;
|
||||
*/
|
||||
public final class Router {
|
||||
|
||||
/** Root of the routing trie; every registered path descends from here. */
|
||||
/**
|
||||
* Root of the routing trie; every registered path descends from here.
|
||||
*/
|
||||
private final Node root = new Node();
|
||||
|
||||
/** Middlewares executed in insertion order for every matched request. */
|
||||
/**
|
||||
* Middlewares executed in insertion order for every matched request.
|
||||
*/
|
||||
private final List<BiConsumer<Request, Response>> middlewares = new ArrayList<>();
|
||||
|
||||
/**
|
||||
@@ -159,13 +163,14 @@ public final class Router {
|
||||
* @return the resolution outcome, never {@code null}
|
||||
*/
|
||||
public Resolution resolve(HttpMethod method, String path) {
|
||||
Map<String, String> params = new HashMap<>(4);
|
||||
Map<String, String> 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 +182,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()) {
|
||||
@@ -247,15 +252,25 @@ public final class Router {
|
||||
* registered at this node, and optional parameter/wildcard children.
|
||||
*/
|
||||
private static final class Node {
|
||||
/** Static child nodes keyed by their literal path segment. */
|
||||
/**
|
||||
* Static child nodes keyed by their literal path segment.
|
||||
*/
|
||||
final Map<String, Node> children = new ConcurrentHashMap<>();
|
||||
/** Handlers registered directly at this node, keyed by HTTP method. */
|
||||
/**
|
||||
* Handlers registered directly at this node, keyed by HTTP method.
|
||||
*/
|
||||
final Map<HttpMethod, Handler> handlers = new ConcurrentHashMap<>();
|
||||
/** Child matching any single segment as a path parameter, or {@code null} if none. */
|
||||
/**
|
||||
* Child matching any single segment as a path parameter, or {@code null} if none.
|
||||
*/
|
||||
Node paramChild;
|
||||
/** Name under which {@link #paramChild} captures the matched segment. */
|
||||
/**
|
||||
* Name under which {@link #paramChild} captures the matched segment.
|
||||
*/
|
||||
String paramName;
|
||||
/** Child matching any single segment as a wildcard, or {@code null} if none. */
|
||||
/**
|
||||
* Child matching any single segment as a wildcard, or {@code null} if none.
|
||||
*/
|
||||
Node wildcardChild;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,7 @@ public final class BadRequestException extends RuntimeException {
|
||||
*
|
||||
* @param message the detail message describing why the request is invalid
|
||||
*/
|
||||
public BadRequestException(String message) { super(message); }
|
||||
public BadRequestException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
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.*;
|
||||
|
||||
/**
|
||||
* 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 final Map<String, String> custom = new LinkedHashMap<>();
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,9 @@ import java.util.Objects;
|
||||
*/
|
||||
public final class TlsConfig {
|
||||
|
||||
/** The pre-built, shareable server SSL context. */
|
||||
/**
|
||||
* The pre-built, shareable server SSL context.
|
||||
*/
|
||||
private final SslContext sslContext;
|
||||
|
||||
private TlsConfig(SslContext sslContext) {
|
||||
@@ -66,8 +68,6 @@ public final class TlsConfig {
|
||||
try {
|
||||
return new TlsConfig(SslContextBuilder.forServer(certificateChain, privateKey, keyPassword).build());
|
||||
} catch (SSLException | RuntimeException e) {
|
||||
// Netty surfaces missing/invalid PEM material as IllegalArgumentException; normalise
|
||||
// every initialisation failure to a single, predictable exception type.
|
||||
throw new IllegalStateException("Failed to initialise TLS from PEM files", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,23 +15,41 @@ import java.util.Set;
|
||||
*/
|
||||
public final class WebSocketConfig {
|
||||
|
||||
/** Maximum size, in bytes, of a single WebSocket frame payload. */
|
||||
/**
|
||||
* Maximum size, in bytes, of a single WebSocket frame payload.
|
||||
*/
|
||||
private final int maxFramePayloadLength;
|
||||
/** Maximum size, in bytes, of an aggregated (multi-frame) message. */
|
||||
/**
|
||||
* Maximum size, in bytes, of an aggregated (multi-frame) message.
|
||||
*/
|
||||
private final int maxAggregatedMessageSize;
|
||||
/** Idle timeout after which an inactive connection is closed; {@code null} disables it. */
|
||||
/**
|
||||
* Idle timeout after which an inactive connection is closed; {@code null} disables it.
|
||||
*/
|
||||
private final Duration idleTimeout;
|
||||
/** Explicit set of allowed origins; ignored when {@link #allowAnyOrigin} is {@code true}. */
|
||||
/**
|
||||
* Explicit set of allowed origins; ignored when {@link #allowAnyOrigin} is {@code true}.
|
||||
*/
|
||||
private final Set<String> allowedOrigins;
|
||||
/** Whether connections from any origin are accepted. */
|
||||
/**
|
||||
* Whether connections from any origin are accepted.
|
||||
*/
|
||||
private final boolean allowAnyOrigin;
|
||||
/** Subprotocols offered during negotiation. */
|
||||
/**
|
||||
* Subprotocols offered during negotiation.
|
||||
*/
|
||||
private final Set<String> subprotocols;
|
||||
/** Whether per-message deflate compression is enabled. */
|
||||
/**
|
||||
* Whether per-message deflate compression is enabled.
|
||||
*/
|
||||
private final boolean compression;
|
||||
/** Whether the protocol handler matches the path by prefix rather than exact equality. */
|
||||
/**
|
||||
* Whether the protocol handler matches the path by prefix rather than exact equality.
|
||||
*/
|
||||
private final boolean checkStartsWith;
|
||||
/** Max in-flight callbacks queued per connection before read backpressure kicks in. */
|
||||
/**
|
||||
* Max in-flight callbacks queued per connection before read backpressure kicks in.
|
||||
*/
|
||||
private final int maxQueuedMessages;
|
||||
|
||||
/**
|
||||
@@ -74,7 +92,7 @@ public final class WebSocketConfig {
|
||||
*
|
||||
* @param origin the request's {@code Origin} header, may be {@code null}
|
||||
* @return {@code true} if any origin is allowed, or if the origin is in the allow-list;
|
||||
* {@code false} for a {@code null} or disallowed origin
|
||||
* {@code false} for a {@code null} or disallowed origin
|
||||
*/
|
||||
public boolean isOriginAllowed(String origin) {
|
||||
if (allowAnyOrigin) return true;
|
||||
@@ -173,23 +191,41 @@ public final class WebSocketConfig {
|
||||
* list, compression enabled, and exact path matching.
|
||||
*/
|
||||
public static final class Builder {
|
||||
/** Maximum single-frame payload size in bytes; defaults to 64 KiB. */
|
||||
private int maxFramePayloadLength = 65_536;
|
||||
/** Maximum aggregated message size in bytes; defaults to 1 MiB. */
|
||||
private int maxAggregatedMessageSize = 1_048_576;
|
||||
/** Idle timeout; defaults to 60 seconds. */
|
||||
private Duration idleTimeout = Duration.ofSeconds(60);
|
||||
/** Accumulated allowed origins (insertion-ordered). */
|
||||
/**
|
||||
* Accumulated allowed origins (insertion-ordered).
|
||||
*/
|
||||
private final Set<String> allowedOrigins = new LinkedHashSet<>();
|
||||
/** Whether any origin is allowed; defaults to {@code false}. */
|
||||
private boolean allowAnyOrigin = false;
|
||||
/** Accumulated subprotocols (insertion-ordered). */
|
||||
/**
|
||||
* Accumulated subprotocols (insertion-ordered).
|
||||
*/
|
||||
private final Set<String> subprotocols = new LinkedHashSet<>();
|
||||
/** Whether compression is enabled; defaults to {@code true}. */
|
||||
/**
|
||||
* Maximum single-frame payload size in bytes; defaults to 64 KiB.
|
||||
*/
|
||||
private int maxFramePayloadLength = 65_536;
|
||||
/**
|
||||
* Maximum aggregated message size in bytes; defaults to 1 MiB.
|
||||
*/
|
||||
private int maxAggregatedMessageSize = 1_048_576;
|
||||
/**
|
||||
* Idle timeout; defaults to 60 seconds.
|
||||
*/
|
||||
private Duration idleTimeout = Duration.ofSeconds(60);
|
||||
/**
|
||||
* Whether any origin is allowed; defaults to {@code false}.
|
||||
*/
|
||||
private boolean allowAnyOrigin = false;
|
||||
/**
|
||||
* Whether compression is enabled; defaults to {@code true}.
|
||||
*/
|
||||
private boolean compression = true;
|
||||
/** Whether path matching uses a prefix check; defaults to {@code false}. */
|
||||
/**
|
||||
* Whether path matching uses a prefix check; defaults to {@code false}.
|
||||
*/
|
||||
private boolean checkStartsWith = false;
|
||||
/** Per-connection queued-message high-watermark; defaults to 1024. */
|
||||
/**
|
||||
* Per-connection queued-message high-watermark; defaults to 1024.
|
||||
*/
|
||||
private int maxQueuedMessages = 1024;
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,11 +3,7 @@ package dev.coph.nextusweb.server.websocket;
|
||||
import dev.coph.nextusweb.server.auth.Principal;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.SimpleChannelInboundHandler;
|
||||
import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame;
|
||||
import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame;
|
||||
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
|
||||
import io.netty.handler.codec.http.websocketx.WebSocketFrame;
|
||||
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
|
||||
import io.netty.handler.codec.http.websocketx.*;
|
||||
import io.netty.handler.timeout.IdleStateEvent;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -40,29 +36,51 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
*/
|
||||
final class WebSocketFrameHandler extends SimpleChannelInboundHandler<WebSocketFrame> {
|
||||
|
||||
/** Executor running one virtual thread per drain task. */
|
||||
/**
|
||||
* Executor running one virtual thread per drain task.
|
||||
*/
|
||||
private static final Executor VT_EXECUTOR = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
/** The application handler receiving lifecycle callbacks. */
|
||||
/**
|
||||
* The application handler receiving lifecycle callbacks.
|
||||
*/
|
||||
private final WebSocketHandler handler;
|
||||
/** The path the connection was established on. */
|
||||
/**
|
||||
* The path the connection was established on.
|
||||
*/
|
||||
private final String path;
|
||||
/** Path parameters captured during routing, keyed by name. */
|
||||
/**
|
||||
* Path parameters captured during routing, keyed by name.
|
||||
*/
|
||||
private final Map<String, String> pathParams;
|
||||
/** Authenticated principal for the connection, or {@code null} if anonymous. */
|
||||
/**
|
||||
* Authenticated principal for the connection, or {@code null} if anonymous.
|
||||
*/
|
||||
private final Principal principal;
|
||||
/** Queued-callback high-watermark at which reads are paused. */
|
||||
/**
|
||||
* Queued-callback high-watermark at which reads are paused.
|
||||
*/
|
||||
private final int maxQueued;
|
||||
/** Watermark at which reads resume after having been paused. */
|
||||
/**
|
||||
* Watermark at which reads resume after having been paused.
|
||||
*/
|
||||
private final int resumeQueued;
|
||||
|
||||
/** FIFO of pending callbacks for this connection; drained by a single virtual thread. */
|
||||
/**
|
||||
* FIFO of pending callbacks for this connection; drained by a single virtual thread.
|
||||
*/
|
||||
private final Queue<Runnable> tasks = new ConcurrentLinkedQueue<>();
|
||||
/** Number of callbacks currently queued (drives the backpressure watermarks). */
|
||||
/**
|
||||
* Number of callbacks currently queued (drives the backpressure watermarks).
|
||||
*/
|
||||
private final AtomicInteger queued = new AtomicInteger();
|
||||
/** Guards that at most one drainer runs at a time, preserving ordering. */
|
||||
/**
|
||||
* Guards that at most one drainer runs at a time, preserving ordering.
|
||||
*/
|
||||
private final AtomicBoolean draining = new AtomicBoolean(false);
|
||||
/** Whether reads are currently paused for backpressure. */
|
||||
/**
|
||||
* Whether reads are currently paused for backpressure.
|
||||
*/
|
||||
private volatile boolean readsPaused = false;
|
||||
|
||||
/**
|
||||
@@ -84,36 +102,6 @@ final class WebSocketFrameHandler extends SimpleChannelInboundHandler<WebSocketF
|
||||
this.resumeQueued = Math.max(1, this.maxQueued / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles pipeline user events. On handshake completion it creates and stores the
|
||||
* {@link WebSocketSession} and dispatches {@link WebSocketHandler#onOpen}; on an idle-state
|
||||
* event it closes the channel; other events are passed up the pipeline.
|
||||
*
|
||||
* @param ctx the channel context
|
||||
* @param evt the user event
|
||||
* @throws Exception if the superclass handling of an unrecognized event fails
|
||||
*/
|
||||
@Override
|
||||
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
|
||||
if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
|
||||
WebSocketSession session = new WebSocketSession(ctx.channel(), path, pathParams, principal);
|
||||
ctx.channel().attr(WebSocketSession.SESSION_KEY).set(session);
|
||||
submit(ctx, () -> {
|
||||
try {
|
||||
handler.onOpen(session);
|
||||
} catch (Throwable t) {
|
||||
safeError(session, t);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (evt instanceof IdleStateEvent) {
|
||||
ctx.close();
|
||||
return;
|
||||
}
|
||||
super.userEventTriggered(ctx, evt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an incoming frame to the appropriate handler callback. Text, binary and close
|
||||
* frames are forwarded to {@code onMessage}, {@code onBinary} and {@code onClose}
|
||||
@@ -233,6 +221,36 @@ final class WebSocketFrameHandler extends SimpleChannelInboundHandler<WebSocketF
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles pipeline user events. On handshake completion it creates and stores the
|
||||
* {@link WebSocketSession} and dispatches {@link WebSocketHandler#onOpen}; on an idle-state
|
||||
* event it closes the channel; other events are passed up the pipeline.
|
||||
*
|
||||
* @param ctx the channel context
|
||||
* @param evt the user event
|
||||
* @throws Exception if the superclass handling of an unrecognized event fails
|
||||
*/
|
||||
@Override
|
||||
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
|
||||
if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) {
|
||||
WebSocketSession session = new WebSocketSession(ctx.channel(), path, pathParams, principal);
|
||||
ctx.channel().attr(WebSocketSession.SESSION_KEY).set(session);
|
||||
submit(ctx, () -> {
|
||||
try {
|
||||
handler.onOpen(session);
|
||||
} catch (Throwable t) {
|
||||
safeError(session, t);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (evt instanceof IdleStateEvent) {
|
||||
ctx.close();
|
||||
return;
|
||||
}
|
||||
super.userEventTriggered(ctx, evt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a pipeline exception to {@link WebSocketHandler#onError} (when a session exists)
|
||||
* and then closes the channel.
|
||||
|
||||
+18
-16
@@ -14,7 +14,9 @@ import java.util.Map;
|
||||
*/
|
||||
public final class WebSocketFrameHandlerFactory {
|
||||
|
||||
/** Default per-connection queued-message high-watermark when none is supplied. */
|
||||
/**
|
||||
* Default per-connection queued-message high-watermark when none is supplied.
|
||||
*/
|
||||
private static final int DEFAULT_MAX_QUEUED = 1024;
|
||||
|
||||
/**
|
||||
@@ -37,21 +39,6 @@ public final class WebSocketFrameHandlerFactory {
|
||||
return create(handler, path, pathParams, null, DEFAULT_MAX_QUEUED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a channel handler with an authenticated principal and the default backpressure
|
||||
* watermark.
|
||||
*
|
||||
* @param handler the application handler to dispatch lifecycle events to
|
||||
* @param path the path the connection was established on
|
||||
* @param pathParams the path parameters captured during routing
|
||||
* @param principal the authenticated principal, or {@code null} if the connection is anonymous
|
||||
* @return a new channel handler ready to be inserted into the pipeline
|
||||
*/
|
||||
public static ChannelHandler create(WebSocketHandler handler, String path,
|
||||
Map<String, String> pathParams, Principal principal) {
|
||||
return create(handler, path, pathParams, principal, DEFAULT_MAX_QUEUED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a channel handler with an authenticated principal and an explicit backpressure
|
||||
* watermark.
|
||||
@@ -68,4 +55,19 @@ public final class WebSocketFrameHandlerFactory {
|
||||
int maxQueued) {
|
||||
return new WebSocketFrameHandler(handler, path, pathParams, principal, maxQueued);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a channel handler with an authenticated principal and the default backpressure
|
||||
* watermark.
|
||||
*
|
||||
* @param handler the application handler to dispatch lifecycle events to
|
||||
* @param path the path the connection was established on
|
||||
* @param pathParams the path parameters captured during routing
|
||||
* @param principal the authenticated principal, or {@code null} if the connection is anonymous
|
||||
* @return a new channel handler ready to be inserted into the pipeline
|
||||
*/
|
||||
public static ChannelHandler create(WebSocketHandler handler, String path,
|
||||
Map<String, String> pathParams, Principal principal) {
|
||||
return create(handler, path, pathParams, principal, DEFAULT_MAX_QUEUED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,13 @@ import tools.jackson.core.JacksonException;
|
||||
*/
|
||||
public final class WebSocketGroup {
|
||||
|
||||
/** Underlying Netty channel group holding the member connections. */
|
||||
/**
|
||||
* Underlying Netty channel group holding the member connections.
|
||||
*/
|
||||
private final ChannelGroup channels;
|
||||
/** Human-readable name of this group. */
|
||||
/**
|
||||
* Human-readable name of this group.
|
||||
*/
|
||||
private final String name;
|
||||
|
||||
/**
|
||||
@@ -103,8 +107,6 @@ public final class WebSocketGroup {
|
||||
public WebSocketGroup broadcastJson(Object value) {
|
||||
try {
|
||||
byte[] bytes = JsonMapper.MAPPER.writeValueAsBytes(value);
|
||||
// Build the text frame straight from the serialized UTF-8 bytes; the channel group
|
||||
// duplicates the payload per recipient, so no String round-trip re-encode is needed.
|
||||
channels.writeAndFlush(new TextWebSocketFrame(Unpooled.wrappedBuffer(bytes)));
|
||||
} catch (JacksonException e) {
|
||||
throw new RuntimeException("JSON serialization failed", e);
|
||||
|
||||
@@ -16,7 +16,9 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
*/
|
||||
public final class WebSocketRouter {
|
||||
|
||||
/** Root of the routing trie. */
|
||||
/**
|
||||
* Root of the routing trie.
|
||||
*/
|
||||
private final Node root = new Node();
|
||||
|
||||
/**
|
||||
@@ -50,31 +52,6 @@ public final class WebSocketRouter {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a path to its handler, capturing any path parameters along the way.
|
||||
*
|
||||
* @param path the request path
|
||||
* @return a {@link Resolution} carrying the handler and captured parameters, or {@code null}
|
||||
* if no handler is registered for the path
|
||||
*/
|
||||
public Resolution resolve(String path) {
|
||||
Map<String, String> params = new HashMap<>(4);
|
||||
Node node = root;
|
||||
for (String segment : split(path)) {
|
||||
Node next = node.children.get(segment);
|
||||
if (next != null) {
|
||||
node = next;
|
||||
} else if (node.paramChild != null) {
|
||||
params.put(node.paramName, segment);
|
||||
node = node.paramChild;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (node.handler == null) return null;
|
||||
return new Resolution(node.handler, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a path into its non-empty segments, ignoring leading and collapsing internal
|
||||
* slashes.
|
||||
@@ -95,6 +72,31 @@ public final class WebSocketRouter {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a path to its handler, capturing any path parameters along the way.
|
||||
*
|
||||
* @param path the request path
|
||||
* @return a {@link Resolution} carrying the handler and captured parameters, or {@code null}
|
||||
* if no handler is registered for the path
|
||||
*/
|
||||
public Resolution resolve(String path) {
|
||||
Map<String, String> params = new HashMap<>(4);
|
||||
Node node = root;
|
||||
for (String segment : split(path)) {
|
||||
Node next = node.children.get(segment);
|
||||
if (next != null) {
|
||||
node = next;
|
||||
} else if (node.paramChild != null) {
|
||||
params.put(node.paramName, segment);
|
||||
node = node.paramChild;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (node.handler == null) return null;
|
||||
return new Resolution(node.handler, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* A successful path resolution.
|
||||
*
|
||||
@@ -109,13 +111,21 @@ public final class WebSocketRouter {
|
||||
* optional path-parameter child, and the handler (if any) registered at this node.
|
||||
*/
|
||||
private static final class Node {
|
||||
/** Static child nodes keyed by their literal path segment. */
|
||||
/**
|
||||
* Static child nodes keyed by their literal path segment.
|
||||
*/
|
||||
final Map<String, Node> children = new ConcurrentHashMap<>();
|
||||
/** Child matching any single segment as a path parameter, or {@code null} if none. */
|
||||
/**
|
||||
* Child matching any single segment as a path parameter, or {@code null} if none.
|
||||
*/
|
||||
Node paramChild;
|
||||
/** Name under which {@link #paramChild} captures the matched segment. */
|
||||
/**
|
||||
* Name under which {@link #paramChild} captures the matched segment.
|
||||
*/
|
||||
String paramName;
|
||||
/** Handler registered at this node, or {@code null} if the path is only a prefix. */
|
||||
/**
|
||||
* Handler registered at this node, or {@code null} if the path is only a prefix.
|
||||
*/
|
||||
WebSocketHandler handler;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,21 +36,35 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
*/
|
||||
public final class WebSocketSession {
|
||||
|
||||
/** Channel attribute key under which the session is stored on its Netty channel. */
|
||||
/**
|
||||
* Channel attribute key under which the session is stored on its Netty channel.
|
||||
*/
|
||||
static final AttributeKey<WebSocketSession> SESSION_KEY =
|
||||
AttributeKey.valueOf("nexusweb.ws.session");
|
||||
|
||||
/** The underlying Netty channel for this connection. */
|
||||
/**
|
||||
* The underlying Netty channel for this connection.
|
||||
*/
|
||||
private final Channel channel;
|
||||
/** Unique identifier generated for this session. */
|
||||
/**
|
||||
* Unique identifier generated for this session.
|
||||
*/
|
||||
private final String id;
|
||||
/** The path the connection was established on. */
|
||||
/**
|
||||
* The path the connection was established on.
|
||||
*/
|
||||
private final String path;
|
||||
/** Path parameters captured during routing, keyed by name. */
|
||||
/**
|
||||
* Path parameters captured during routing, keyed by name.
|
||||
*/
|
||||
private final Map<String, String> pathParams;
|
||||
/** Authenticated principal for this connection, or {@code null} if anonymous. */
|
||||
/**
|
||||
* Authenticated principal for this connection, or {@code null} if anonymous.
|
||||
*/
|
||||
private final Principal principal;
|
||||
/** Thread-safe bag of user-defined attributes attached to the session. */
|
||||
/**
|
||||
* Thread-safe bag of user-defined attributes attached to the session.
|
||||
*/
|
||||
private final Map<String, Object> attributes = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
@@ -70,6 +84,36 @@ public final class WebSocketSession {
|
||||
this.principal = principal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level helper that writes a text payload directly to a channel, allocating the buffer
|
||||
* from the channel's allocator. Used by collaborators that hold a channel but not a session.
|
||||
*
|
||||
* @param channel the channel to write to
|
||||
* @param text the text to send
|
||||
* @return a future completing when the write finishes; an already-succeeded future if the
|
||||
* channel is no longer active
|
||||
*/
|
||||
static ChannelFuture sendRaw(Channel channel, String text) {
|
||||
if (!channel.isActive()) return channel.newSucceededFuture();
|
||||
ByteBuf buf = channel.alloc().buffer();
|
||||
buf.writeCharSequence(text, CharsetUtil.UTF_8);
|
||||
return channel.writeAndFlush(new TextWebSocketFrame(true, 0, buf));
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level helper that writes a binary payload directly to a channel.
|
||||
*
|
||||
* @param channel the channel to write to
|
||||
* @param data the bytes to send
|
||||
* @return a future completing when the write finishes; an already-succeeded future if the
|
||||
* channel is no longer active
|
||||
*/
|
||||
static ChannelFuture sendRawBinary(Channel channel, byte[] data) {
|
||||
if (!channel.isActive()) return channel.newSucceededFuture();
|
||||
ByteBuf buf = channel.alloc().buffer(data.length).writeBytes(Unpooled.wrappedBuffer(data));
|
||||
return channel.writeAndFlush(new BinaryWebSocketFrame(buf));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the unique identifier generated for this session.
|
||||
*
|
||||
@@ -121,7 +165,7 @@ public final class WebSocketSession {
|
||||
* Returns the peer's remote IP address.
|
||||
*
|
||||
* @return the remote host address, or a string form of the address if it is not an
|
||||
* {@link InetSocketAddress}; {@code null} if unavailable
|
||||
* {@link InetSocketAddress}; {@code null} if unavailable
|
||||
*/
|
||||
public String remoteAddress() {
|
||||
SocketAddress addr = channel.remoteAddress();
|
||||
@@ -171,7 +215,7 @@ public final class WebSocketSession {
|
||||
*
|
||||
* @param text the text to send
|
||||
* @return a future completing when the write finishes; an already-succeeded future if the
|
||||
* channel is no longer active
|
||||
* channel is no longer active
|
||||
*/
|
||||
public ChannelFuture send(String text) {
|
||||
if (!channel.isActive()) return channel.newSucceededFuture();
|
||||
@@ -183,7 +227,7 @@ public final class WebSocketSession {
|
||||
*
|
||||
* @param value the object to serialize and send
|
||||
* @return a future completing when the write finishes; an already-succeeded future if the
|
||||
* channel is no longer active
|
||||
* channel is no longer active
|
||||
* @throws RuntimeException if JSON serialization fails
|
||||
*/
|
||||
public ChannelFuture sendJson(Object value) {
|
||||
@@ -202,7 +246,7 @@ public final class WebSocketSession {
|
||||
*
|
||||
* @param data the bytes to send
|
||||
* @return a future completing when the write finishes; an already-succeeded future if the
|
||||
* channel is no longer active
|
||||
* channel is no longer active
|
||||
*/
|
||||
public ChannelFuture sendBinary(byte[] data) {
|
||||
if (!channel.isActive()) return channel.newSucceededFuture();
|
||||
@@ -214,7 +258,7 @@ public final class WebSocketSession {
|
||||
* Sends a WebSocket ping frame to the peer (e.g. as a keep-alive).
|
||||
*
|
||||
* @return a future completing when the write finishes; an already-succeeded future if the
|
||||
* channel is no longer active
|
||||
* channel is no longer active
|
||||
*/
|
||||
public ChannelFuture ping() {
|
||||
if (!channel.isActive()) return channel.newSucceededFuture();
|
||||
@@ -237,41 +281,11 @@ public final class WebSocketSession {
|
||||
* @param code the WebSocket close status code
|
||||
* @param reason the human-readable close reason
|
||||
* @return a future completing when the close frame has been written; an already-succeeded
|
||||
* future if the channel is no longer active
|
||||
* future if the channel is no longer active
|
||||
*/
|
||||
public ChannelFuture close(int code, String reason) {
|
||||
if (!channel.isActive()) return channel.newSucceededFuture();
|
||||
return channel.writeAndFlush(new CloseWebSocketFrame(code, reason))
|
||||
.addListener(ChannelFutureListener.CLOSE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level helper that writes a text payload directly to a channel, allocating the buffer
|
||||
* from the channel's allocator. Used by collaborators that hold a channel but not a session.
|
||||
*
|
||||
* @param channel the channel to write to
|
||||
* @param text the text to send
|
||||
* @return a future completing when the write finishes; an already-succeeded future if the
|
||||
* channel is no longer active
|
||||
*/
|
||||
static ChannelFuture sendRaw(Channel channel, String text) {
|
||||
if (!channel.isActive()) return channel.newSucceededFuture();
|
||||
ByteBuf buf = channel.alloc().buffer();
|
||||
buf.writeCharSequence(text, CharsetUtil.UTF_8);
|
||||
return channel.writeAndFlush(new TextWebSocketFrame(true, 0, buf));
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level helper that writes a binary payload directly to a channel.
|
||||
*
|
||||
* @param channel the channel to write to
|
||||
* @param data the bytes to send
|
||||
* @return a future completing when the write finishes; an already-succeeded future if the
|
||||
* channel is no longer active
|
||||
*/
|
||||
static ChannelFuture sendRawBinary(Channel channel, byte[] data) {
|
||||
if (!channel.isActive()) return channel.newSucceededFuture();
|
||||
ByteBuf buf = channel.alloc().buffer(data.length).writeBytes(Unpooled.wrappedBuffer(data));
|
||||
return channel.writeAndFlush(new BinaryWebSocketFrame(buf));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user