diff --git a/src/puppetlabs/trapperkeeper/services/webserver/jetty_core.clj b/src/puppetlabs/trapperkeeper/services/webserver/jetty_core.clj index ed8dbd2b..4f288671 100644 --- a/src/puppetlabs/trapperkeeper/services/webserver/jetty_core.clj +++ b/src/puppetlabs/trapperkeeper/services/webserver/jetty_core.clj @@ -800,6 +800,20 @@ (proxy [jakarta.servlet.http.HttpServlet] [] (service [^jakarta.servlet.http.HttpServletRequest request ^jakarta.servlet.http.HttpServletResponse response] + ;; Force chunked transfer encoding. In ring-core 1.15.x, strings + ;; are written directly to the underlying stream and teh stream is + ;; closed without flushing, which lets Jetty use Content-Length framing. + ;; With TLS 1.3, a NewSessionTicket may be sent immediately after the + ;; response data. Ruby's net-http sees the pending TLS record and blocks + ;; on a keep-alive check until timeout. Chunked encoding avoids this + ;; because the ticket is consumed during body reading rather than + ;; lingering in the TCP buffer. + ;; + ;; See https://github.com/OpenVoxProject/openvox-server/issues/197 for + ;; more details. A fix in net-http is https://github.com/ruby/ruby/pull/6423 + ;; but even if that gets merged into the version of Ruby we use in OpenVox, + ;; older agents will be vulnerable to this issue. + (.setHeader response "Transfer-Encoding" "chunked") (let [request-map (-> (servlet/build-request-map request) (assoc :response response)) response-map (handler request-map)] diff --git a/test/clj/puppetlabs/trapperkeeper/services/webserver/jetty_core_test.clj b/test/clj/puppetlabs/trapperkeeper/services/webserver/jetty_core_test.clj index d45f316f..0c4879f5 100644 --- a/test/clj/puppetlabs/trapperkeeper/services/webserver/jetty_core_test.clj +++ b/test/clj/puppetlabs/trapperkeeper/services/webserver/jetty_core_test.clj @@ -139,6 +139,48 @@ access-log-config" (validate-gzip-encoding-when-gzip-requested body port))))))) +(deftest response-uses-chunked-encoding + (with-test-logging + (testing "responses use chunked transfer encoding instead of content-length" + (let [body (apply str (repeat 1000 "f")) + app (fn [req] + (-> body + (rr/response) + (rr/status 200) + (rr/content-type "text/plain") + (rr/charset "UTF-8")))] + (with-test-webserver app port + (let [resp (http-sync/get (format "http://localhost:%d/" port) + {:decompress-body false + :as :text})] + (is (= 200 (:status resp))) + (is (= "chunked" (get-in resp [:headers "transfer-encoding"])) + "response should use chunked encoding to avoid TLS 1.3 NewSessionTicket hang") + (is (nil? (get-in resp [:headers "content-length"])) + "response should not have content-length when using chunked encoding"))) + + (with-test-webserver-and-config app port {:gzip-enable true} + (let [resp (http-sync/get (format "http://localhost:%d/" port))] + (is (= 200 (:status resp))) + (is (= body (slurp (:body resp)))) + (is (= "gzip" (get-in resp [:orig-content-encoding])) + "gzip should still work with chunked encoding"))) + + (testing "small responses also use chunked encoding" + (let [small-app (fn [req] + (-> "hello" + (rr/response) + (rr/status 200) + (rr/content-type "text/plain")))] + (with-test-webserver small-app port + (let [resp (http-sync/get (format "http://localhost:%d/" port) + {:decompress-body false + :as :text})] + (is (= 200 (:status resp))) + (is (= "chunked" (get-in resp [:headers "transfer-encoding"]))) + (is (nil? (get-in resp [:headers "content-length"]))) + (is (= "hello" (:body resp))))))))))) + (deftest jmx (with-test-logging (testing "by default Jetty JMX support is enabled"