Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
62 changes: 62 additions & 0 deletions test/web.carp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
41 changes: 23 additions & 18 deletions web.carp
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down