Opened 4 weeks ago
Closed 3 weeks ago
#37200 closed Bug (wontfix)
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: | zky |
|---|---|---|---|
| Component: | HTTP handling | Version: | 6.1 |
| Severity: | Normal | Keywords: | signing, cookies, SIGNING_BACKEND, get_signed_cookie |
| Cc: | Triage Stage: | Accepted | |
| 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 (9)
comment:1 by , 4 weeks ago
comment:2 by , 3 weeks ago
| Triage Stage: | Unreviewed → Accepted |
|---|
Sharp find! This is an abstraction leak regression introduced during the fix for CVE-2026-6873 in 70d3651.
comment:3 by , 3 weeks ago
| Owner: | set to |
|---|---|
| Status: | new → assigned |
comment:4 by , 3 weeks ago
| Owner: | removed |
|---|---|
| Status: | assigned → new |
| Triage Stage: | Accepted → Unreviewed |
| Version: | 5.2 → 6.1 |
Sharp find! This is an abstraction leak regression introduced during the fix for CVE-2026-6873 in 70d3651.
I'm afraid this triage assessment is incorrect. The reported issue is also reproducible in the parent of this commit 09fdc06346b167ff6231a4cb80b85ae67b53c715
I was able to reproduce it on the stable/5.1.x branch so even if accepted this wouldn't be a release blocker.
comment:5 by , 3 weeks ago
| Owner: | set to |
|---|---|
| Status: | new → assigned |
comment:6 by , 3 weeks ago
It appears that get_signed_cookie() was introduced in f60d42. However, this exposes an abstraction leak.
comment:7 by , 3 weeks ago
| Triage Stage: | Unreviewed → Accepted |
|---|
comment:9 by , 3 weeks ago
| Resolution: | → wontfix |
|---|---|
| Status: | assigned → closed |
I've reviewed the history of when this feature was originally added back in 2010. I found this thread on the mailing list which included this comment https://groups.google.com/g/django-developers/c/KX6LIgBvfzo?pli=1
The timestamp is necessary to limit replay attacks, and so it should probably be more than optional - always issued, and checked by default.
It is therefore security related that max_age is always required. Then looking at this:
SIGNING_BACKEND="django.core.signing.Signer", # valid, documented override
I'm unable to find this. If it is documented we should fix that as it is not a valid option. The SIGNING_BACKEND is documented as
The backend used for signing cookies and other data.
But we know that the Signer class is not appropriate for signing of cookies and that the default TimestampSigner should be used, or another subclass which validates timestamps.
It could be that things have moved on in this space since 2010. If a review was presented of the current landscape with concrete proposals of how it could be improved then I am sure that would be welcomed.
As it stands I'm going to close this ticket as wontfix as allowing the max_age check to be skipped seems like a step backwards in terms of security given the feedback previously provided.
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.