diff --git a/nix/modules/hive-gateway.nix b/nix/modules/hive-gateway.nix index b5065a90..6f2696ee 100644 --- a/nix/modules/hive-gateway.nix +++ b/nix/modules/hive-gateway.nix @@ -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//` 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