248 lines
9.7 KiB
Java
248 lines
9.7 KiB
Java
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.
|
|
*
|
|
* <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 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<String, String> 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);
|
|
}
|
|
}
|
|
}
|