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.
Summary
The visualization
jsonAPI lets any authenticated CourtListener account attach a newJSONVersionto another user's SCOTUSMap visualization, because the create path never enforces the object-owner check that the retrieve/update/delete paths do. The attacker-suppliedjson_datais then rendered raw (via the|safefilter) 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
DocketTagobjects, 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
freelawproject/courtlistenermain@f7f45696fca033a83fabace3ebd880916aa36ec2(continuously deployed to courtlistener.com)POST /api/rest/v4/visualizations/json/(also reachable under/api/rest/v3/...), with the sink atGET /visualizations/scotus-mapper/{pk}/embed/POST /api/rest/v4/docket-tags/(also reachable under/api/rest/v3/...)Technical detail
cl/visualizations/api_permissions.pyclassIsParentVisualizationOwnerandcl/favorites/api_permissions.pyclassIsTagOwnerdefine onlyhas_object_permission(). This is invoked for retrieve/update/delete, and the classes do not havehas_permission()methods, so DRF's default lets any authenticated request through with no ownership check.maprelation is unscoped —cl/visualizations/api_serializers.py,JSONVersionSerializerusesfields = "__all__"with no override onmap, so that field is backed bySCOTUSMap.objects.all(); a request body can therefore reference any user's SCOTUSMap (sequential integer PKs, easily enumerable).json_datais stored and served verbatim —cl/visualizations/models.py:369,json_data = models.TextField()has no validator, andSCOTUSMap.jsonreturns the newest version'sjson_data(JSONVersion.Meta.ordering = ["-date_modified"]), so the attacker's freshly-created version wins.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
JSONVersionwhosemapforeign 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
json_datawith|safeand instead JSON-encode it safely (e.g. Django'sjson_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.