Skip to content

Commit aef652f

Browse files
dylanpulverclaude
andcommitted
Expire the cookie on Set-Cookie Max-Age=0
`Max-Age=0` is the standard way for a server to delete a cookie, but readsetcookies dropped the attribute instead of recording it. The leading-zero guard rejects any value whose first character is '0', which catches "0" itself, so the parsed Cookie kept `maxage == 0` (attribute absent) and CookieJar stored the cookie rather than deleting it. A logout that clears a session with `Set-Cookie: sid=; Path=/; Max-Age=0` left the stale entry in the jar, to be sent on every later request. Reject a leading zero only on a non-zero value, and map every delta-seconds <= 0 to the `maxage == -1` deletion sentinel (RFC 6265 5.2.2). This also closes a round trip inside the package: `stringify` already serializes a deletion as "Max-Age=0", which the parser could not read back. `Max-Age=01` is still ignored and positive values are unchanged. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 769b917 commit aef652f

2 files changed

Lines changed: 36 additions & 7 deletions

File tree

src/http_cookies.jl

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -343,13 +343,15 @@ function readsetcookies(hdrs::Headers)::Vector{Cookie}
343343
elseif lowerattr == "domain"
344344
c.domain = val
345345
elseif lowerattr == "max-age"
346-
try
347-
secs = parse(Int, val)
348-
val[1] == '0' && continue
349-
c.maxage = max(secs, -1)
350-
catch
351-
continue
352-
end
346+
secs = tryparse(Int, val)
347+
# A leading zero is only rejected on a non-zero value: "0" is a
348+
# meaningful delta-seconds, and rejecting it would drop the
349+
# attribute entirely.
350+
(secs === nothing || (secs != 0 && val[1] == '0')) && continue
351+
# RFC 6265 5.2.2: delta-seconds <= 0 expires the cookie
352+
# immediately. `maxage < 0` is this Cookie's sentinel for that
353+
# and is what `stringify` re-serializes as "Max-Age=0".
354+
c.maxage = secs <= 0 ? -1 : secs
353355
elseif lowerattr == "expires"
354356
c.rawexpires = val
355357
parsed = _parse_http_gmt_datetime(val)

test/http_cookie_tests.jl

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,33 @@ end
154154
@test !("expired" in [c.name for c in HT.getcookies!(jar, "https", "example.com", "/docs/page")])
155155
end
156156

157+
@testset "HTTP cookie Max-Age=0 expires the cookie" begin
158+
jar = HT.CookieJar()
159+
HT.setcookies!(jar, "https", "example.com", "/", _set_cookie_headers("sid=abc; Path=/"))
160+
@test [c.name for c in HT.getcookies!(jar, "https", "example.com", "/")] == ["sid"]
161+
HT.setcookies!(jar, "https", "example.com", "/", _set_cookie_headers("sid=; Path=/; Max-Age=0"))
162+
@test isempty(HT.getcookies!(jar, "https", "example.com", "/"))
163+
164+
maxage(value) = HT.cookies(HT.Response(200; headers = _set_cookie_headers("k=v; Max-Age=$value")))[1].maxage
165+
# RFC 6265 5.2.2: any delta-seconds <= 0 expires the cookie immediately.
166+
@test maxage("0") == -1
167+
@test maxage("-0") == -1
168+
@test maxage("-30") == -1
169+
# A leading zero on a non-zero value is still rejected, an unparseable or
170+
# missing value still leaves the attribute unset, and a positive
171+
# delta-seconds is unchanged.
172+
@test maxage("01") == 0
173+
@test maxage("xyz") == 0
174+
@test maxage("") == 0
175+
@test maxage("60") == 60
176+
177+
# stringify serializes a deletion as "Max-Age=0", so the parser has to read
178+
# that form back as a deletion.
179+
deletion = HT.stringify(HT.Cookie("sid", ""; maxage = -1, path = "/"), false)
180+
@test occursin("; Max-Age=0", deletion)
181+
@test HT.cookies(HT.Response(200; headers = _set_cookie_headers(deletion)))[1].maxage == -1
182+
end
183+
157184
@testset "HTTP CookieJar restores from saved entries (#931)" begin
158185
# "save": populate a jar, then keep its entries storage
159186
jar = HT.CookieJar()

0 commit comments

Comments
 (0)