fix cookie expiry: compare as an instant, honour Max-Age precedence#23
Conversation
Cookie.expired? compared Datetime.now (local wall-clock, with the local zone attached) against an Expires value parsed from a GMT string with Datetime.>, which is timezone-unaware: the two operands were in different frames, so the verdict was off by the machine's UTC offset. The time library grew after-instant? in 0.5.0; this call site was never migrated. The load bump to time@0.5.0 is required by the fix, not decorative. expired? is now a wrapper around a new expired-at?, which takes the instant to compare against, so the timezone behaviour is testable without depending on the test machine's zone. parse-set folded the ; separated attributes left to right, so between Max-Age and Expires the last one written won. RFC 6265 §4.1.2.2 gives Max-Age precedence regardless of order, so the fold now re-applies the Max-Age attribute, if there is one, after everything else. A Max-Age of zero previously resolved to now + 0. expired? compares strictly, so whether that reads as expired depended on Datetime.now's nanoseconds field advancing between parse and check. RFC 6265 §5.2.2 says a non-positive Max-Age expires the cookie immediately; the expiry is now the UNIX epoch, which is deterministic and stays representable where Long is 32 bits.
There was a problem hiding this comment.
Build & Tests
Checked out claude/cookie-expiry-instant-maxage (fbe5818) and ran it on armhf.
carp -x test/http.carp→ 188 passed, 0 failed, matching the claim (+10 over the 178 baseline).- CI is green on the current head SHA (
fbe5818a), not a stale commit; branch merge-base equalsorigin/masterhead. - The
time@0.4.0 → 0.5.0bump checks out exactly as described. The only removed definitions arefind-month-from-days,for-yearandto-unix; I confirmed all three areprivate+hiddenin 0.4.0 (time.carp:325-332, 357-359) and that neitherhttp.carpnortest/http.carpreferences any of them. The bump is load-bearing —after-instant?is new in 0.5.0.
I reproduced all three non-vacuity mutations independently rather than taking the PR's word for it, and each is caught by exactly the right tests:
| mutation | result |
|---|---|
after-instant? → > (tz-unaware) |
186/2 — both UTC+2 / UTC−5 instant tests |
| drop the Max-Age re-application | 187/1 — Max-Age wins over an Expires that comes after it |
max-age-expiry non-positive → now + i |
187/1 — Max-Age=0 puts the expiry in the past, not at the instant of parsing |
The tests are load-bearing. Both diagnosed bugs are real and the fixes are right in the direction they aim at.
Findings
1. Blocking — the instant comparison introduces a Y2038 regression: any expiry after 2038-01-19 now reports as already expired. http.carp:44
Datetime.after-instant? funnels through Datetime.to-unix-timestamp, which returns an Int — 32-bit in Carp on every target, not just this Pi. Far-future dates wrap negative, so "now" compares as later than the expiry and the cookie is declared dead. The old Datetime.> compared wall-clock fields and had no such range limit, so this is a regression, not a pre-existing issue. Identical probe, both branches:
master this PR
id=a; Expires=Mon, 18 Jan 2038 00:00:00 GMT => live live
id=a; Expires=Wed, 20 Jan 2038 00:00:00 GMT => live EXPIRED
id=a; Expires=Wed, 01 Jan 2070 00:00:00 GMT => live EXPIRED
id=a; Expires=Fri, 31 Dec 9999 23:59:59 GMT => live EXPIRED
id=a; Max-Age=500000000 => live EXPIRED
The wrap is clean and exactly at the classic boundary:
Datetime.to-unix-timestamp 2038-01-18 -> 2147385600
Datetime.to-unix-timestamp 2039-01-01 -> -2117514496
Practical impact today is the "never expires" idiom — Expires=Fri, 31 Dec 9999 23:59:59 GMT and friends are a common way to write a permanent cookie, and they are now dropped on arrival, including by http-client's CookieJar. It fails in the safe direction (cookies die early rather than outliving their expiry), so it isn't a security hole, but it is a functional break. The existing test suite misses it because the future-Expires test uses 2037, which sits just under the cliff.
A fix that keeps everything this PR is trying to buy: normalize both operands to UTC and compare wall-clock, skipping the epoch-seconds bottleneck entirely. Datetime.to-utc is public in time@0.5.0.
(defn expired-at? [c t]
(match @(expires c)
(Maybe.Just d) (> &(Datetime.to-utc t) &(Datetime.to-utc &d))
(Maybe.Nothing) false))
I applied exactly that and re-ran: 188/0 — the two timezone tests still pass, so it is genuinely timezone-aware and not a quiet revert — and all seven far-future cases above go back to live. Worth adding a post-2038 Expires assertion so this can't silently come back.
The root cause is in time: after-instant?, before-instant? and equal-instant? are all silently wrong past 2038-01-19 for the same reason. That's worth an issue on time regardless of what happens here, but http doesn't need to wait for it.
2. Non-blocking — Max-Age values that overflow are not clamped. http.carp:107
RFC 6265 §5.2.2 says a delta-seconds larger than the largest representable value should be clamped to it. Max-Age=2147483647 (a legal 68-year max-age) instead yields an immediately expired cookie on both branches, so this one is pre-existing rather than new. Flagging it because §5.2.2 is exactly the clause the new max-age-expiry cites for the non-positive case, so the other half of the same sentence is a natural thing to handle in the function you just introduced. Note that fixing finding 1 does not fix this — the overflow here is in Datetime.add-seconds, before any comparison happens.
3. Nit — the PR description says "this UTC box". This machine is Europe/Berlin/CEST, i.e. UTC+2. That actually makes the evidence stronger than claimed: the explicitly-zoned operands mean the tests pass unchanged at UTC+2 locally and at UTC in CI. Only the description needs correcting.
Things I checked that are fine
parse-set-propshort-circuits on(Result.Error err), so re-applying the trailingMax-Agecannot resurrect a cookie that failed on an earlier attribute. Verified both orders:SameSite=bogus; Max-Age=10andMax-Age=10; SameSite=bogusboth stay errors.Array.unsafe-lastonmax-agesis guarded byArray.empty?, and picking the last is right per RFC 6265 §5.3 —Max-Age=100; Max-Age=0expires,Max-Age=0; Max-Age=100stays live.max-age-attr?doesn't over-match:Path=/max-age; Max-Age=0and a cookie namedid=maxageare both classified correctly, and theattrsslice excludes the name=value pair.Datetime.from-unix-timestamp 0yields a tz-less datetime thatto-utctreats as zero-offset, so the epoch sentinel is unambiguous.- Applying the
Max-Ageattribute twice is harmless — the second application wins and the values agree. - No CHANGELOG in this repo, so nothing to update.
Verdict: revise
Both diagnosed bugs are real and the tests are proven non-vacuous, but the instant comparison trades a several-hour timezone error for a hard cliff that reports every post-2038 expiry as expired — swap after-instant? for the to-utc + > form above (188/0 with the cliff gone) and this is ready.
Datetime.after-instant? funnels through the 32-bit to-unix-timestamp, so every expiry after 2038-01-19 wrapped negative and read as already expired — a regression that dropped the Expires=...9999 "never expires" idiom (and http-client's CookieJar) on arrival. Normalize both operands with Datetime.to-utc and compare wall-clock instead: timezone-aware, with no epoch range limit. Adds post-2038 live assertions (2070 and 9999).
|
Thanks @carpentry-reviewer — the blocking finding is fixed in e7efb8e. Finding 1 (blocking — Y2038 regression). Swapped the This sidesteps the 32-bit Non-vacuity, as you asked — two new post-2038 assertions (the existing future-Expires test used 2037, just under the cliff):
Mutation-proven: with the two assertions added but Finding 3 (nit). Correct — this machine is Finding 2 (non-blocking). Left untouched — you marked the |
There was a problem hiding this comment.
Build & Tests
Checked out the new head e7efb8e8 on armhf (merge-base still equals origin/master, so nothing stale), rebuilt, and ran carp -x test/http.carp → 190 passed, 0 failed, matching the claim (+2 over the 188 at fbe5818).
Prior feedback
- Finding 1 (was blocking — Y2038 regression): fixed.
expired-at?now compares(> &(Datetime.to-utc t) &(Datetime.to-utc &d))instead ofDatetime.after-instant?, which is exactly the form I proposed. I re-derived why it dodges the cliff rather than trusting it: a tz-lessExpiresisutc?(time.carp:363,Maybe.nothing? (tz dt)→ true), soto-utctakes its relabel fast-path (time.carp:376-377) and never callsadd-seconds/to-unix-timestampon the far-future operand.Datetime.nowis 2026, well under the boundary, so its slow-path shift can't wrap either. Both post-2038 cases (2070,9999) now readlive. - Non-vacuity, re-confirmed independently: reverting
expired-at?back toafter-instant?fails exactly the two new assertions (188/2— "an Expires past 2038…" and "the never-expires idiom (9999)…"), and nothing else. The timezone assertions (UTC+2 → before 12:00 GMT, UTC−5 → after) still pass with the fix in place, so it is genuinely timezone-aware and not a quiet revert to naiveDatetime.>. - Finding 2 (Max-Age > INT_MAX clamp): correctly left out of scope — pre-existing and non-blocking, and the overflow is in
Datetime.add-secondsbefore any comparison, so finding 1's fix doesn't touch it. Fine as a separate follow-up. - Finding 3 (description "UTC box"): acknowledged; the explicitly-zoned test operands make the evidence stronger, as noted.
Findings
Nothing new. The two diagnosed bugs (instant-vs-wall-clock expiry, Max-Age precedence) remain correctly fixed, and the regression the first fix introduced is now closed with no coverage lost. The root cause still lives in time (after-instant?/before-instant?/equal-instant? all wrap past 2038-01-19) and deserves its own issue there, but http doesn't need to wait for it.
Verdict: merge
The blocking Y2038 regression is resolved exactly as recommended, tests are green (190/0) and mutation-proven non-vacuous, and CI is green on the current head. Ready to merge from my side; it's still marked draft, so the final call and un-drafting are yours.
Two cookie-expiry defects in the
Cookiemodule, and the dependency bump one of them needs.1. Expiry was compared as wall-clock, not as an instant
Cookie.expired?was(> &(Datetime.now) &d).Datetime.>is documented in the time library as timezone-unaware — it compares the wall-clock fields and ignores the zone. But the two operands live in different frames:Datetime.nowreturns local time with the local zone attached (tm_gmtoff), whileexpirescomes out ofparse-set-prop's"%a, %d %b %Y %H:%M:%S GMT"parse with no timezone, which the library treats as UTC.So the verdict was off by the machine's UTC offset. On UTC+2 a cookie was judged expired two hours early; on UTC-5 a session cookie stayed live five hours past its stated expiry.
The time library already ships the right primitive —
Datetime.after-instant?, added in carpentry-org/time#16 — and this call site was simply never migrated. Thetime@0.4.0→time@0.5.0bump on line 2 is required by this fix, not decorative:after-instant?landed in 0.5.0. I checked the 0.4.0..0.5.0 delta: the only removals arefor-year,to-unix, andfind-month-from-days, allprivate+hiddenin 0.4.0, and http only reaches forDatetime.add-seconds/now/strftime/strptimeplus the generated field getters, all still present. The full suite passes on 0.5.0.expired?is now a wrapper over a new publicexpired-at?, which takes the instant to compare against. That is what makes the timezone behaviour testable: this box is UTC, so a test that only goes throughDatetime.nowwould pass vacuously here and prove nothing.2. Max-Age did not take precedence over Expires
RFC 6265 §4.1.2.2: "If a cookie has both the Max-Age and the Expires attribute, the Max-Age attribute has precedence and controls the expiration date of the cookie."
parse-setfolds the;-separated attributes left to right, so it was last-one-wins:Set-Cookie: a=b; Max-Age=0; Expires=<far future>left the cookie alive even thoughMax-Age=0means delete it now, and swapping the two attributes gave the opposite answer for the same cookie. The fold now re-applies the Max-Age attribute, if the header carries one, after everything else. Unknown attributes (#3), case-insensitive attribute names (#14), and every existing error string are untouched.A
Max-Ageof zero or less also needed handling: it resolved tonow + 0, and sinceexpired?compares strictly, whether that read as expired depended onDatetime.now's nanoseconds field (filled from the monotonic clock) happening to advance between parse and check. RFC 6265 §5.2.2 says a non-positive Max-Age expires the cookie immediately, so the expiry is now the UNIX epoch — deterministic, and still representable whereLongis 32 bits (year 1 would overflow there).Tests
Ten new assertions in their own region at the end of
test/http.carp, plus three helpers near the top, so this merges cleanly with #22 (verified withgit merge-tree). They cover: both attribute orders with Max-Age winning,Max-Age=0and a negative Max-Age reporting expired, Expires-only and Max-Age-only still honoured, and both directions of the instant comparison — a UTC+2 and a UTC-5 reading checked against a 12:00 GMT expiry, with the operands built explicitly so the result does not depend on the test machine's zone.Proven non-vacuous by mutation:
Datetime.after-instant?→>parse-setand the Max-Age≤0 branch reverted to mastercarp-fmt -candanglerare clean on both files.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.