From aef652f85e50e56bee21cf22c9378637f434a491 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Fri, 4 Sep 2026 11:20:55 +0300 Subject: [PATCH] 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 --- src/http_cookies.jl | 16 +++++++++------- test/http_cookie_tests.jl | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/http_cookies.jl b/src/http_cookies.jl index 8919b6732..ccb093b7f 100644 --- a/src/http_cookies.jl +++ b/src/http_cookies.jl @@ -343,13 +343,15 @@ function readsetcookies(hdrs::Headers)::Vector{Cookie} elseif lowerattr == "domain" c.domain = val elseif lowerattr == "max-age" - try - secs = parse(Int, val) - val[1] == '0' && continue - c.maxage = max(secs, -1) - catch - continue - end + secs = tryparse(Int, val) + # A leading zero is only rejected on a non-zero value: "0" is a + # meaningful delta-seconds, and rejecting it would drop the + # attribute entirely. + (secs === nothing || (secs != 0 && val[1] == '0')) && continue + # RFC 6265 5.2.2: delta-seconds <= 0 expires the cookie + # immediately. `maxage < 0` is this Cookie's sentinel for that + # and is what `stringify` re-serializes as "Max-Age=0". + c.maxage = secs <= 0 ? -1 : secs elseif lowerattr == "expires" c.rawexpires = val parsed = _parse_http_gmt_datetime(val) diff --git a/test/http_cookie_tests.jl b/test/http_cookie_tests.jl index b0dc3fc11..0ff943635 100644 --- a/test/http_cookie_tests.jl +++ b/test/http_cookie_tests.jl @@ -154,6 +154,33 @@ end @test !("expired" in [c.name for c in HT.getcookies!(jar, "https", "example.com", "/docs/page")]) end +@testset "HTTP cookie Max-Age=0 expires the cookie" begin + jar = HT.CookieJar() + HT.setcookies!(jar, "https", "example.com", "/", _set_cookie_headers("sid=abc; Path=/")) + @test [c.name for c in HT.getcookies!(jar, "https", "example.com", "/")] == ["sid"] + HT.setcookies!(jar, "https", "example.com", "/", _set_cookie_headers("sid=; Path=/; Max-Age=0")) + @test isempty(HT.getcookies!(jar, "https", "example.com", "/")) + + maxage(value) = HT.cookies(HT.Response(200; headers = _set_cookie_headers("k=v; Max-Age=$value")))[1].maxage + # RFC 6265 5.2.2: any delta-seconds <= 0 expires the cookie immediately. + @test maxage("0") == -1 + @test maxage("-0") == -1 + @test maxage("-30") == -1 + # A leading zero on a non-zero value is still rejected, an unparseable or + # missing value still leaves the attribute unset, and a positive + # delta-seconds is unchanged. + @test maxage("01") == 0 + @test maxage("xyz") == 0 + @test maxage("") == 0 + @test maxage("60") == 60 + + # stringify serializes a deletion as "Max-Age=0", so the parser has to read + # that form back as a deletion. + deletion = HT.stringify(HT.Cookie("sid", ""; maxage = -1, path = "/"), false) + @test occursin("; Max-Age=0", deletion) + @test HT.cookies(HT.Response(200; headers = _set_cookie_headers(deletion)))[1].maxage == -1 +end + @testset "HTTP CookieJar restores from saved entries (#931)" begin # "save": populate a jar, then keep its entries storage jar = HT.CookieJar()