Add an RFC 7235 Auth module and Response.unauthorized - #25
Conversation
http modelled every other major header family but had no authentication support at all: only Status.unauthorized (401) with a hardcoded reason, and no way to attach the WWW-Authenticate header that RFC 7235 s3.1 requires of every 401. Consumers (llm, http-client, web) each hand-roll the header. Credentials models RFC 7235 s2.1. `credentials` and `challenge` share an identical ABNF, so one type covers the Authorization and the WWW-Authenticate side alike: a scheme plus either a token68 or a list of auth-params. Params are stored as an ordered array of pairs rather than a Map so that header order survives a parse/serialize round-trip; Carp's Map iterates by bucket, which would make serialization non-deterministic. The list parser is the hard part of RFC 7235: a comma both separates auth-params within a challenge and separates challenges from each other. The scanner resolves this with one rule -- a bare token directly after a scheme is that scheme's token68, and a bare token anywhere else opens a new challenge -- which is what the grammar implies, since the token68 alternative can only follow the scheme with no comma between. Basic (RFC 7617) goes through base64.carp; Bearer (RFC 6750) is plain. Scheme matching is case-insensitive, the scheme itself is preserved as written so round-trips are byte-exact, and param values are always quoted on the way out, which the grammar permits for any of them. The parser handles attacker-controlled header values, so the scanner never indexes past the value it was given: quoted-strings that are unterminated or end in a backslash run to the end of the input, bytes that fit the grammar nowhere are dropped, and base64 that is invalid or has no colon comes back as an error. 56 new assertions, 256 total.
There was a problem hiding this comment.
Build & Tests
carp -x test/http.carp on c56fb67: 256 assertions, 0 failures (200 → 256, as claimed). CI green on ubuntu + macOS at this head, so the new base64.carp@0.2.0 dependency resolves there as well as here. Merge-base is the current origin/master tip (d46fa15), a clean fast-forward. No CHANGELOG in this repo and none added, which is right. docs/Response.html is +24/−0 and contains only the new unauthorized entry — minimal, disclosed, and consistent with #20.
This is a draft, so treat the below as input rather than a gate.
Findings
1. token68 is scanned with the RFC 7230 tchar set, which excludes / — so Auth.basic produces values Auth.parse cannot read back (http.carp:1312, 1328, 1379).
tchar? is the RFC 7230 token set. But token68 is a different production, and both RFC 7235 §2.1 and RFC 6750 §2.1 spell it out:
token68 = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"="
Every one of those characters is a tchar except / — and / is in the standard base64 alphabet that Base64.encode-str emits. So the module cannot read its own output:
Auth.basic "u" "aaa?" => (Success "Basic dTphYWE/")
Auth.parse "Basic dTphYWE/" => scheme='Basic' token='dTphYWE' <-- truncated at the /
Auth.basic-credentials => ERROR "input length must be a multiple of 4"
scan stops the token at the /, then line 1378 drops the / as a byte that fits nowhere, and whatever follows it is read as a new challenge:
Auth.parse-challenges "Bearer ab/cd.ef/gh" => 2 challenges
Auth.parse "Bearer ab/cd.ef/gh" => ERROR "more than one scheme in credentials"
This is not a corner. Measuring how often Base64.encode-str "user:password" contains a /, over 20 000 random printable-ASCII credentials per row:
user 4 chars, password 8 chars -> 3.8%
user 5 chars, password 12 chars -> 4.9%
user 8 chars, password 16 chars -> 6.4%
user 6 chars, password 20 chars -> 8.2%
user 10 chars, password 32 chars -> 13.4%
So roughly one Basic login in twenty is unreadable, and an opaque Bearer token in the standard alphabet — which RFC 6750 explicitly permits — is misparsed as two challenges. (URL-safe base64 is unaffected: - and _ are both tchars.)
It fails loudly rather than silently, which is the one piece of good news. For the truncated prefix to be a decodable length, the / would have to sit at a base64 index that is a multiple of 4, and that requires a source byte ≥ 252 — which valid UTF-8 never contains. Every case I could construct ends in a clean Result.Error, not in different credentials. Worth confirming rather than relying on, though.
The fix is small. Since the token68 position is exactly where token68-ok is true, only that scan needs the wider set — a param name can never contain /, so nothing else can be affected. I prototyped this to confirm the diagnosis rather than to prescribe the patch:
(hidden token68-char?)
(private token68-char?)
; RFC 7235 token68 allows "/", which is not an RFC 7230 tchar.
(defn token68-char? [c] (or (tchar? c) (= c \/)))
(hidden scan-token68)
(private scan-token68)
(defn scan-token68 [s i len]
(let-do [j i]
(while (and (< j len) (token68-char? (String.char-at s j)))
(set! j (Int.inc j)))
j))with line 1379 becoming
(let-do [tend (if token68-ok
(scan-token68 s i len)
(scan-token s i len))Deliberately not adding = to that set, so realm="a" still takes the auth-param branch and dXNlcjpwYXNz== still takes the scan-padding branch. With it: Basic dTphYWE/ round-trips through basic-credentials to u / aaa?, Bearer ab/cd.ef/gh is one challenge, the RFC §4.1 example is still 2 challenges, Basic realm="a", Bearer, Digest realm="b" is still 3, and the suite is still 256/0.
That last number is also the coverage point: a behaviour-changing fix leaves the suite untouched, so nothing in test/http.carp exercises a / in a token68 at all. The PR describes testing Basic dXNlcjpwYXNz== for padding, which pins the = handling but not the alphabet.
2. Minor, the other direction of the same mismatch. tchar? also accepts !#$%&'*^`| in a token68, which the grammar does not allow, and a bare high byte is taken into a scheme token (Auth.parse-challenges "Basic \xc3\xbf…" → 1 challenge). Being lax about what it accepts is a much smaller problem than truncating what it should accept, and matches how the rest of the module treats malformed input, so I would leave it — noting it only because it is the same root cause.
What held up
I went after the scanner specifically and could not fault the rest of it.
- Round-trip. 26 hand-picked awkward values — a
,inside a quoted realm, escaped"and\, an empty value, a bare scheme,realm=with nothing after it, an unquoted value, an unterminated quoted-string, a trailing backslash, a stray=, a tab inside a quote,eq=signinside a value, duplicate param names, a lowercasebasicwith an uppercaseREALM— all satisfyparse(str(c)) == c. - Hostile input, my own fixtures rather than the PR's. 50 000 backslashes inside an unterminated quote, 50 000 quotes, 30 000 commas, 10 000 chained challenges, 10 000 chained params, 50 000
=of padding, multi-byte UTF-8 inside and outside quotes, a lone"after a scheme,=alone, empty. No crash, no hang, every one gives a defined answer. - The ambiguity rule is right. The RFC §4.1 example gives 2 challenges,
Basic realm="a", Bearer, Digest realm="b"gives 3 with the middle one empty, andDigest realm="a", qop="auth"gives 1 with 2 params. - The colon split in
basic-credentialsis safe on multi-byte input.String.index-ofis implemented over bytes (carp_string.h:365), the same index spacebyte-sliceuses, so a UTF-8 user-id cannot desynchronise the split. scanalways advances, on every branch, so no input can loop it.Response.unauthorizedcomposesset-headeroverrespond, so it replaces rather than appends an existingWWW-Authenticate— covered by the suite and it behaves.
On the two decisions you flagged
Both are yours, but since you asked: I would keep the base64 dependency. The web precedent is a fixed-format encode for one handshake; this needs decode too, and the finding above is a small argument for the dep rather than against — the alphabet is exactly the sort of detail that goes wrong when hand-rolled. Leaving gendocs.carp alone also seems right; bringing that list up to date is a separate change and mixing it in here would obscure this diff.
Verdict: revise
The design is the strongest part of this PR — one type for both sides of §2.1 because the ABNF is identical, an ordered pair array rather than a Map so serialization is stable, and a challenge-list scanner whose one lookahead rule falls out of the grammar and handles every case I threw at it including the RFC's own worst example. Malformed input really was probed rather than assumed; I could not crash it. But token68 is scanned with the wrong character set, and the consequence is that Auth.basic emits values Auth.parse truncates — a few percent of Basic credentials and any standard-alphabet Bearer token. Add / to the scan at the token68 position, add a test with a / in it, and this is ready.
token68 is a different production from token: RFC 7235 §2.1 and RFC 6750 §2.1 both give it as 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"=". Every one of those is a tchar except "/", which is in the standard base64 alphabet Base64.encode-str emits — so the module could not read its own output: Auth.basic "u" "aaa?" => Basic dTphYWE/ Auth.parse "Basic dTphYWE/" => token 'dTphYWE', truncated at the / Auth.basic-credentials => "input length must be a multiple of 4" and a standard-alphabet Bearer token was read as two challenges. Roughly one Basic login in twenty is affected. The wider set applies only where token68-ok holds, so "realm=\"a\"" keeps the auth-param branch and "dXNlcjpwYXNz==" keeps the padding branch; "=" stays out of it for the same reason. Both the dispatch and the scan are widened, so a token68 may also begin with a slash rather than silently losing it. Seven assertions added, 256 -> 263. Five of them fail without the fix; the other two pin that the fixture really contains a slash and that the wider alphabet does not reach an auth-param value.
|
Addresses finding 1 of @carpentry-reviewer's Reproduced first, on
|
There was a problem hiding this comment.
Build & Tests
carp -x test/http.carp on c902860: 263 assertions, 0 failures (256 → 263). CI green on ubuntu + macOS against this exact head (12:17Z). Merge-base is the current origin/master tip (d46fa15), so the branch is a clean fast-forward and there is no stale-branch hazard. This repo has no CHANGELOG, and the README carries the new module's documentation.
git diff c56fb67..c902860 touches token68-char?, scan-token68 and three lines inside scan, plus seven assertions. Nothing else in the module moved.
Prior feedback
The token68 alphabet finding is fixed, and I verified it at scale rather than on the reported fixtures. I built binaries from c56fb67 and c902860 and ran 4037 header values through Auth.parse-challenges, comparing full structure — challenge count, scheme, token and every param:
differing outputs: 148 / 4037
differing inputs that contain NO '/': 0
That zero is the result that matters: the widened scan is reachable only where token68-ok holds, and nothing without a / in it changed behaviour in any way. The 42 challenge-count changes are all 2 → 1 or 3 → 2 collapses where a slash was previously splitting one token into two challenges.
The end-to-end bug is gone. 4000 random user:password pairs, encoded exactly as Auth.basic does, then pushed back through Auth.parse and Auth.basic-credentials:
c56fb67 round-trip OK 3862 decode ERROR 138 wrong value 0
c902860 round-trip OK 4000 decode ERROR 0 wrong value 0
All 138 failures on the old head are exactly the inputs whose base64 contains a / — a measured 3.5% of credentials, inside the 3.8–13.4% band I estimated. wrong value 0 on both confirms the other half of what I claimed last round: it fails loudly, and never decodes to different credentials.
Spot checks all hold: the §4.1 example still yields 2 challenges, Basic realm="a", Bearer, Digest realm="b" still yields 3, Basic dXNlcjpwYXNz== keeps its padding, Bearer ab/cd.ef/gh is one challenge, and Bearer /abc gives /abc. Basic dTphYWE/= and /== now keep the slash and the padding, which the old head truncated. Basic a//b//c collapses from two challenges to one token.
Restricting the widening to the token68-ok position is the right call and the (not (if token68-ok (token68-char? c) (tchar? c))) skip branch is widened consistently with it, so a leading / opens a token68 but is still dropped anywhere else. Declining to add = keeps realm="a" on the auth-param branch and dXNlcjpwYXNz== on the padding branch, exactly as intended.
Findings
Nothing blocking. I could not break it.
Beyond the differential above I re-ran my hostile fixtures against the new scanner — 20 000 consecutive /, 10 000 a/ repeats, a 20 000-character unterminated quoted string, 5 000 backslashes, 2 000 chained challenges, 2 000 chained params, and high bytes inside and outside quotes, both raw and after a scheme. No crashes, no hangs, and scan-token68 is bounded by len on the same (< j len) guard as scan-token, so the unchecked String.char-at cannot walk off the end.
One observation, not a defect: because name is scanned with the wider alphabet whenever token68-ok holds, an auth-param name can now contain a / — Basic dTphYWE/=x becomes a param dtphywe/ = x where it used to be a token plus a stray challenge. Both readings are of malformed input (a / is legal in neither an RFC 7235 token nor a complete token68 followed by =x), the param value side is correctly still scanned with scan-token, and your test pins that. I mention it only because it is the one behaviour change the tests do not cover.
Finding 2 from last round — tchar? also admitting !#$%&'*^`| in a token68 — is untouched, as agreed.
Verdict: merge
The blocking finding is fully resolved, and the fix is precisely scoped: every one of the 148 behaviour changes across 4037 header values involves a /, and Basic credentials now round-trip 4000/4000 where the previous head lost 138 of them. The challenge-separation rule, the padding branch and the auth-param branches are all unaffected, and the new assertions genuinely cover the alphabet rather than restating the old behaviour. Leaving this as a draft for you — I have not marked it ready — but from my side there is nothing left to fix. The two disclosed decisions (the base64.carp dependency, and gendocs.carp left alone) are still yours to make, and the regenerated docs/Response.html follows the precedent set by #20.
http.carpnow starts with:Basic auth needs base64, and this is the only reason for it. It's an intra-org,
pure-Carp package with no native dependencies, and it loads and round-trips
cleanly alongside
http. Buthttphad three dependencies before this and nowhas four, and the alternative is what
webalready chose: when theWebSocket handshake needed base64,
webhand-rolled a ~20-line internal encoderrather than take the dep.
I kept the base64 use confined to
Auth.basicandAuth.basic-credentials—two call sites,
Base64.encode-strandBase64.decode-str— so swapping it foran internal encoder is a small, local edit and nothing else in the module moves.
Say the word and I'll do that instead.
What this adds
httpmodelsCookie,Form,MediaType,Accept,CacheControl,TransferEncoding,Multipart,Request,ResponseandStatus— butnothing at all for authentication. Today the org has
Status.unauthorized(401)with a hardcoded reason string,
webnamingAuthorizationonce in a CORSdefault, and
http-clientknowing it only as a header to strip on cross-originredirect.
llmbuilds itsAuthorizationvalue by hand. Nothing parses eitherside of RFC 7235.
CredentialsRFC 7235 §2.1 gives
credentialsandchallengebyte-identical ABNF:so one type models both the
Authorizationand theWWW-Authenticatesiderather than two types with the same shape. If you'd rather have the two names
even at the cost of the duplication, that's an easy change.
paramsis an ordered array of pairs rather than aMapbecause Carp'sMapiterates by hash bucket, which would make serialization order arbitrary and
break the round-trip. Lookup is a linear scan over a list that is never more
than a handful of entries.
The challenge-list parser
This is the part worth reviewing. In
WWW-Authenticatea comma both separatesauth-params within a challenge and separates challenges from each other, so
these two need lookahead to tell apart:
Digest realm="a", qop="auth"Basic realm="a", Bearer realm="b"The scanner resolves it with one rule: a bare token directly after a scheme is
that scheme's token68; a bare token anywhere else opens a new challenge. That
follows from the grammar — the
token68alternative can only appear right afterauth-schemewith no comma in between — and it handles the awkward cases:Newauth realm="apps", type=1, title="Login to \"apps\"", Basic realm="simple"→ two challengesBasic realm="a", Bearer, Digest realm="b"→ three challenges, the middle one with no params at allBasic dXNlcjpwYXNz==→ token68 keeps its=padding rather than being read asdXNlcjpwYXNzwith an empty paramBasic, Bearer, and the accessors
Auth.basic/Auth.basic-credentials(RFC 7617) — splits on the firstcolon only, so a password may contain colons; a user-id containing one is
rejected at build time, since the scheme cannot represent it.
Auth.bearer/Auth.bearer-token(RFC 6750).Auth.basic-challenge/Auth.bearer-challengebuild server-side values.as written, so
Basic dXNlcjpwYXNzround-trips byte-for-byte.\xquoted-pairs; unquoted token values worktoo;
Credentials.realmandCredentials.param(case-insensitive) read themback.
Credentials.stralways quotes param values on the way out. The grammarpermits a quoted-string for any auth-param, so this is always valid and always
round-trips; the one visible effect is that
type=1re-serializes astype="1".Response.unauthorizedA 401 with no
WWW-Authenticateviolates RFC 7235 §3.1, and theResponseconstructor family could not attach one. This is the smallest fix — it mirrors
redirect, which takes itsLocationthe same way:Testing
carp -x test/http.carp: 256 passing, up from 200. Baseline was greenbefore the change and no existing assertion moved.
Since this parses attacker-controlled headers and Carp's
String.char-atandbyte-slicedon't bounds-check — a bad computed index segfaults rather thanerroring — the malformed-input cases are covered directly rather than assumed:
empty and whitespace-only values, a lone scheme with no credentials, a trailing
comma, an unterminated quoted-string, a trailing backslash inside a
quoted-string, a stray
=, commas alone, non-ASCII bytes both inside andoutside a quoted-string, a 20 000-character value, and a 20 000-character run of
delimiters. None crash; each has a defined result.
Base64 that doesn't decode, or decodes to something with no colon, comes back as
a clean
Result.Error. A loneBasicdoes too.I also ran the new assertions against three deliberate mutations to confirm they
aren't vacuous: removing the token68 lookahead fails 11, dropping the
backslash-escaping on serialization and making scheme matching case-sensitive
fail 4 between them.
Beyond the suite, I fuzzed the scanner separately with 10 000-character runs of
commas, quotes, backslashes and equals signs, a 50 000-character token, a
50 000-character unterminated quoted-string, 5 000 chained challenges and 5 000
chained params — no crashes, all fast.
carp-fmt --checkandanglerare clean on both changed files;gendocs.carpruns, and its regenerated
docs/Response.htmlis included.Two loose ends for you
gendocs.carpis untouched. Itssave-docslist isRequest Response Cookie Status Form TransferEncoding— it already omitsMediaType,Accept,CacheControlandMultipart, so addingAuthalonewould be inconsistent. Tell me if you want the list brought up to date; that
felt like a separate change.
CHANGELOG.mdwas created, sincehttpdoesn't have one.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.