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 Stefan Lilov, 4 weeks 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.

comment:2 by Vishy, 3 weeks ago

Triage Stage: UnreviewedAccepted

Sharp find! This is an abstraction leak regression introduced during the fix for CVE-2026-6873 in 70d3651.

comment:3 by Vishy, 3 weeks ago

Owner: set to Vishy
Status: newassigned

comment:4 by David Smith, 3 weeks ago

Owner: Vishy removed
Status: assignednew
Triage Stage: AcceptedUnreviewed
Version: 5.26.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 zky, 3 weeks ago

Owner: set to zky
Status: newassigned

comment:6 by Vishy, 3 weeks ago

It appears that get_signed_cookie() was introduced in f60d42. However, this exposes an abstraction leak.

comment:7 by zky, 3 weeks ago

Triage Stage: UnreviewedAccepted

comment:9 by David Smith, 3 weeks ago

Resolution: wontfix
Status: assignedclosed

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.

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