From b3f93d7292a29fbcf135278172f28d58cd2d2d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=B9=82=E0=B8=97=E0=B8=AA=E0=B8=BA=E0=B8=95=E0=B8=B1?= =?UTF-8?q?=E0=B8=A5?= Date: Sun, 23 Feb 2025 17:35:25 +0700 Subject: [PATCH] nixos/h2o: TLS recommendations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Mozilla’s ssl-config-generator project --- .../services/web-servers/h2o/common.nix | 46 ++++++ .../services/web-servers/h2o/default.nix | 139 ++++++++++++++---- .../web-servers/h2o/vhost-options.nix | 9 +- nixos/tests/web-servers/h2o/default.nix | 1 + .../web-servers/h2o/tls-recommendations.nix | 115 +++++++++++++++ 5 files changed, 281 insertions(+), 29 deletions(-) create mode 100644 nixos/modules/services/web-servers/h2o/common.nix create mode 100644 nixos/tests/web-servers/h2o/tls-recommendations.nix diff --git a/nixos/modules/services/web-servers/h2o/common.nix b/nixos/modules/services/web-servers/h2o/common.nix new file mode 100644 index 000000000000..d2efc886dd8b --- /dev/null +++ b/nixos/modules/services/web-servers/h2o/common.nix @@ -0,0 +1,46 @@ +{ lib }: +{ + tlsRecommendationsOption = lib.mkOption { + type = lib.types.nullOr ( + lib.types.enum [ + "modern" + "intermediate" + "old" + ] + ); + default = null; + example = "intermediate"; + description = '' + By default, H2O, without prejudice, will use as many TLS versions & + cipher suites as it & the TLS library (OpenSSL) can support. The user is + expected to hone settings for the security of their server. Setting some + constraints is recommended, & if unsure about what TLS settings to use, + this option gives curated TLS settings recommendations from Mozilla’s + ‘SSL Configuration Generator’ project (see + ) or read more at Mozilla’s Wiki (see + ). + + modern + : Services with clients that support TLS 1.3 & don’t need backward + compatibility + + intermediate + : General-purpose servers with a variety of clients, recommended for + almost all systems + + old + : Compatible with a number of very old clients, & should be used only as + a last resort + + The default for all virtual hosts can be set with + services.h2o.defaultTLSRecommendations, but this value can be overridden + on a per-host basis using services.h2o.hosts..tls.recommmendations. + The settings will also be overidden by manual values set with + services.settings.h2o.hosts..tls.extraSettings. + + NOTE: older/weaker ciphers might require overriding the OpenSSL version + of H2O (such as `openssl_legacy`). This can be done with + sevices.settings.h2o.package. + ''; + }; +} diff --git a/nixos/modules/services/web-servers/h2o/default.nix b/nixos/modules/services/web-servers/h2o/default.nix index e359ea9671d8..f752b6b9b256 100644 --- a/nixos/modules/services/web-servers/h2o/default.nix +++ b/nixos/modules/services/web-servers/h2o/default.nix @@ -6,7 +6,6 @@ }: # TODO: Gems includes for Mruby -# TODO: Recommended options let cfg = config.services.h2o; inherit (config.security.acme) certs; @@ -22,6 +21,8 @@ let mkCertOwnershipAssertion = import ../../../security/acme/mk-cert-ownership-assertion.nix lib; + inherit (import ./common.nix { inherit lib; }) tlsRecommendationsOption; + settingsFormat = pkgs.formats.yaml { }; getNames = name: vhostSettings: rec { @@ -76,6 +77,34 @@ let all = certNames'.dependent ++ certNames'.independent; }; + mozTLSRecs = + if cfg.defaultTLSRecommendations != null then + let + # NOTE: if updating, *do* verify the changes then adjust ciphers & + # other settings with the tests @ + # `nixos/tests/web-servers/h2o/tls-recommendations.nix` + # & run with `nix-build -A nixosTests.h2o.tls-recommendations` + version = "5.7"; + git_tag = "v5.7.1"; + guidelinesJSON = + lib.pipe + { + urls = [ + "https://ssl-config.mozilla.org/guidelines/${version}.json" + "https://raw.githubusercontent.com/mozilla/ssl-config-generator/refs/tags/${git_tag}/src/static/guidelines/${version}.json" + ]; + sha256 = "sha256:1mj2pcb1hg7q2wpgdq3ac8pc2q64wvwvwlkb9xjmdd9jm4hiyny7"; + } + [ + pkgs.fetchurl + builtins.readFile + builtins.fromJSON + ]; + in + guidelinesJSON.configurations + else + null; + hostsConfig = lib.concatMapAttrs ( name: value: let @@ -130,23 +159,79 @@ let ] ) { - "${names.server}:${builtins.toString port.TLS}" = value.settings // { - listen = - let - identity = - value.tls.identity - ++ lib.optional (builtins.elem names.cert certNames.all) { - key-file = "${certs.${names.cert}.directory}/key.pem"; - certificate-file = "${certs.${names.cert}.directory}/fullchain.pem"; + "${names.server}:${builtins.toString port.TLS}" = + let + tlsRecommendations = lib.attrByPath [ "tls" "recommendations" ] cfg.defaultTLSRecommendations value; + + hasTLSRecommendations = tlsRecommendations != null && mozTLSRecs != null; + + # NOTE: Let’s Encrypt has sunset OCSP stapling. Mozilla’s + # ssl-config-generator is at present still recommending this setting, but + # this module will skip setting a stapling value as Let’s Encrypt + + # ACME is the most likely use case. + # + # See: https://github.com/mozilla/ssl-config-generator/issues/323 + tlsRecAttrs = lib.optionalAttrs hasTLSRecommendations ( + let + recs = mozTLSRecs.${tlsRecommendations}; + in + { + min-version = builtins.head recs.tls_versions; + cipher-preference = "server"; + "cipher-suite-tls1.3" = recs.ciphersuites; + } + // lib.optionalAttrs (recs.ciphers.openssl != [ ]) { + cipher-suite = lib.concatStringsSep ":" recs.ciphers.openssl; + } + ); + + headerRecAttrs = + lib.optionalAttrs + ( + hasTLSRecommendations + && value.tls != null + && builtins.elem value.tls.policy [ + "force" + "only" + ] + ) + ( + let + headerSet = value.settings."header.set" or [ ]; + recs = mozTLSRecs.${tlsRecommendations}; + hsts = "Strict-Transport-Security: max-age=${builtins.toString recs.hsts_min_age}; includeSubDomains; preload"; + in + { + "header.set" = + if builtins.isString headerSet then + [ + headerSet + hsts + ] + else + headerSet ++ [ hsts ]; + } + ); + in + value.settings + // headerRecAttrs + // { + listen = + let + identity = + value.tls.identity + ++ lib.optional (builtins.elem names.cert certNames.all) { + key-file = "${certs.${names.cert}.directory}/key.pem"; + certificate-file = "${certs.${names.cert}.directory}/fullchain.pem"; + }; + in + { + port = port.TLS; + ssl = (lib.recursiveUpdate tlsRecAttrs value.tls.extraSettings) // { + inherit identity; }; - in - { - port = port.TLS; - ssl = value.tls.extraSettings // { - inherit identity; }; - }; - }; + }; }; in # With a high likelihood of HTTP & ACME challenges being on the same port, @@ -184,11 +269,13 @@ in }; package = lib.mkPackageOption pkgs "h2o" { - example = '' - pkgs.h2o.override { - withMruby = false; - }; - ''; + example = # nix + '' + pkgs.h2o.override { + withMruby = false; + openssl = pkgs.openssl_legacy; + } + ''; }; defaultHTTPListenPort = mkOption { @@ -209,6 +296,8 @@ in example = 8443; }; + defaultTLSRecommendations = tlsRecommendationsOption; + settings = mkOption { type = settingsFormat.type; default = { }; @@ -216,13 +305,7 @@ in }; hosts = mkOption { - type = types.attrsOf ( - types.submodule ( - import ./vhost-options.nix { - inherit config lib; - } - ) - ); + type = types.attrsOf (types.submodule (import ./vhost-options.nix { inherit config lib; })); default = { }; description = '' The `hosts` config to be merged with the settings. diff --git a/nixos/modules/services/web-servers/h2o/vhost-options.nix b/nixos/modules/services/web-servers/h2o/vhost-options.nix index 8917b5d6f05d..907551113826 100644 --- a/nixos/modules/services/web-servers/h2o/vhost-options.nix +++ b/nixos/modules/services/web-servers/h2o/vhost-options.nix @@ -1,4 +1,8 @@ -{ config, lib, ... }: +{ + config, + lib, + ... +}: let inherit (lib) @@ -6,6 +10,8 @@ let mkOption types ; + + inherit (import ./common.nix { inherit lib; }) tlsRecommendationsOption; in { options = { @@ -128,6 +134,7 @@ in ] ''; }; + recommendations = tlsRecommendationsOption; extraSettings = mkOption { type = types.attrs; default = { }; diff --git a/nixos/tests/web-servers/h2o/default.nix b/nixos/tests/web-servers/h2o/default.nix index 23d419842834..e5f8e71103d2 100644 --- a/nixos/tests/web-servers/h2o/default.nix +++ b/nixos/tests/web-servers/h2o/default.nix @@ -13,4 +13,5 @@ in { basic = handleTestOn supportedSystems ./basic.nix { inherit system; }; mruby = handleTestOn supportedSystems ./mruby.nix { inherit system; }; + tls-recommendations = handleTestOn supportedSystems ./tls-recommendations.nix { inherit system; }; } diff --git a/nixos/tests/web-servers/h2o/tls-recommendations.nix b/nixos/tests/web-servers/h2o/tls-recommendations.nix new file mode 100644 index 000000000000..489dd2c70d31 --- /dev/null +++ b/nixos/tests/web-servers/h2o/tls-recommendations.nix @@ -0,0 +1,115 @@ +import ../../make-test-python.nix ( + { lib, pkgs, ... }: + + let + domain = "acme.test"; + port = 8443; + + hello_txt = + name: + pkgs.writeTextFile { + name = "/hello_${name}.txt"; + text = "Hello, ${name}!"; + }; + + mkH2OServer = + recommendations: + { pkgs, lib, ... }: + { + services.h2o = { + enable = true; + package = pkgs.h2o.override ( + lib.optionalAttrs + (builtins.elem recommendations [ + "intermediate" + "old" + ]) + { + openssl = pkgs.openssl_legacy; + } + ); + defaultTLSRecommendations = "modern"; # prove overridden + hosts = { + "${domain}" = { + tls = { + inherit port recommendations; + policy = "force"; + identity = [ + { + key-file = ../../common/acme/server/acme.test.key.pem; + certificate-file = ../../common/acme/server/acme.test.cert.pem; + } + ]; + }; + settings = { + paths."/"."file.file" = "${hello_txt recommendations}"; + }; + }; + }; + settings = { + ssl-offload = "kernel"; + }; + }; + + security.pki.certificates = [ + (builtins.readFile ../../common/acme/server/ca.cert.pem) + ]; + + networking = { + firewall.allowedTCPPorts = [ port ]; + extraHosts = "127.0.0.1 ${domain}"; + }; + }; + in + { + name = "h2o-tls-recommendations"; + + meta = { + maintainers = with lib.maintainers; [ toastal ]; + }; + + nodes = { + server_modern = mkH2OServer "modern"; + server_intermediate = mkH2OServer "intermediate"; + server_old = mkH2OServer "old"; + }; + + testScript = + let + portStr = builtins.toString port; + in + # python + '' + curl_basic = "curl -v --tlsv1.3 --http2 'https://${domain}:${portStr}/'" + curl_head = "curl -v --head 'https://${domain}:${portStr}/'" + curl_max_tls1_2 ="curl -v --tlsv1.0 --tls-max 1.2 'https://${domain}:${portStr}/'" + curl_max_tls1_2_intermediate_cipher ="curl -v --tlsv1.0 --tls-max 1.2 --ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256' 'https://${domain}:${portStr}/'" + curl_max_tls1_2_old_cipher ="curl -v --tlsv1.0 --tls-max 1.2 --ciphers 'ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA256' 'https://${domain}:${portStr}/'" + + server_modern.wait_for_unit("h2o.service") + modern_response = server_modern.succeed(curl_basic) + assert "Hello, modern!" in modern_response + modern_head = server_modern.succeed(curl_head) + assert "strict-transport-security" in modern_head + server_modern.fail(curl_max_tls1_2) + + server_intermediate.wait_for_unit("h2o.service") + intermediate_response = server_intermediate.succeed(curl_basic) + assert "Hello, intermediate!" in intermediate_response + intermediate_head = server_modern.succeed(curl_head) + assert "strict-transport-security" in intermediate_head + server_intermediate.succeed(curl_max_tls1_2) + server_intermediate.succeed(curl_max_tls1_2_intermediate_cipher) + server_intermediate.fail(curl_max_tls1_2_old_cipher) + + server_old.wait_for_unit("h2o.service") + old_response = server_old.succeed(curl_basic) + assert "Hello, old!" in old_response + old_head = server_modern.succeed(curl_head) + assert "strict-transport-security" in old_head + server_old.succeed(curl_max_tls1_2) + server_old.succeed(curl_max_tls1_2_intermediate_cipher) + server_old.succeed(curl_max_tls1_2_old_cipher) + ''; + } +)