Compare commits

...
Author SHA1 Message Date
atlas
5ec1306c7b feat(gateway): assert hsts.enable requires TLS
Addresses mara's review: add a NixOS assertion that rejects
hsts.enable = true when no TLS mode is configured. HSTS over
plain HTTP is silently ignored by browsers; the assertion turns
a silent misconfiguration into a build-time error.
2026-06-04 13:50:22 +02:00
atlas
5264ef7d5e feat(gateway): add gateway.hsts.enable option, disabled by default
HSTS was unconditionally tied to hasTls. This is risky: enabling it on a
deployment that later loses TLS locks browsers out until max-age expires.

Add three options under services.hyperhive.gateway.hsts:
  enable          — bool, default false
  maxAge          — seconds, default 31536000 (1 year)
  includeSubDomains — bool, default true

HSTS header is now only emitted when hsts.enable = true.
2026-06-04 13:50:22 +02:00
atlas
18941848dc feat(gateway): add security headers to all vhosts
X-Frame-Options, X-Content-Type-Options, Referrer-Policy at server
scope on _, forge, and matrix vhosts. HSTS added when TLS is active.

nginx inheritance rule: locations with their own add_header (CORS API
endpoints like /.well-known/matrix/client, /_matrix/) are unaffected —
they already carry the headers they need. HTML-serving and proxy
locations pick the security headers up automatically.
2026-06-04 13:50:22 +02:00

View file

@ -331,6 +331,48 @@ in
};
};
hsts = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Add `Strict-Transport-Security` to all gateway vhosts.
Disabled by default: HSTS pins HTTPS in the browser's HSTS
preload list; enabling it on a deployment that later loses TLS
will lock browsers out until the max-age expires. Only enable
this when you are certain TLS is permanent.
Requires TLS to be active (`selfSignedTls = true`, a `tls.certDir`,
or `tls.acme.enable = true`). Enabling HSTS without TLS is
technically harmless (browsers ignore the header over plain HTTP)
but is almost certainly a misconfiguration.
'';
};
maxAge = lib.mkOption {
type = lib.types.ints.positive;
default = 31536000;
example = 86400;
description = ''
Value for the `max-age` directive in seconds.
Default: 31536000 (1 year), which is the value required for
HSTS preload list submission. Use a shorter value (e.g. 86400)
while testing so browsers forget the pin quickly.
'';
};
includeSubDomains = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to include `includeSubDomains` in the HSTS header.
Only disable this if the gateway host has sub-domains that
intentionally serve plain HTTP.
'';
};
};
};
config = lib.mkIf cfg.enable {
@ -375,6 +417,18 @@ in
Let's Encrypt needs a contact address for the ACME account.
'';
}
{
assertion =
!cfg.hsts.enable
|| cfg.selfSignedTls
|| cfg.tls.certDir != null
|| cfg.tls.acme.enable;
message = ''
services.hyperhive.gateway.hsts.enable = true requires TLS to be
configured (selfSignedTls, tls.certDir, or tls.acme.enable). HSTS
over plain HTTP is ignored by browsers and indicates a config error.
'';
}
];
# Ensure bind-mount sources exist at host boot before the gateway
@ -444,15 +498,9 @@ in
# - selfSignedTls=true: generated cert stored in persistent state dir.
# - tls.certDir set: operator-provided cert bind-mounted at /run/hive-tls.
tlsCert =
if cfg.tls.certDir != null then
"/run/hive-tls/${cfg.tls.certName}"
else
"${tlsDir}/cert.pem";
if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.certName}" else "${tlsDir}/cert.pem";
tlsKey =
if cfg.tls.certDir != null then
"/run/hive-tls/${cfg.tls.keyName}"
else
"${tlsDir}/key.pem";
if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.keyName}" else "${tlsDir}/key.pem";
# True when nginx should listen with TLS (any mode).
hasTls = cfg.selfSignedTls || cfg.tls.certDir != null || cfg.tls.acme.enable;
# Listen addresses every vhost shares. Plain http on `cfg.port`
@ -498,6 +546,23 @@ in
publicPort = if hasTls then cfg.httpsPort else cfg.port;
publicPortDefault = if hasTls then 443 else 80;
publicPortSuffix = if publicPort == publicPortDefault then "" else ":${toString publicPort}";
# Security headers added at the server scope on every vhost.
# nginx's add_header inheritance rule: a location that defines its
# own add_header does NOT inherit the server-level ones, so API
# locations with CORS headers (e.g. /.well-known/matrix/client,
# /_matrix/) are unaffected. HTML-serving and proxy locations that
# carry no add_header of their own pick these up automatically.
hstsDirectives = lib.concatStringsSep "; " (
[ "max-age=${toString cfg.hsts.maxAge}" ]
++ lib.optional cfg.hsts.includeSubDomains "includeSubDomains"
);
securityHeaders = ''
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
${lib.optionalString cfg.hsts.enable ''add_header Strict-Transport-Security "${hstsDirectives}" always;''}
'';
in
{
system.stateVersion = "26.05";
@ -794,7 +859,7 @@ in
# agents without a nixos-rebuild. nginx's longest-prefix-
# match rule ensures `/agent/<name>/` from this file beats
# the `/agent/` catch-all above.
extraConfig = ''
extraConfig = securityHeaders + ''
include /run/hive-state/agents.conf;
'';
};
@ -808,6 +873,7 @@ in
lib.optionalAttrs (forgeCfg.enable or false && forgeCfg.behindGateway or false) {
"${forgeCfg.domain}" = vhostTls // {
listen = vhostListen;
extraConfig = securityHeaders;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString forgeCfg.httpPort}/";
proxyWebsockets = true;
@ -830,6 +896,7 @@ in
lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) {
"${matrixCfg.gatewayHost}" = vhostTls // {
listen = vhostListen;
extraConfig = securityHeaders;
locations = {
"/_matrix/" = {
proxyPass = "http://127.0.0.1:${toString matrixCfg.httpPort}";
@ -935,10 +1002,10 @@ in
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts =
[ cfg.port ]
++ lib.optional (cfg.selfSignedTls || cfg.tls.certDir != null || cfg.tls.acme.enable)
cfg.httpsPort;
allowedTCPPorts = [
cfg.port
]
++ lib.optional (cfg.selfSignedTls || cfg.tls.certDir != null || cfg.tls.acme.enable) cfg.httpsPort;
};
# `/etc/hosts` entries for local dev — bare hive domain + any