Security News

Cybersecurity news aggregator

🔓
MEDIUM Vulnerabilities Reddit r/netsec

Stored XSS in Django's admin via an unvalidated URLField display path (CVE-2026-15920)

  • What: Stored XSS vulnerability in Django's admin interface
  • Impact: Could allow attackers to execute malicious scripts in admin panels
Read Full Article →

[ 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 t...

Share this article