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.
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_BACKENDat a thinSignersubclass that accepts and ignoresmax_age:This is a strict superset of
Signer's existing interface --sign(),signature(),sign_object(), and__init__are all inherited unchanged, sosignature output is byte-for-byte identical to what a plain
Signerproduces(HMAC computation only depends on
key/salt/algorithm/sep, none ofwhich the subclass touches). Existing signed cookies remain valid after
switching -- no forced re-signing or logout.
contrib.messages'CookieStorage(which also goes throughget_cookie_signer()) is unaffectedtoo, since
unsign_object()never passesmax_agein the first place.The one thing to flag for anyone using this: it's a compatibility shim, not
real expiry support.
max_ageis silently ignored, exactly as it already waswhenever
SIGNING_BACKENDpointed at a plainSignerbefore this ticket wasfiled -- 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 amax_ageparam) is identical across allthree, so the workaround applies regardless of patch version.