Skip to content

CourtListener: unscoped visualization JSONVersion create (IDOR) chained to stored XSS in the public embed view

High
mlissner published GHSA-cvh7-rv7v-wx2j Aug 13, 2026

Package

courtlistener

Affected versions

main @ f7f45696fca033a83fabace3ebd880916aa36ec2 ·

Patched versions

None

Description

Summary

The visualization json API lets any authenticated CourtListener account attach a new JSONVersion to another user's SCOTUSMap visualization, because the create path never enforces the object-owner check that the retrieve/update/delete paths do. The attacker-supplied json_data is then rendered raw (via the |safe filter) inside a <script> block on the public embed view, so it executes as JavaScript on the courtlistener.com origin for anyone who loads that visualization's embed URL. In short: an object-permission gap on create is chained into a stored cross-site scripting vulnerability that a low-privileged user can plant on a victim's map.

While reviewing the above issue we discovered that the same pattern was used on DocketTag objects, though they do not allow XSS attacks, so this only allows one user to modify the tags of another, but does not allow JavaScript injection.

Affected

  • Repository: freelawproject/courtlistener
  • Branch / commit: main @ f7f45696fca033a83fabace3ebd880916aa36ec2 (continuously deployed to courtlistener.com)
  • Endpoints:
    • POST /api/rest/v4/visualizations/json/ (also reachable under /api/rest/v3/...), with the sink at GET /visualizations/scotus-mapper/{pk}/embed/
    • POST /api/rest/v4/docket-tags/ (also reachable under /api/rest/v3/...)

Technical detail

  • Create paths are unguarded — cl/visualizations/api_permissions.py class IsParentVisualizationOwner and cl/favorites/api_permissions.py class IsTagOwner define only has_object_permission(). This is invoked for retrieve/update/delete, and the classes do not have has_permission() methods, so DRF's default lets any authenticated request through with no ownership check.
  • The map relation is unscoped — cl/visualizations/api_serializers.py, JSONVersionSerializer uses fields = "__all__" with no override on map, so that field is backed by SCOTUSMap.objects.all(); a request body can therefore reference any user's SCOTUSMap (sequential integer PKs, easily enumerable).
  • json_data is stored and served verbatim — cl/visualizations/models.py:369, json_data = models.TextField() has no validator, and SCOTUSMap.json returns the newest version's json_data (JSONVersion.Meta.ordering = ["-date_modified"]), so the attacker's freshly-created version wins.
  • Raw render into a script block — cl/visualizations/templates/visualization_embedded.html:20-22, var opinions = {{ viz.json|safe }}; disables autoescaping and injects the stored value as a JavaScript statement; it sits outside the deleted/unpublished content gate, so it renders even for a non-owner loading a private map's embed. The payload lands inside the value of the legitimately-nonced <script>, so a nonce-based CSP does not gate it.

Impact

CWE-639 (Authorization Bypass Through User-Controlled Key) chained to CWE-79 (Stored Cross-site Scripting).

CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N = 8.7 (High). Arbitrary JavaScript executes on the courtlistener.com origin in the browser of anyone who loads the poisoned visualization's embed URL.

Proof of concept

Reproduced locally on an unmodified build with synthetic data using two accounts. Account B created a JSONVersion whose map foreign key pointed at Account A's private SCOTUSMap (HTTP 201, despite B having no relationship to that map), and the supplied payload was then served raw inside the embed view's <script> block and executed as live JavaScript. A positive control rendering the identical value through Django's default autoescape neutralized it (HTML-entity-encoded, did not execute), and restoring an ownership check on create returned HTTP 403 — isolating both halves of the chain. Full request/response captures available on request.

Remediation

  • The JSON API is converted to read only since this is an undocumented API that was seldom used.
  • An ownership check was added to the docket-tag API.
  • We no longer render json_data with |safe and instead JSON-encode it safely (e.g. Django's json_script) and escape it before embedding it in the script block.

Novelty

No existing CVE, GHSA, or OSV advisory covers this CourtListener visualizations create-path issue, and it is distinct from the earlier CourtListener UserTag authorization issue (different application, viewset, endpoint, and root-cause chain).

Credit

This issue was found by Santosh Kumar Puppala (https://github.com/Santoshkumarpuppala); coordinated disclosure, a CVE and credit under this name would be appreciated.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
Required
Scope
Changed
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

CVE ID

No known CVE

Weaknesses

Authorization Bypass Through User-Controlled Key

The system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data. Learn more on MITRE.

Credits