Skip to content

validate cookie Max-Age as -?digits before int()#12947

Draft
dxbjavid wants to merge 2 commits into
aio-libs:masterfrom
dxbjavid:cookie-maxage-strict-digits
Draft

validate cookie Max-Age as -?digits before int()#12947
dxbjavid wants to merge 2 commits into
aio-libs:masterfrom
dxbjavid:cookie-maxage-strict-digits

Conversation

@dxbjavid

Copy link
Copy Markdown
Contributor

What do these changes do?

CookieJar.update_cookies reads a Set-Cookie Max-Age with a bare int(). Python's int() is more permissive than :rfc:6265#section-5.2.2, which says a Max-Age is "-"? DIGIT+ and any other value must be ignored. So Max-Age=+1000, Max-Age=1_000 and whitespace-padded values are honoured and persist the cookie with the given lifetime, whereas a browser drops the attribute and keeps it as a session cookie. The jar already clears a clearly non-numeric value (Max-Age=string), so this just completes that existing intent by validating against -?[0-9]+ before parsing.

Are there changes in behavior for the user?

A Set-Cookie whose Max-Age is not -?DIGIT+ now leaves the cookie as a session cookie instead of giving it an expiry. Well-formed values, including negative ones, are unchanged.

Is it a substantial burden for the maintainers to support this?

No, it is a small contained check next to the existing parse.

Related issue number

N/A

Checklist

  • I think the code is well written
  • Unit tests for the changes exist
  • Documentation reflects the changes
  • If you provide code modification, please add yourself to CONTRIBUTORS.txt
  • Add a new news fragment into the CHANGES/ folder

@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided There is a change note present in this PR label Jun 17, 2026
@codecov

codecov Bot commented Jun 17, 2026

Copy link
Copy Markdown

❌ 1 Tests Failed:

Tests completed Failed Passed Skipped
4827 1 4826 45
View the top 1 failed test(s) by shortest run time
tests.test_client_functional::test_morsel_with_attributes
Stack Traces | 0.003s run time
aiohttp_client = <function aiohttp_client.<locals>.go at 0x107e08d60>

    #x1B[0m#x1B[94masync#x1B[39;49;00m #x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mtest_morsel_with_attributes#x1B[39;49;00m(aiohttp_client: AiohttpClient) -> #x1B[94mNone#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
        #x1B[90m# A comment from original test:#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        #x1B[90m##x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        #x1B[90m# No cookie attribute should pass here#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        #x1B[90m# they are only used as filters#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        #x1B[90m# whether to send particular cookie or not.#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        #x1B[90m# E.g. if cookie expires it just becomes thrown away.#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        #x1B[90m# Server who sent the cookie with some attributes#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        #x1B[90m# already knows them, no need to send this back again and again#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        #x1B[94masync#x1B[39;49;00m #x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mhandler#x1B[39;49;00m(request: web.Request) -> web.Response:#x1B[90m#x1B[39;49;00m
            #x1B[94massert#x1B[39;49;00m request.cookies.keys() == {#x1B[33m"#x1B[39;49;00m#x1B[33mtest3#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m}#x1B[90m#x1B[39;49;00m
            #x1B[94massert#x1B[39;49;00m request.cookies[#x1B[33m"#x1B[39;49;00m#x1B[33mtest3#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m] == #x1B[33m"#x1B[39;49;00m#x1B[33m456#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
            #x1B[94mreturn#x1B[39;49;00m web.Response()#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        c: http.cookies.Morsel[#x1B[96mstr#x1B[39;49;00m] = http.cookies.Morsel()#x1B[90m#x1B[39;49;00m
        c.set(#x1B[33m"#x1B[39;49;00m#x1B[33mtest3#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33m456#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, #x1B[33m"#x1B[39;49;00m#x1B[33m456#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m)#x1B[90m#x1B[39;49;00m
        c[#x1B[33m"#x1B[39;49;00m#x1B[33mhttponly#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m] = #x1B[94mTrue#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        c[#x1B[33m"#x1B[39;49;00m#x1B[33msecure#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m] = #x1B[94mTrue#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        c[#x1B[33m"#x1B[39;49;00m#x1B[33mmax-age#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m] = #x1B[94m1000#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        app = web.Application()#x1B[90m#x1B[39;49;00m
        app.router.add_get(#x1B[33m"#x1B[39;49;00m#x1B[33m/#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m, handler)#x1B[90m#x1B[39;49;00m
>       client = #x1B[94mawait#x1B[39;49;00m aiohttp_client(app, cookies={#x1B[33m"#x1B[39;49;00m#x1B[33mtest2#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m: c})#x1B[90m#x1B[39;49;00m
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m

aiohttp_client = <function aiohttp_client.<locals>.go at 0x107e08d60>
app        = <Application 0x107cb1430>
c          = <Morsel: test3=456; HttpOnly; Max-Age=1000; Path=/; Secure>
handler    = <function test_morsel_with_attributes.<locals>.handler at 0x107e08ea0>

#x1B[1m#x1B[31mtests/test_client_functional.py#x1B[0m:2854: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
#x1B[1m#x1B[31m.venv/lib/python3.11.../site-packages/pytest_aiohttp/plugin.py#x1B[0m:184: in go
    #x1B[0mclient = aiohttp_client_cls(server, **kwargs)#x1B[90m#x1B[39;49;00m
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
        __param    = <Application 0x107cb1430>
        aiohttp_client_cls = <class 'aiohttp.test_utils.TestClient'>
        clients    = []
        kwargs     = {'cookies': {'test2': <Morsel: test3=456; HttpOnly; Max-Age=1000; Path=/; Secure>}}
        server     = <aiohttp.test_utils.TestServer object at 0x10785e110>
        server_kwargs = {}
#x1B[1m#x1B[31maiohttp/test_utils.py#x1B[0m:261: in __init__
    #x1B[0m#x1B[96mself#x1B[39;49;00m._session = ClientSession(cookie_jar=cookie_jar, **kwargs)#x1B[90m#x1B[39;49;00m
                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
        cookie_jar = <aiohttp.cookiejar.CookieJar object at 0x10785c4d0>
        kwargs     = {'cookies': {'test2': <Morsel: test3=456; HttpOnly; Max-Age=1000; Path=/; Secure>}}
        self       = <aiohttp.test_utils.TestClient object at 0x10785e6d0>
        server     = <aiohttp.test_utils.TestServer object at 0x10785e110>
#x1B[1m#x1B[31maiohttp/client.py#x1B[0m:373: in __init__
    #x1B[0m#x1B[96mself#x1B[39;49;00m._cookie_jar.update_cookies(cookies)#x1B[90m#x1B[39;49;00m
        auto_decompress = True
        base_url   = None
        connector  = <aiohttp.connector.TCPConnector object at 0x1065e4590>
        connector_owner = True
        cookie_jar = <aiohttp.cookiejar.CookieJar object at 0x10785c4d0>
        cookies    = {'test2': <Morsel: test3=456; HttpOnly; Max-Age=1000; Path=/; Secure>}
        fallback_charset_resolver = <function ClientSession.<lambda> at 0x104a9b7e0>
        headers    = None
        json_serialize = <function dumps at 0x103c08ae0>
        json_serialize_bytes = None
        loop       = <_UnixSelectorEventLoop running=False closed=False debug=False>
        max_field_size = 8190
        max_headers = 128
        max_line_size = 8190
        middlewares = ()
        proxy      = None
        raise_for_status = False
        read_bufsize = 262144
        request_class = <class 'aiohttp.client_reqrep.ClientRequest'>
        requote_redirect_url = True
        response_class = <class 'aiohttp.client_reqrep.ClientResponse'>
        self       = <aiohttp.client.ClientSession object at 0x1084cbbc0>
        skip_auto_headers = None
        ssl_shutdown_timeout = <_SENTINEL.sentinel: 1>
        timeout    = ClientTimeout(total=300, connect=None, sock_read=None, sock_connect=None, ceil_threshold=5)
        trace_configs = None
        trust_env  = False
        version    = HttpVersion(major=1, minor=1)
        ws_response_class = <class 'aiohttp.client_ws.ClientWebSocketResponse'>
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <aiohttp.cookiejar.CookieJar object at 0x10785c4d0>
cookies = dict_items([('test2', <Morsel: test3=456; HttpOnly; Max-Age=1000; Path=/; Secure>)])
response_url = URL('')

    #x1B[0m#x1B[94mdef#x1B[39;49;00m#x1B[90m #x1B[39;49;00m#x1B[92mupdate_cookies#x1B[39;49;00m(#x1B[96mself#x1B[39;49;00m, cookies: LooseCookies, response_url: URL = URL()) -> #x1B[94mNone#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
    #x1B[90m    #x1B[39;49;00m#x1B[33m"""Update cookies."""#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
        hostname = response_url.raw_host#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        #x1B[94mif#x1B[39;49;00m #x1B[95mnot#x1B[39;49;00m #x1B[96mself#x1B[39;49;00m._unsafe #x1B[95mand#x1B[39;49;00m is_ip_address(hostname):#x1B[90m#x1B[39;49;00m
            #x1B[90m# Don't accept cookies from IPs#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
            #x1B[94mreturn#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        #x1B[94mif#x1B[39;49;00m #x1B[96misinstance#x1B[39;49;00m(cookies, Mapping):#x1B[90m#x1B[39;49;00m
            cookies = cookies.items()#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
        #x1B[94mfor#x1B[39;49;00m name, cookie #x1B[95min#x1B[39;49;00m cookies:#x1B[90m#x1B[39;49;00m
            #x1B[94mif#x1B[39;49;00m #x1B[95mnot#x1B[39;49;00m #x1B[96misinstance#x1B[39;49;00m(cookie, Morsel):#x1B[90m#x1B[39;49;00m
                tmp = SimpleCookie()#x1B[90m#x1B[39;49;00m
                tmp[name] = cookie  #x1B[90m# type: ignore[assignment]#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                cookie = tmp[name]#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
            domain = cookie[#x1B[33m"#x1B[39;49;00m#x1B[33mdomain#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m]#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
            #x1B[90m# ignore domains with trailing dots#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
            #x1B[94mif#x1B[39;49;00m domain #x1B[95mand#x1B[39;49;00m domain[-#x1B[94m1#x1B[39;49;00m] == #x1B[33m"#x1B[39;49;00m#x1B[33m.#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
                domain = #x1B[33m"#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                #x1B[94mdel#x1B[39;49;00m cookie[#x1B[33m"#x1B[39;49;00m#x1B[33mdomain#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m]#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
            #x1B[94mif#x1B[39;49;00m #x1B[95mnot#x1B[39;49;00m domain #x1B[95mand#x1B[39;49;00m hostname #x1B[95mis#x1B[39;49;00m #x1B[95mnot#x1B[39;49;00m #x1B[94mNone#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
                #x1B[90m# Set the cookie's domain to the response hostname#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                #x1B[90m# and set its host-only-flag#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                #x1B[96mself#x1B[39;49;00m._host_only_cookies.add((hostname, name))#x1B[90m#x1B[39;49;00m
                domain = cookie[#x1B[33m"#x1B[39;49;00m#x1B[33mdomain#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m] = hostname#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
            #x1B[94mif#x1B[39;49;00m domain #x1B[95mand#x1B[39;49;00m domain[#x1B[94m0#x1B[39;49;00m] == #x1B[33m"#x1B[39;49;00m#x1B[33m.#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
                #x1B[90m# Remove leading dot#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                domain = domain[#x1B[94m1#x1B[39;49;00m:]#x1B[90m#x1B[39;49;00m
                cookie[#x1B[33m"#x1B[39;49;00m#x1B[33mdomain#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m] = domain#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
            #x1B[94mif#x1B[39;49;00m hostname #x1B[95mand#x1B[39;49;00m #x1B[95mnot#x1B[39;49;00m #x1B[96mself#x1B[39;49;00m._is_domain_match(domain, hostname):#x1B[90m#x1B[39;49;00m
                #x1B[90m# Setting cookies for different domains is not allowed#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                #x1B[94mcontinue#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
            path = cookie[#x1B[33m"#x1B[39;49;00m#x1B[33mpath#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m]#x1B[90m#x1B[39;49;00m
            #x1B[94mif#x1B[39;49;00m #x1B[95mnot#x1B[39;49;00m path #x1B[95mor#x1B[39;49;00m path[#x1B[94m0#x1B[39;49;00m] != #x1B[33m"#x1B[39;49;00m#x1B[33m/#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
                #x1B[90m# Set the cookie's path to the response path#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                path = response_url.path#x1B[90m#x1B[39;49;00m
                #x1B[94mif#x1B[39;49;00m #x1B[95mnot#x1B[39;49;00m path.startswith(#x1B[33m"#x1B[39;49;00m#x1B[33m/#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m):#x1B[90m#x1B[39;49;00m
                    path = #x1B[33m"#x1B[39;49;00m#x1B[33m/#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                #x1B[94melse#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
                    #x1B[90m# Cut everything from the last slash to the end#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                    path = #x1B[33m"#x1B[39;49;00m#x1B[33m/#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m + path[#x1B[94m1#x1B[39;49;00m : path.rfind(#x1B[33m"#x1B[39;49;00m#x1B[33m/#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m)]#x1B[90m#x1B[39;49;00m
                cookie[#x1B[33m"#x1B[39;49;00m#x1B[33mpath#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m] = path#x1B[90m#x1B[39;49;00m
            path = path.rstrip(#x1B[33m"#x1B[39;49;00m#x1B[33m/#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m)#x1B[90m#x1B[39;49;00m
    #x1B[90m#x1B[39;49;00m
            #x1B[94mif#x1B[39;49;00m max_age := cookie[#x1B[33m"#x1B[39;49;00m#x1B[33mmax-age#x1B[39;49;00m#x1B[33m"#x1B[39;49;00m]:#x1B[90m#x1B[39;49;00m
                #x1B[90m# int() would also accept sign prefixes, underscores and#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                #x1B[90m# surrounding whitespace, so a Max-Age other clients treat as#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                #x1B[90m# malformed (and ignore, leaving a session cookie) would#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                #x1B[90m# otherwise be honoured here and persist the cookie.#x1B[39;49;00m#x1B[90m#x1B[39;49;00m
                #x1B[94mtry#x1B[39;49;00m:#x1B[90m#x1B[39;49;00m
>                   #x1B[94mif#x1B[39;49;00m #x1B[95mnot#x1B[39;49;00m _MAX_AGE_RE.fullmatch(max_age):#x1B[90m#x1B[39;49;00m
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^#x1B[90m#x1B[39;49;00m
#x1B[1m#x1B[31mE                   TypeError: expected string or bytes-like object, got 'int'#x1B[0m

cookie     = <Morsel: test3=456; HttpOnly; Max-Age=1000; Path=/; Secure>
cookies    = dict_items([('test2', <Morsel: test3=456; HttpOnly; Max-Age=1000; Path=/; Secure>)])
domain     = ''
hostname   = None
max_age    = 1000
name       = 'test2'
path       = ''
response_url = URL('')
self       = <aiohttp.cookiejar.CookieJar object at 0x10785c4d0>

#x1B[1m#x1B[31maiohttp/cookiejar.py#x1B[0m:383: TypeError

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@codspeed-hq

codspeed-hq Bot commented Jun 17, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 83 untouched benchmarks
⏩ 83 skipped benchmarks1


Comparing dxbjavid:cookie-maxage-strict-digits (d64f8d8) with master (db5c238)

Open in CodSpeed

Footnotes

  1. 83 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

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

Labels

bot:chronographer:provided There is a change note present in this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant