Stored XSS in Django's admin via an unvalidated URLField display path (CVE-2026-15920)
Django's admin auto-linked URLField values without validating the scheme — a stored javascript: value rendered as a live link. Fixed in 6.0.8 and 5.2.17.
[ CONTENTS ]
| SYNT ID | SYNT-2026-004 |
| Severity | Moderate (per Django’s security policy) |
| Asset | django/django — django.contrib.admin.utils.display_for_field |
| Affected | Django 5.2, 6.0, and 6.1 rc (introduced in 5.2) |
| Fixed in | Django 6.0.8 and 5.2.17 (2026-08-04) |
| CVE | CVE-2026-15920 |
The short version: Django’s admin turns a URLField into a clickable link when you display it
as a read-only field or a list column. The code that builds that link never checked what kind of
URL it was. A stored value like javascript:alert(document.cookie) was, as far as that code was
concerned, as good a “URL” as https://example.com — and it rendered as a real, live <a href>.
Why it matters: the bug is in Django’s own rendering code, not in application code, so no
amount of care in your own views prevents it. Listing a URLField in readonly_fields or
list_display is about as unremarkable as admin configuration gets. The one precondition — the
bad value has to already be in the database — sounds like a barrier and mostly isn’t: every common
write path that doesn’t go through a ModelForm skips the validation that would have caught it.
How it happened: Django 5.2 added a convenience that auto-links URLField values in the admin.
The new branch was written by copying the shape of the FileField branch sitting directly above
it. That neighbour happens to be safe for an unrelated reason, so the copy inherited its structure
without the scheme check it never needed.
What it is: stored XSS in Django’s admin. A staff user who clicks the rendered link runs the attacker’s JavaScript in their own authenticated session.
What it isn’t: not reflected, and not something a victim can be handed as a URL. Not a failure
of URLValidator, which rejects these values correctly every time it is actually asked. And not
the admin’s own add/change form for a URLField — that path was already fixed in 2019.
Read on for the code path, what we proved, and what Django changed.
A convenience that shipped one check short
The function is display_for_field() in django/contrib/admin/utils.py. It’s the thing standing
behind two very ordinary ModelAdmin features:
readonly_fields— any field you mark read-only on a change form gets rendered through it.list_display— any column on a changelist that isn’t the one auto-linked to the object’s own change page also goes through it.
Both are core, thoroughly documented ModelAdmin attributes. Listing a URLField in either is
about as unremarkable as admin configuration gets.
How a javascript: value reaches the page
ATTACK FLOW From an unvalidated write to script running in a staff session.
flowchart LR W["write path skipping full_clean()
(bulk_create, fixtures, signal, non-ModelForm save)"]:::accent D["URLField value stored,
unvalidated"]:::n R["readonly_fields / list_display"]:::n F["display_for_field()
no scheme check"]:::alert S["staff user clicks the link"]:::n X["JS executes in the
admin's authenticated session"]:::alert W --> D --> R --> F --> S --> X classDef n fill:#1A1A1C,stroke:#2A2A2D,color:#EDEAE3 classDef accent fill:#0A0A0B,stroke:#FF4A1C,color:#EDEAE3 classDef alert fill:#0A0A0B,stroke:#E8342B,color:#EDEAE3
Four lines, and the one that’s missing
VULNERABLE CODE django/contrib/admin/utils.py:460-463 at tag 6.0.7, inside display_for_field().
elif isinstance(field, models.FileField) and value and not avoid_link:
return format_html('<a href="{}">{}</a>', value.url, value)
elif isinstance(field, models.URLField) and value and not avoid_link:
return format_html('<a href="{}">{}</a>', value, value)
The URLField branch was added directly alongside the pre-existing FileField branch, by copying
its shape, when the auto-linking feature landed
(97ee8b82c2,
“Fixed #36032 — Rendered URLField values as links in the admin,” December 2024, shipped in Django
5.2). The FileField branch happens to survive unscathed: FileSystemStorage.url() routes through
Django’s filepath_to_uri(), which percent-quotes characters outside its safe set — including :
— so a file literally named javascript:... would come out as javascript%3A... before it ever
reaches the template. The new URLField branch has no equivalent step. format_html() escapes
HTML syntax — quotes, angle brackets — but does nothing about URL scheme, so the stored string
goes straight into the link, unmodified.
Why “it has to be in the database first” isn’t much of a barrier
Model.save() never calls full_clean() on its own — only ModelForm.is_valid() does that. Every
other common write path skips it entirely: bulk_create() (Django’s own docs are explicit that it
bypasses save() and validation), loading fixtures, a signal handler writing to a related object, or
any plain .save() call from outside a ModelForm — which describes most non-admin write paths in
a typical Django app: a DRF serializer, a custom API view, a management command, a data migration.
None of that is exotic. It’s the default shape of “data enters this application through something
other than the admin’s own add/change form.” Only the admin’s own editable widget path validates on
the way in; nothing else does, by design, because Django’s validation is opt-in at the ModelForm
layer rather than mandatory at the model layer.
Django already fixed this once — in the widget next door
This isn’t a new bug class for Django’s admin. AdminURLFieldWidget — the widget used to render an
editable URLField input — already validates with URLValidator before it will build a link.
That check exists because of CVE-2019-12308,
fixed in Django 1.11.21, 2.1.9, and 2.2.2, which covered exactly this shape of issue in the editable
widget.
Worth being precise about how far that parallel goes, since it would be easy to overstate. The 2019
advisory named two vectors: an unvalidated value already sitting in the database, and a value
supplied live as a URL query parameter (a reflected case, since the editable widget can pre-populate
from request.GET). This new finding only reproduces the first of those. display_for_field()
renders straight off the stored model instance — there’s no request-parameter path into it, so
nothing here is reflected. It’s the stored-value half of the 2019 CVE’s class, showing up in a
different rendering path that the 2019 fix didn’t touch, rather than a full reintroduction of
that CVE. The reason it’s still worth taking seriously: the stored-value case was itself judged
CVE-worthy back in 2019. “The value has to get into the database first” wasn’t a reason to dismiss
it then, so it isn’t one now either.
What we actually proved
We built an isolated reproduction (a pinned, released Django, served the way a hardened deployment
actually would be — not runserver) and verified the mechanism dynamically rather than reasoning
about it in the abstract: a URLField value written through a validation-skipping path renders, on
a real admin change page, as a genuine unescaped <a href>; clicking it runs the attacker-supplied
JavaScript in that session. A negative control confirmed the identical value is correctly rejected
by URLValidator/forms.URLField when the normal, validated write path is actually used — isolating
“the write skipped validation” as the precondition, not any weakness in Django’s URL validation
itself.
We’re deliberately not walking through the exact reproduction here. The mechanics above are enough to understand and fix the class of bug; they’re not enough to hand someone a working exploit.
What this means in practice
The summary above gives the shape; two details are worth being precise about.
It needs a click, and that genuinely limits it. A javascript: href doesn’t execute on page
load, only on navigation. Merely rendering the changelist doesn’t fire anything. So this is not a
drive-by: it needs a staff user to land on the page and click the poisoned link, which is a
smaller window than a payload that runs on render.
Once clicked, the ceiling is higher than “an alert box.” The script runs with that user’s
authenticated admin session. Session cookies are typically HttpOnly and out of direct reach, so
this isn’t straightforward session theft — but CSRF tokens are readable from document.cookie,
and a CSRF token inside a live admin session is a meaningful foothold on its own. The attacker
acts as that staff user for as long as the tab is open.
Django rated it Moderate. That matches what we found, and we’re not arguing with it in either direction.
The fix — what Django changed
The patch, authored by Django’s team
(13debb622a
on 6.0, backported in parallel to
5.2,
6.1, and
main), adds the
missing check directly to display_for_field()’s URLField branch:
THE PATCH Commit 13debb622a, applied in parallel to 6.0, 5.2, 6.1, and main.
elif isinstance(field, models.URLField) and value and not avoid_link:
+ # Only render a clickable link for URLs with a safe scheme, so that a
+ # potentially dangerous stored value is shown as plain text rather than
+ # an executable link. The check is deliberately independent of the
+ # field's own validators, which may permit such schemes.
+ try:
+ URLValidator()(value)
+ except ValidationError:
+ return display_for_value(value, empty_value_display)
return format_html('<a href="{}">{}</a>', value, value)
A value that fails URLValidator now falls back to display_for_value() — the same plain-text,
autoescaped path already used for every non-URLField value in this function. No new mechanism, no
new escaping logic: it reuses what the function already does for the common case, and reuses the
exact validator AdminURLFieldWidget already relies on, so the two code paths agree with each
other for the first time.
Django’s own release note classifies this “Moderate” per their security policy — Django scores XSS in that bucket generally, rather than assigning a CVSS vector, and we’re quoting their classification as given rather than layering our own score on top of it.
We tested the patch before confirming it back to Django: applied against a real Django checkout, it
passes the full existing admin test suite with no regressions, plus a new test the patch itself
adds; separately, against our own reproduction, we confirmed the original stored payload — and
several scheme-obfuscation variants beyond the literal reported case (mixed case, embedded
whitespace, a data: URI) — now render as plain text, while ordinary http/https links are
completely unaffected.
Before / after
BEFORE Django 5.2 through 6.0.7, and 6.1 rc — the stored value goes straight into the link.
flowchart LR V1["URLField value
(any scheme)"]:::accent N1["no scheme check"]:::alert L1["format_html renders link"]:::n O1["live, clickable link
(incl. javascript:)"]:::alert V1 --> N1 --> L1 --> O1 classDef n fill:#1A1A1C,stroke:#2A2A2D,color:#EDEAE3 classDef accent fill:#0A0A0B,stroke:#FF4A1C,color:#EDEAE3 classDef alert fill:#0A0A0B,stroke:#E8342B,color:#EDEAE3
AFTER Django 6.0.8 and 5.2.17 — anything URLValidator rejects falls back to plain text.
flowchart LR V2["URLField value
(any scheme)"]:::accent C2{"URLValidator()
passes?"}:::n L2["format_html renders link"]:::n P2["display_for_value()
plain text, autoescaped"]:::n V2 --> C2 C2 -->|yes| L2 C2 -->|no| P2 classDef n fill:#1A1A1C,stroke:#2A2A2D,color:#EDEAE3 classDef accent fill:#0A0A0B,stroke:#FF4A1C,color:#EDEAE3 classDef alert fill:#0A0A0B,stroke:#E8342B,color:#EDEAE3
References
Django’s own write-up is the authoritative account of the issue.
- Django security releases, 4 August 2026 — the advisory, including the CVE-2026-15920 entry
- Release notes: Django 6.0.8 · Django 5.2.17
- Fix commits: 6.0
13debb622a· 5.2b9adb81339· 6.15a260d309a· main47511a2102 97ee8b82c2— the commit that introduced the auto-linking feature in 5.2- CVE-2019-12308 — the 2019
AdminURLFieldWidgetfix this sits alongside - Django’s security policy — how the “Moderate” rating is assigned
Timeline
| Date (UTC) | Event |
|---|---|
| 2026-07-12 | Found during an ongoing audit of Django’s admin app |
| 2026-07-14 | Reported to security@djangoproject.com |
| 2026-07-14 | Acknowledged by Django’s security team |
| 2026-07-21 | Confirmed by Django; proposed patch shared for review |
| 2026-08-04 | Fix released (13debb622a) — Django 6.0.8 and 5.2.17 (CVE-2026-15920) |
| 2026-08-05 | This writeup published |
A feature that made the admin nicer to read shipped one validation step behind the widget sitting right next to it. That’s usually all a stored XSS turns out to be — not a broken check, a missing one, copied from a neighbor that didn’t need it.
— Syntetisk research (@misop00p (aka @ansjdnakjdnajkd))