Skip to content

Honour Max-Age when extracting cookies (GH#69)#76

Open
oalders wants to merge 7 commits into
masterfrom
fix-69
Open

Honour Max-Age when extracting cookies (GH#69)#76
oalders wants to merge 7 commits into
masterfrom
fix-69

Conversation

@oalders

@oalders oalders commented Jun 23, 2026

Copy link
Copy Markdown
Member

Closes #69

Problem

Since 6.10 (GH#61, "do not set discard if max-age is set"), the max-age
branch in extract_cookies incremented the expiry counter but never pushed
the value onto @cur. As a result set_cookie always received an undef
max-age, so Max-Age was completely ignored: cookies received no expiry,
and Max-Age=0 no longer deleted a cookie (a server-side logout/expiry
signal silently failed — CWE-613, Insufficient Session Expiration).

While fixing that, a second defect was found: both the expires and
max-age branches collapsed into the same "Max-Age" entry on @cur, and a
downstream first-wins dedup meant header order decided which applied. RFC
6265 §5.3 requires Max-Age to take precedence over Expires.

Changes

  • lib/HTTP/Cookies.pm:
    • Honour Max-Age: carry the parsed value through to set_cookie.
    • Ignore a non-integer Max-Age per RFC 6265 §5.2.2 (previously such a value
      coerced to 0 and wrongly deleted the cookie). Our regex also rejects a
      bare -, matching the stricter forthcoming 6265bis wording.
    • Cap an absurdly large Max-Age at ~10 years — the same approximation the
      sibling expires branch already uses — so time() + maxage cannot become
      a float that serializes to garbage.
    • Resolve expiry after parsing with Max-Age taking precedence over
      Expires regardless of order
      , and last-value-wins for a repeated
      attribute (RFC 6265 §5.3 / 6265bis §5.7). A malformed Max-Age now falls
      back to Expires instead of being treated as present-but-zero.
  • Changes: changelog entries.

Testing

  • New t/issue69.t: future expiry, Max-Age=0 deletion, non-integer
    ignored, large-value cap, Max-Age-over-Expires precedence in both
    orders, finite-Max-Age-over-far-future-Expires, and repeated-Max-Age
    last-wins. Each verified red→green (fails with the relevant fix reverted,
    passes with it).
  • Updated the existing max-age test in t/cookies.t, which had asserted the
    buggy no-expiry output, to a date-independent pattern.
  • Full suite green: Tests=128, Result: PASS.

RFC backing (verbatim)

  • §5.2.2: "If the first character of the attribute-value is not a DIGIT or a
    - character, ignore the cookie-av. If the remainder … contains a
    non-DIGIT character, ignore the cookie-av." … "If delta-seconds is less
    than or equal to zero (0), let expiry-time be the earliest representable
    date and time."
  • §5.3: "If a cookie has both the Max-Age and the Expires attribute, the
    Max-Age attribute has precedence and controls the expiration date."

Note: the ~10-year cap is a pragmatic anti-overflow measure, not mandated by
the RFC.

@oalders oalders marked this pull request as ready for review June 23, 2026 18:26
@oalders oalders force-pushed the fix-69 branch 2 times, most recently from 1ee7987 to c13b796 Compare June 23, 2026 19:56
oalders and others added 7 commits June 25, 2026 19:06
Since 6.10 (GH#61, "do not set discard if max-age is set") the max-age
branch in extract_cookies incremented the expiry counter but never pushed
the value onto @Cur, so set_cookie always saw an undef max-age. Max-Age was
completely ignored: cookies got no expiry and Max-Age=0 failed to delete.

Push the value through, and while here:
  - ignore a non-integer Max-Age per RFC 6265 5.2.2 (previously such a value
    would coerce to 0 and wrongly delete the cookie)
  - cap an absurdly large value at ~10 years, the same approximation the
    expires branch already uses, so time() + maxage cannot overflow to a
    float that serializes to garbage

Add t/issue69.t covering a future expiry, Max-Age=0 deletion, the
non-integer case and the cap. Update the existing max-age test in
t/cookies.t, which had asserted the buggy no-expiry output, to a
date-independent pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both the expires and max-age branches pushed a "Max-Age" entry onto @Cur,
and the downstream %hash dedup kept the first one seen. So whichever
attribute appeared first in the header won, e.g. "expires=...; Max-Age=0"
kept the cookie instead of deleting it.

Collect the Max-Age and Expires values separately during parsing (last
occurrence of each wins) and resolve once at the end, letting Max-Age take
precedence regardless of order, per RFC 6265 5.3 (and 6265bis, which
formalises last-value-wins). A malformed Max-Age now correctly falls back
to Expires rather than being treated as a present-but-zero value.

Extend t/issue69.t with order-independent precedence and repeated-attribute
cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Use a snake_case name for the Max-Age value collected while parsing,
matching the reviewer's preference and the $expires_age sibling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
t/issue69.t uses subtest(), added in Test::More 0.95_01. macOS Perl
5.12 ships core Test::More 0.94, where the subtests aborted with "no
plan declared". Declaring the version on the use line makes AutoPrereqs
emit a test-phase floor of 0.96 (the first stable release with subtest,
already the develop-phase floor) and fails early with a clear message
on too-old toolchains (GH#69).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 10*365*24*60*60 literal appeared in both the Expires fallback and
the Max-Age cap. Hoist it to a single $ten_years lexical so the cap is
defined once and the two branches share it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The issue69.t test was added before the repo standardised on perltidy
(via the new precious lint job on master).  Tidy it so the branch passes
CI after rebasing onto the perltidy'd master.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@oalders

oalders commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

@wolfsage, how does this look?

@wolfsage wolfsage left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, one minor change maybe needed

Comment thread lib/HTTP/Cookies.pm

# RFC 6265 5.2.2: a non-integer Max-Age is ignored, not
# treated as 0 (which would wrongly delete the cookie).
if ( defined $v && $v =~ /\A-?\d+\z/ ) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

\d will allow other digits besides [0-9] because of Unicode I think.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Setting Max-Age=X on a cookie is completely ignored

2 participants