diff --git a/README.md b/README.md index 0ddb11f8..a871d0d8 100644 --- a/README.md +++ b/README.md @@ -358,8 +358,10 @@ Configuration of an HTTPS server connector. :security-provider "the security provider name" :client-auth "either :need or :want to set the corresponding need/wantClientAuth field" :ssl-context "a concrete pre-configured SslContext" - :sni-required? "true if a SNI certificate is required, default false" - :sni-host-check? "true if the SNI Host name must match, default false"} + :sni-required? "true if SNI is required, else requests will be rejected with 400 response, default false" + :sni-host-check? "true if the SNI Host name must match when there is an SNI certificate, default false" + :sts-max-age "set the Strict-Transport-Security max age in seconds, default -1" + :sts-include-subdomains? "true if a include subdomain property is sent with any Strict-Transport-Security header"} ``` ### :slipway.handler.gzip diff --git a/common/src/slipway.clj b/common/src/slipway.clj index ccce28c1..0b614fe3 100644 --- a/common/src/slipway.clj +++ b/common/src/slipway.clj @@ -36,8 +36,10 @@ :security-provider "the security provider name" :client-auth "either :need or :want to set the corresponding need/wantClientAuth field" :ssl-context "a concrete pre-configured SslContext" - :sni-required? "true if a SNI certificate is required, default false" - :sni-host-check? "true if the SNI Host name must match, default false"} + :sni-required? "true if SNI is required, else requests will be rejected with 400 response, default false" + :sni-host-check? "true if the SNI Host name must match when there is an SNI certificate, default false" + :sts-max-age "set the Strict-Transport-Security max age in seconds, default -1" + :sts-include-subdomains? "true if a include subdomain property is sent with any Strict-Transport-Security header"} #:slipway.connector.http{:host "the network interface this connector binds to as an IP address or a hostname. If null or 0.0.0.0, then bind to all interfaces. Default null/all interfaces." :port "port this connector listens on. If set to 0 a random port is assigned which may be obtained with getLocalPort(), default 80" diff --git a/common/src/slipway/connector/https.clj b/common/src/slipway/connector/https.clj index 7567263f..59b19b6a 100644 --- a/common/src/slipway/connector/https.clj +++ b/common/src/slipway/connector/https.clj @@ -8,15 +8,22 @@ (org.eclipse.jetty.util.ssl SslContextFactory$Server))) (defn default-config ^HttpConfiguration - [{::keys [port http-forwarded? sni-required? sni-host-check?] :or {sni-required? false sni-host-check? false}}] - (log/infof "sni required? %s, sni host check? %s" sni-required? sni-host-check?) + [{::keys [port http-forwarded? sni-required? sni-host-check? sts-max-age sts-include-subdomains?] + :or {sni-required? false + sni-host-check? false + sts-max-age -1 + sts-include-subdomains? false}}] + (log/infof "sni required? %s, sni host check? %s, sts-max-age %s, sts-include-subdomains? %s" + sni-required? sni-host-check? sts-max-age sts-include-subdomains?) (let [config (doto (HttpConfiguration.) (.setSecurePort port) (.setSendServerVersion false) (.setSendDateHeader false) (.addCustomizer (doto (SecureRequestCustomizer.) (.setSniRequired sni-required?) - (.setSniHostCheck sni-host-check?))))] + (.setSniHostCheck sni-host-check?) + (.setStsMaxAge sts-max-age) + (.setStsIncludeSubDomains sts-include-subdomains?))))] (when http-forwarded? (.addCustomizer config (ForwardedRequestCustomizer.))) config)) @@ -98,8 +105,10 @@ :security-provider "the security provider name" :client-auth "either :need or :want to set the corresponding need/wantClientAuth field" :ssl-context "a concrete pre-configured SslContext" - :sni-required? "true if a SNI certificate is required, default false" - :sni-host-check? "true if the SNI Host name must match, default false"}) + :sni-required? "true if SNI is required, else requests will be rejected with 400 response, default false" + :sni-host-check? "true if the SNI Host name must match when there is an SNI certificate, default false" + :sts-max-age "set the Strict-Transport-Security max age in seconds, default -1" + :sts-include-subdomains? "true if a include subdomain property is sent with any Strict-Transport-Security header"}) (defmethod server/connector ::connector [^Server server {::keys [host port idle-timeout proxy-protocol? http-config configurator] diff --git a/common/test/slipway/example.clj b/common/test/slipway/example.clj index 94b4ea85..6d976db0 100644 --- a/common/test/slipway/example.clj +++ b/common/test/slipway/example.clj @@ -29,6 +29,13 @@ :truststore-password "password" :truststore-type "PKCS12"}) +(def hsts #::https{:sts-max-age 31536000 + :sts-include-subdomains? true}) + +(def hsts-no-subdomains #::https{:sts-max-age 31536000}) + +(def hsts-no-max-age #::https{:sts-include-subdomains? true}) + (def form-authenticator (FormAuthenticator. "/login" "/login-retry" false)) (def options @@ -38,6 +45,16 @@ :https #::server{:connectors [https-connector] :error-handler app/server-error-handler} + :hsts #::server{:connectors [(merge https-connector hsts)] + :error-handler app/server-error-handler} + + :hsts-no-subdomains #::server{:connectors [(merge https-connector hsts-no-subdomains)] + :error-handler app/server-error-handler} + + ;; this is an error condition / incorrect configuration - subdomains requires max-age set + :hsts-no-max-age #::server{:connectors [(merge https-connector hsts-no-max-age)] + :error-handler app/server-error-handler} + :http+https #::server{:connectors [http-connector https-connector] :error-handler app/server-error-handler} diff --git a/common/test/slipway/https_server_test.clj b/common/test/slipway/https_server_test.clj index e08b0714..a258bb53 100644 --- a/common/test/slipway/https_server_test.clj +++ b/common/test/slipway/https_server_test.clj @@ -253,4 +253,98 @@ (-> (client/do-get "https" "user:wrong@localhost" 3443 "/user" {:insecure? true}) (select-keys of-interest))))) - (finally (example/stop!)))) \ No newline at end of file + (finally (example/stop!)))) + +(deftest strict-transport-security + + (testing "hsts not configured" + + (try + (example/start! [:https]) + + (is (= {:protocol-version {:name "HTTP" :major 1 :minor 1} + :status 200 + :reason-phrase "OK" + :orig-content-encoding "gzip" + :headers {"Connection" "close" + "Content-Type" "text/html" + "Vary" "Accept-Encoding, User-Agent"} + :body (html/user-page {})} + (-> (client/do-get "https://localhost:3443/user" {:insecure? true}) + (select-keys (conj of-interest :headers))))) + + (finally (example/stop!)))) + + (testing "no hsts configuration" + + (try + (example/start! [:https]) + + (is (= {:protocol-version {:name "HTTP" :major 1 :minor 1} + :status 200 + :reason-phrase "OK" + :orig-content-encoding "gzip" + :headers {"Connection" "close" + "Content-Type" "text/html" + "Vary" "Accept-Encoding, User-Agent"} + :body (html/user-page {})} + (-> (client/do-get "https://localhost:3443/user" {:insecure? true}) + (select-keys (conj of-interest :headers))))) + + (finally (example/stop!)))) + + (testing "sts-max-age and subdomains" + + (try + (example/start! [:hsts]) + + (is (= {:protocol-version {:name "HTTP" :major 1 :minor 1} + :status 200 + :reason-phrase "OK" + :orig-content-encoding "gzip" + :headers {"Connection" "close" + "Content-Type" "text/html" + "Strict-Transport-Security" "max-age=31536000; includeSubDomains" + "Vary" "Accept-Encoding, User-Agent"} + :body (html/user-page {})} + (-> (client/do-get "https://localhost:3443/user" {:insecure? true}) + (select-keys (conj of-interest :headers))))) + + (finally (example/stop!)))) + + (testing "sts-include without subdomains" + + (try + (example/start! [:hsts-no-subdomains]) + + (is (= {:protocol-version {:name "HTTP" :major 1 :minor 1} + :status 200 + :reason-phrase "OK" + :orig-content-encoding "gzip" + :headers {"Connection" "close" + "Content-Type" "text/html" + "Strict-Transport-Security" "max-age=31536000" + "Vary" "Accept-Encoding, User-Agent"} + :body (html/user-page {})} + (-> (client/do-get "https://localhost:3443/user" {:insecure? true}) + (select-keys (conj of-interest :headers))))) + + (finally (example/stop!)))) + + (testing "hsts no max age (incorrect configuration, no header included)" + + (try + (example/start! [:hsts-no-max-age]) + + (is (= {:protocol-version {:name "HTTP" :major 1 :minor 1} + :status 200 + :reason-phrase "OK" + :orig-content-encoding "gzip" + :headers {"Connection" "close" + "Content-Type" "text/html" + "Vary" "Accept-Encoding, User-Agent"} + :body (html/user-page {})} + (-> (client/do-get "https://localhost:3443/user" {:insecure? true}) + (select-keys (conj of-interest :headers))))) + + (finally (example/stop!))))) \ No newline at end of file