GHSA-xqhv-chqm-fhcc Joro: Unauthenticated Cross-Origin Plugin Upload Leads to RCE
Unauthenticated Cross-Origin Plugin Upload Leads to RCE (Joro ≤ v1.1.0)**Severity:** Critical
**CVSS v3.1:** 9.6 (AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H)
**Affected versions:** Joro ≤ v1.1.0, proxy mode (default), Linux/macOS
**Reporter:** cstover
**Date:** 2026-05-27SummaryJoro's default proxy mode (in versions <= 1.1.0) exposes a local API on `127.0.0.1:9090` that performs no authentication and applies a wildcard CORS policy. Because plugin uploads use the CORS-safelisted `multipart/form-data` content type, cross-origin JavaScript on any page the operator visits can reach privileged endpoints - including uploading a native plugin and triggering a restart - directly through the operator's browser, with no preflight or credentials. Since plugins execute on load, this yields unauthenticated remote code execution as the operator's user from a single page visit.Root CauseThree weaknesses combined into the exploit chain.**1. No authentication in proxy mode.**
`internal/api/server.go` applied `AuthMiddleware` only when `listenerMode` was `true`. In the default proxy mode every API endpoint — including plugin upload and system restart — accepted requests without any token, cookie, or credential.**2. Permissive CORS with an insufficient protection assumption.**
`corsMiddleware` set `Access-Control-Allow-Origin: *` unconditionally on all responses. `SECURITY.md` documented this as an intentional tradeoff on the basis that proxy mode binds to `127.0.0.1`, which the document states "limits exposure to the local machine."That assumption was incorrect. `multipart/form-data` is a CORS-safelisted `Content-Type`, so cross-origin JavaScript can POST files to the Joro API without triggering a preflight request — the browser allows it. Any web page the operator visited reached the localhost API through their browser without restriction. The localhost bind provided no protection against browser-mediated requests.**3. Plugin `init()` executed on `plugin.Open()` before symbol lookup.**
`internal/plugins/loader.go` called `plugin.Open()`, which ran the plugin's `init()` functions before any symbol lookup occurred. A plugin with no exports still executed its payload the moment Joro restarted.Attack ChainThe operator visits an attacker-controlled page in Firefox on their machine.JavaScript on the page fetches `pwn.so` from the attacker's server (same-origin, no CORS issue).JavaScript POSTs `pwn.so` to `http://127.0.0.1:9090/api/v1/plugins/upload` as `multipart/form-data`. Joro accepts it — no auth, no preflight.JavaScript POSTs to `http://127.0.0.1:9090/api/v1/system/restart`. Joro re-executes.On restart, `plugin.Open("pwn.so")` calls `init()`, which opens a goroutine and dials back to the attacker's listener.An interactive `/bin/bash -i` shell is obtained as the operator's user.The plugin ABI matches without any access to the operator's machine. The same public v1.1.0 release tarball is downloaded and Joro's own `--build-plugin` feature is used, which reads `runtime/debug.BuildInfo` from the release binary and forwards every ABI-relevant flag. One `.so` works against every operator running that release.ImpactUnauthenticated, remote, browser-mediated code execution as the operator's user. Because the exploit pivots through the operator's browser to the loopback-bound API, the network bind offers no protection, and a single ABI-matched plugin works against every operator running the affected release.FixThe chain is broken at multiple layers. Cross-origin browser access to the proxy-mode API is eliminated, the API is restricted to same-origin requests targeting a loopback host, and the UI/API is bound to loopback only.1. Removed the wildcard CORS header and gated the proxy-mode API behind a same-origin guard`corsMiddleware` (which set `Access-Control-Allow-Origin: *` on every response) was deleted, and proxy mode now wraps the API in `originGuard` instead. (`internal/api/server.go`, commit `5c0ca35`)2. Same-origin enforcement via `Sec-Fetch-Site` + `Origin`/`Host``originGuard` rejects state-changing requests (and the `/ws` upgrade) whose `Sec-Fetch-Site` indicates a cross-origin initiator or whose `Origin` host does not match the request `Host`. Non-browser local tooling (no browser headers) is still allowed. (`internal/api/originguard.go`, commit `5c0ca35`)3. Tightened the WebSocket origin checkThe WebSocket upgrader previously accepted every origin (`CheckOrigin: return true`). It now rejects cross-origin handshakes while still permitting non-browser clients. (`internal/api/ws.go`, commit `5c0ca35`)4. Bound the proxy-mode UI/API to loopback and removed the wildcard host exceptionThe same-origin check alone can be defeated by DNS rebinding under a wildcard bind, because a rebound host (e.g. `attacker.com`) carries consistent `Origin`/`Host`/`Sec-Fetch-Site` headers. Two coordinated changes close this: the proxy-mode UI/API now binds to `127.0.0.1` regardless of `--bind` (which governs only the proxy port), and `hostAllowed` no longer has a wildcard exception, so the host must be loopback or the exact bind address. (`internal/api/server.go` and `internal/api/originguard.go`, commit `871936f`)
CVSS v3.1 score: 9.6