Read Connection and Content-Type request headers case-insensitively - #45
Conversation
HTTP header field names are case-insensitive (RFC 7230 §3.2), but web-keep-alive? and Form.decode-request looked up "Connection" and "Content-Type" with exact-case Map.get-with-default. A client sending those headers lowercased was silently missed: `connection: close` was ignored (wrongly keeping the connection ALIVE) and a lowercase `content-type` was missed on the urlencoded-form path. Route both reads through http's case-insensitive Request.header — the same accessor web already uses for these headers in multipart? and the WebSocket upgrade path. Absent-header and canonical-case behavior is unchanged; only non-canonical casing now reads correctly.
There was a problem hiding this comment.
Build & Tests
Checked out claude/ci-header-reads and built locally (armhf; web CI is macOS-only, so this is the real cross-check): test/web.carp 206/0, test/websocket.carp 128/0. CI green. Branch's merge-base is the current main tip — no stale-branch skew.
Findings
The tests are genuinely non-vacuous — verified independently. I reverted only web.carp back to HEAD~1 while keeping the new tests, and got exactly the two casing tests failing (204/2):
Test 'decode-request reads a lowercase content-type header' failed
Test 'keep-alive? honors a lowercase connection: close' failed
…while the two controls (Connection: keep-alive canonical case, absent-on-HTTP/1.1) kept passing. Restoring the fix → 206/0. So each new test pins the specific gap it targets.
The Request.header routing is correct. Confirmed in http@0.2.0 that header-lookup lowercases both the sought name and each stored key and returns (Maybe.Just <first value>) / Nothing — genuine case-insensitive first-value semantics, matching the old Array.unsafe-first "first value" behavior. decode-request now mirrors its sibling Form.multipart? line-for-line, which is the consistency the PR describes.
One out-of-scope observation (not a blocker, no change requested here): web-keep-alive? still compares the header value case-sensitively — (/= &conn "close"). Per RFC 7230 the close connection-option token is itself case-insensitive, so Connection: CLOSE is still (wrongly) treated as keep-alive. This is unchanged from before your PR and orthogonal to the field-name casing you're fixing here, so it's correct to leave it out — just flagging it as a possible future one-liner (String.ascii-to-lower on the value before the compare), not something to fold into this PR.
Verdict: merge
Two real client-facing correctness bugs on legal lowercase-header input, fixed minimally by routing through the accessor web already uses elsewhere, with tests proven to fail without the fix. Clean.
What
HTTP header field names are case-insensitive (RFC 7230 §3.2), and http stores request headers under their original case (not lowercased). Two request-header reads in web used an exact-case
Map.get-with-default, so a client sending the header lowercased was silently missed:web-keep-alive?read"Connection"exactly. A client sendingconnection: close(lowercase) was not seen, so the connection was wrongly kept alive — a real protocol bug.Form.decode-requestread"Content-Type"exactly. A lowercasecontent-type:header was missed, soapplication/x-www-form-urlencodedbodies were not decoded.web was already inconsistent here:
Form.multipart?reads Content-Type case-insensitively viaRequest.header, and the WebSocket upgrade path uses web's ownheader-values-ci. This routes both buggy reads through http'sRequest.header— the same case-insensitive first-value accessor web already uses for these headers — closing both gaps and the internal inconsistency.Semantics preserved
web-keep-alive?returns the same result as before when the header is absent (HTTP/1.1 → keep-alive, otherwise close) and for canonical-case input; it only differs when the header is present in non-canonical case. The exact-case value comparison against"close"is unchanged.Form.decode-requestyields the same value as before for canonical-case input.Tests
Added to
test/web.carp:connection: close(lowercase) →web-keep-alive?false;Connection: keep-alive→ true; absent on HTTP/1.1 → true (default).content-type: application/x-www-form-urlencoded→Form.decode-requestdecodes the body.Verified the tests are non-vacuous by stashing the
web.carpfix: the two casing tests fail against the current code (204/2) and pass after the fix (206/0).Verification (local armhf; web CI is macOS-only)
carp -x test/web.carp→ 206 passed / 0 failed (was 202)carp -x test/websocket.carp→ 128 passed / 0 failedcarp-fmt -cclean;anglerreports only pre-existing HTTP-verb method-name findings, none in the changed codeOpened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.