package dev.coph.nextusweb.server.security; import dev.coph.nextusweb.server.router.Response; import io.netty.handler.codec.http.HttpHeaders; import java.time.Duration; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; /** * A small, immutable policy of standard HTTP security response headers that the server adds to * every response. It complements the transport- and authentication-level protections (TLS, CORS, * the auth gate, rate limiting) with the browser-facing hardening headers that mitigate * MIME-sniffing, click-jacking, referrer leakage and protocol-downgrade attacks. * *

Like {@code CorsHandler} the header strings are computed once at construction time and then * reused for every response, so applying them is cheap. Attach an instance with * {@code HttpServer.withSecurityHeaders(...)} and the request pipeline applies it to all * responses.

* *

Two design choices keep the feature safe to switch on:

* */ public final class SecurityHeaders { /** The {@code Strict-Transport-Security} header name, gated on a secure connection. */ private static final String HSTS = "Strict-Transport-Security"; /** Headers added to every response (subject to not already being present). */ private final List> always; /** Pre-rendered HSTS header value, or {@code null} if HSTS is disabled. */ private final String hstsValue; private SecurityHeaders(Builder b) { List> list = new ArrayList<>(); if (b.contentTypeOptions) { list.add(Map.entry("X-Content-Type-Options", "nosniff")); } if (notBlank(b.frameOptions)) { list.add(Map.entry("X-Frame-Options", b.frameOptions)); } if (notBlank(b.referrerPolicy)) { list.add(Map.entry("Referrer-Policy", b.referrerPolicy)); } if (notBlank(b.contentSecurityPolicy)) { list.add(Map.entry("Content-Security-Policy", b.contentSecurityPolicy)); } for (var e : b.custom.entrySet()) { list.add(Map.entry(e.getKey(), e.getValue())); } this.always = List.copyOf(list); if (b.hstsMaxAge != null && !b.hstsMaxAge.isZero() && !b.hstsMaxAge.isNegative()) { StringBuilder sb = new StringBuilder("max-age=").append(b.hstsMaxAge.toSeconds()); if (b.hstsIncludeSubDomains) sb.append("; includeSubDomains"); if (b.hstsPreload) sb.append("; preload"); this.hstsValue = sb.toString(); } else { this.hstsValue = null; } } private static boolean notBlank(String s) { return s != null && !s.isBlank(); } /** * A sensible, conservative default policy: {@code X-Content-Type-Options: nosniff}, * {@code X-Frame-Options: DENY}, {@code Referrer-Policy: no-referrer} and, on HTTPS * connections, a one-year {@code Strict-Transport-Security} header (without * {@code includeSubDomains}/{@code preload}, which are opt-in because of their wide blast * radius). No {@code Content-Security-Policy} is set, since a useful CSP is application * specific. * * @return the default security-header policy */ public static SecurityHeaders defaults() { return builder() .hsts(Duration.ofDays(365), false, false) .build(); } /** * Creates a builder pre-populated with the conservative defaults (see {@link #defaults()}), * except that HSTS is disabled until configured with {@link Builder#hsts}. * * @return a fresh builder */ public static Builder builder() { return new Builder(); } /** * Adds the configured security headers to a response, skipping any header the handler has * already set, and adding {@code Strict-Transport-Security} only when {@code secure} is * {@code true}. * * @param res the response to decorate * @param secure whether the underlying connection is secured by TLS */ public void apply(Response res, boolean secure) { HttpHeaders headers = res.headers(); for (Map.Entry e : always) { if (!headers.contains(e.getKey())) { headers.set(e.getKey(), e.getValue()); } } if (secure && hstsValue != null && !headers.contains(HSTS)) { headers.set(HSTS, hstsValue); } } /** * Fluent builder for {@link SecurityHeaders}. Sensible defaults are pre-set; call the setters * only to override them. Passing {@code null} (or a blank string) to a setter disables that * particular header. */ public static final class Builder { private boolean contentTypeOptions = true; private String frameOptions = "DENY"; private String referrerPolicy = "no-referrer"; private String contentSecurityPolicy; private Duration hstsMaxAge; private boolean hstsIncludeSubDomains; private boolean hstsPreload; private final Map custom = new LinkedHashMap<>(); private Builder() { } /** * Enables or disables {@code X-Content-Type-Options: nosniff} (defends against MIME * sniffing). Enabled by default. * * @param enabled {@code true} to emit the header * @return this builder, for fluent chaining */ public Builder contentTypeOptions(boolean enabled) { this.contentTypeOptions = enabled; return this; } /** * Sets the {@code X-Frame-Options} value (click-jacking defence); typical values are * {@code "DENY"} (the default) or {@code "SAMEORIGIN"}. Pass {@code null} or a blank string * to omit the header. * * @param value the header value, or {@code null}/blank to disable * @return this builder, for fluent chaining */ public Builder frameOptions(String value) { this.frameOptions = value; return this; } /** * Sets the {@code Referrer-Policy} value (defaults to {@code "no-referrer"}). Pass * {@code null} or a blank string to omit the header. * * @param value the header value, or {@code null}/blank to disable * @return this builder, for fluent chaining */ public Builder referrerPolicy(String value) { this.referrerPolicy = value; return this; } /** * Sets a {@code Content-Security-Policy}. Disabled by default because a useful CSP is * application specific; supply one tailored to your app. Pass {@code null} or a blank * string to omit the header. * * @param value the policy string, or {@code null}/blank to disable * @return this builder, for fluent chaining */ public Builder contentSecurityPolicy(String value) { this.contentSecurityPolicy = value; return this; } /** * Enables {@code Strict-Transport-Security} (HSTS), which is emitted only on HTTPS * connections. Be deliberate with {@code includeSubDomains} and {@code preload}: they are * hard to roll back, so enable them only once every subdomain is reliably served over * HTTPS. * * @param maxAge how long browsers should pin HTTPS; {@code null}/zero/negative * disables HSTS * @param includeSubDomains whether the policy also covers every subdomain * @param preload whether to request inclusion in browser preload lists * @return this builder, for fluent chaining */ public Builder hsts(Duration maxAge, boolean includeSubDomains, boolean preload) { this.hstsMaxAge = maxAge; this.hstsIncludeSubDomains = includeSubDomains; this.hstsPreload = preload; return this; } /** * Disables {@code Strict-Transport-Security}. * * @return this builder, for fluent chaining */ public Builder noHsts() { this.hstsMaxAge = null; return this; } /** * Adds an arbitrary additional response header (for example {@code Permissions-Policy} or * {@code Cross-Origin-Opener-Policy}). Like the built-in headers it is only applied when the * handler has not already set it. * * @param name the header name * @param value the header value * @return this builder, for fluent chaining */ public Builder header(String name, String value) { Objects.requireNonNull(name, "name"); Objects.requireNonNull(value, "value"); this.custom.put(name, value); return this; } /** * Builds the immutable {@link SecurityHeaders}. * * @return the configured instance */ public SecurityHeaders build() { return new SecurityHeaders(this); } } }