Opened 3 hours ago

Last modified 3 hours ago

#37200 new Bug

Signer.unsign() doesn't accept max_age, breaking HttpRequest.get_signed_cookie() when SIGNING_BACKEND is not a TimestampSigner

Reported by: Stefan Lilov Owned by:
Component: HTTP handling Version: 5.2
Severity: Normal Keywords: signing, cookies, SIGNING_BACKEND, get_signed_cookie
Cc: Triage Stage: Unreviewed
Has patch: yes Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

HttpRequest.get_signed_cookie() (via signing._unsign_cookie() on 5.2.15+, or inline on earlier 5.2.x patches) unconditionally calls .unsign(value, max_age=max_age) on whatever signer signing.get_cookie_signer() returns — and that signer's class is controlled entirely by the public, documented SIGNING_BACKEND setting. But Signer.unsign(self, signed_value) has never accepted a max_age parameter — only TimestampSigner.unsign(self, value, max_age=None) does. If a project sets SIGNING_BACKEND to "django.core.signing.Signer" (a plain, non-expiring signer — a legitimate use case, e.g. to avoid the timestamp prefix on cookie values), every call to request.get_signed_cookie() raises TypeError unconditionally, regardless of whether max_age is actually passed by the caller.

Minimal reproduction:

import django
from django.conf import settings
settings.configure(
    SECRET_KEY="x",
    SIGNING_BACKEND="django.core.signing.Signer",  # valid, documented override
    USE_TZ=True,
)
django.setup()

from django.test import RequestFactory
from django.http import HttpResponse

rf = RequestFactory()
resp = HttpResponse()
resp.set_signed_cookie("k", "v")  # signs fine -- Signer.sign() has no max_age issue

req = rf.get("/")
req.COOKIES["k"] = resp.cookies["k"].value
req.get_signed_cookie("k")
# TypeError: Signer.unsign() got an unexpected keyword argument 'max_age'

Expected behavior: request.get_signed_cookie() works with any SIGNING_BACKEND that produces a valid signer (mirroring HttpResponse.set_signed_cookie(), which works fine with a plain Signer since it only calls .sign()).

Actual behavior: TypeError on every call, unconditionally — this isn't gated on max_age actually being passed a non-None value; the kwarg is passed either way.

Change History (1)

comment:1 by Stefan Lilov, 3 hours ago

Until this is fixed in core, projects hitting this can work around it entirely
at the settings level, without touching any call sites, by pointing
SIGNING_BACKEND at a thin Signer subclass that accepts and ignores
max_age:

# myproject/signing.py
from django.core import signing


class MaxAgeCompatSigner(signing.Signer):
    """
    Signer that tolerates the max_age kwarg HttpRequest.get_signed_cookie()
    (via signing._unsign_cookie()) always passes, regardless of
    SIGNING_BACKEND. Signer.unsign() doesn't accept max_age at all -- only
    TimestampSigner.unsign() does -- so with SIGNING_BACKEND set to a plain
    Signer, request.get_signed_cookie() raises TypeError unconditionally.
    This subclass makes SIGNING_BACKEND-selected signers interchangeable with
    TimestampSigner for that purpose, without adopting its timestamp-prefixed
    signing format (i.e. without gaining real expiry enforcement).
    """

    def unsign(self, signed_value, max_age=None):
        return super().unsign(signed_value)
# settings.py
SIGNING_BACKEND = "myproject.signing.MaxAgeCompatSigner"

This is a strict superset of Signer's existing interface -- sign(),
signature(), sign_object(), and __init__ are all inherited unchanged, so
signature output is byte-for-byte identical to what a plain Signer produces
(HMAC computation only depends on key/salt/algorithm/sep, none of
which the subclass touches). Existing signed cookies remain valid after
switching -- no forced re-signing or logout. contrib.messages'
CookieStorage (which also goes through get_cookie_signer()) is unaffected
too, since unsign_object() never passes max_age in the first place.

The one thing to flag for anyone using this: it's a compatibility shim, not
real expiry support. max_age is silently ignored, exactly as it already was
whenever SIGNING_BACKEND pointed at a plain Signer before this ticket was
filed -- if a project actually needs signature-age enforcement on cookies, it
should use TimestampSigner (the default) rather than this workaround.

Confirmed against 5.2.4, 5.2.14, and 5.2.15 -- the underlying incompatibility
(Signer.unsign() never having a max_age param) is identical across all
three, so the workaround applies regardless of patch version.

Note: See TracTickets for help on using tickets.
Back to Top