diff --git a/CHANGELOG.md b/CHANGELOG.md index aa9faf2..5c775e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,12 @@ `close`, so a client sending `CLOSE`, `Close`, or `close, TE` kept the socket open and got `Connection: keep-alive` back. The value is now read as a list of case-insensitive comma-separated tokens (RFC 9110 §7.6.1). +- **`Connection: close` is honoured when it arrives on its own header line.** + A client sending `Connection: keep-alive` and `Connection: close` as two + separate field lines kept the socket open, because only the first line was + read. Every `Connection` line is now considered — whatever the case of its + field name, so `connection:` from an HTTP/2 downgrading proxy counts too — + as RFC 9110 §5.3 requires of repeated field lines. ## 0.8.0 (2026-07-12) diff --git a/test/web.carp b/test/web.carp index 08674e0..f9b38ae 100644 --- a/test/web.carp +++ b/test/web.carp @@ -541,6 +541,68 @@ (web-keep-alive? &(parse-req "GET / HTTP/1.0\r\nHost: x\r\n\r\n")) "keep-alive? defaults to close on HTTP/1.0 without Connection") + ; -- repeated Connection field lines (RFC 9110 §5.3) -- + (assert-false test + (web-keep-alive? + &(parse-req + "GET / HTTP/1.1\r\nConnection: keep-alive\r\nConnection: close\r\n\r\n")) + "keep-alive? honors close on a later Connection line") + + (assert-false test + (web-keep-alive? + &(parse-req + "GET / HTTP/1.1\r\nConnection: close\r\nConnection: keep-alive\r\n\r\n")) + "keep-alive? honors close on an earlier Connection line") + + (assert-false test + (web-keep-alive? + &(parse-req + "GET / HTTP/1.1\r\nConnection: TE\r\nConnection: Keep-Alive, CLOSE\r\n\r\n")) + "keep-alive? honors a close token in any of several Connection lines") + + (assert-true test + (web-keep-alive? + &(parse-req + "GET / HTTP/1.1\r\nConnection: TE\r\nConnection: keep-alive\r\n\r\n")) + "keep-alive? true when no Connection line lists close") + + ; -- repeated Connection lines differing in field-name case (RFC 9110 §5.1) -- + (assert-false test + (web-keep-alive? + &(parse-req + "GET / HTTP/1.1\r\nConnection: close\r\nconnection: keep-alive\r\n\r\n")) + "keep-alive? honors close on a differently cased earlier Connection line") + + (assert-false test + (web-keep-alive? + &(parse-req + "GET / HTTP/1.1\r\nconnection: keep-alive\r\nConnection: close\r\n\r\n")) + "keep-alive? honors close on a differently cased later Connection line") + + (assert-false test + (web-keep-alive? + &(parse-req + "GET / HTTP/1.1\r\nCONNECTION: close\r\nConnection: keep-alive\r\n\r\n")) + "keep-alive? honors close on an upper-case CONNECTION line") + + (assert-false test + (web-keep-alive? + &(parse-req + "GET / HTTP/1.1\r\nConnection: keep-alive\r\nCONNECTION: close\r\n\r\n")) + "keep-alive? honors a trailing upper-case CONNECTION: close") + + (assert-false test + (web-keep-alive? + &(parse-req + "GET / HTTP/1.1\r\nConnection: close\r\nconnection: keep-alive\r\nCONNECTION: TE\r\n\r\n")) + "keep-alive? honors close among three case variants of Connection") + + (assert-true test + (web-keep-alive? + &(parse-req + "GET / HTTP/1.1\r\nConnection: TE\r\nconnection: keep-alive\r\n\r\n")) + "keep-alive? true when no cased Connection variant lists close") + ; -- decode-multipart-request -- (assert-equal test "hello" diff --git a/web.carp b/web.carp index 49fc178..751351c 100644 --- a/web.carp +++ b/web.carp @@ -1289,15 +1289,30 @@ responses. The `protocols` field lists the subprotocols that this route supports nl &(String.to-bytes "http/1.1"))))))) +; Case-insensitive header value lookup. Header names in the parsed map +; preserve original case (RFC 7230 §3.2 says names are case-insensitive). +(hidden header-values-ci) +(defn header-values-ci [hdrs name] + (let [lower-name &(String.ascii-to-lower name)] + (Map.kv-reduce + &(fn [acc k v] + (if (= &(String.ascii-to-lower k) lower-name) + (Array.concat &[acc @v]) + acc)) + (the (Array String) []) + hdrs))) + (defn web-keep-alive? [req] - (let [http11 (= (Request.version req) "HTTP/1.1") - conn (match (Request.header req "Connection") - (Maybe.Just v) v - (Maybe.Nothing) (if http11 @"keep-alive" @"close"))] - ; RFC 9110 §7.6.1: connection options are case-insensitive comma-separated tokens - (not - (Array.any? &(fn [t] (= &(String.trim t) "close")) - &(String.split-by &(String.ascii-to-lower &conn) &[\,]))))) + (let [conns (header-values-ci (Request.headers req) "Connection")] + (if (Array.empty? &conns) + (= (Request.version req) "HTTP/1.1") + ; RFC 9110 §5.3/§7.6.1: repeated lines form one comma-separated token list + (not + (Array.any? + &(fn [c] + (Array.any? &(fn [t] (= &(String.trim t) "close")) + &(String.split-by &(String.ascii-to-lower c) &[\,]))) + &conns))))) (defn web-finalize-response [resp keep-alive] (let [body-len (String.length (Response.body &resp)) @@ -1411,16 +1426,6 @@ responses. The `protocols` field lists the subprotocols that this route supports (when found (break)))) result)) -; Case-insensitive header value lookup. Header names in the parsed map -; preserve original case (RFC 7230 §3.2 says names are case-insensitive). -(hidden header-values-ci) -(defn header-values-ci [hdrs name] - (let [lower-name &(String.ascii-to-lower name)] - (Map.kv-reduce - &(fn [acc k v] (if (= &(String.ascii-to-lower k) lower-name) @v acc)) - (the (Array String) []) - hdrs))) - ; Try to parse a WebSocket upgrade from a raw HTTP buffer. Returns ; (Maybe (Pair String (Pair Int (Pair (Maybe String) (Map String String))))) ; — the accept key, route index, negotiated protocol, and params — or