{"id":"GHSA-xqhv-chqm-fhcc","summary":"Joro: Unauthenticated Cross-Origin Plugin Upload Leads to RCE","details":"# Unauthenticated Cross-Origin Plugin Upload Leads to RCE (Joro ≤ v1.1.0)\n\n**Severity:** Critical\n**CVSS v3.1:** 9.6 (AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H)\n**Affected versions:** Joro ≤ v1.1.0, proxy mode (default), Linux/macOS\n**Reporter:** cstover\n**Date:** 2026-05-27\n\n---\n\n## Summary\n\nJoro's default proxy mode (in versions \u003c= 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.\n\n---\n\n## Root Cause\n\nThree weaknesses combined into the exploit chain.\n\n**1. No authentication in proxy mode.**\n`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.\n\n**2. Permissive CORS with an insufficient protection assumption.**\n`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.\"\n\nThat 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.\n\n**3. Plugin `init()` executed on `plugin.Open()` before symbol lookup.**\n`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.\n\n---\n\n## Attack Chain\n\n1. The operator visits an attacker-controlled page in Firefox on their machine.\n2. JavaScript on the page fetches `pwn.so` from the attacker's server (same-origin, no CORS issue).\n3. 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.\n4. JavaScript POSTs to `http://127.0.0.1:9090/api/v1/system/restart`. Joro re-executes.\n5. On restart, `plugin.Open(\"pwn.so\")` calls `init()`, which opens a goroutine and dials back to the attacker's listener.\n6. An interactive `/bin/bash -i` shell is obtained as the operator's user.\n\nThe 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.\n\n---\n\n## Impact\n\nUnauthenticated, 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.\n\n## Fix\n\nThe 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.\n\n### 1. Removed the wildcard CORS header and gated the proxy-mode API behind a same-origin guard\n\n`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`)\n\n```diff\n var handler http.Handler = mux\n if s.listenerMode {\n+     // Listener/teamserver: bearer-token auth.\n      handler = team.AuthMiddleware(s.teamToken, handler)\n+} else {\n+     // Proxy mode: restrict the API to same-origin browser requests.\n+     handler = originGuard(uiBind, handler)\n }\n-handler = corsMiddleware(handler)\n```\n\n```diff\n-// corsMiddleware adds permissive CORS headers for dev usage.\n-func corsMiddleware(next http.Handler) http.Handler {\n-     return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n-             w.Header().Set(\"Access-Control-Allow-Origin\", \"*\")\n-             w.Header().Set(\"Access-Control-Allow-Methods\", \"GET, POST, PUT, DELETE, OPTIONS\")\n-             w.Header().Set(\"Access-Control-Allow-Headers\", \"Content-Type, Authorization, X-Joro-Nickname\")\n-             if r.Method == http.MethodOptions {\n-                     w.WriteHeader(http.StatusNoContent)\n-                     return\n-             }\n-             next.ServeHTTP(w, r)\n-     })\n-}\n```\n\n### 2. Same-origin enforcement via `Sec-Fetch-Site` + `Origin`/`Host`\n\n`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`)\n\n```go\nfunc isMutating(method string) bool {\n      switch method {\n      case http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch:\n              return true\n      default:\n              return false\n      }\n}\n\nfunc sameOrigin(r *http.Request) bool {\n      switch r.Header.Get(\"Sec-Fetch-Site\") {\n      case \"\", \"same-origin\", \"none\":\n              // Same-origin, a direct navigation, or a non-browser client.\n      default: // \"cross-site\", \"same-site\"\n              return false\n      }\n      if origin := r.Header.Get(\"Origin\"); origin != \"\" {\n              if origin == \"null\" {\n                      return false // opaque/sandboxed cross-origin context\n              }\n              u, err := url.Parse(origin)\n              if err != nil || !strings.EqualFold(reqHostname(u.Host), reqHostname(r.Host)) {\n                      return false\n              }\n      }\n      return true\n}\n```\n\n### 3. Tightened the WebSocket origin check\n\nThe 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`)\n\n```diff\nvar upgrader = websocket.Upgrader{\n-     CheckOrigin: func(r *http.Request) bool { return true },\n+     CheckOrigin: func(r *http.Request) bool {\n+             origin := r.Header.Get(\"Origin\")\n+             if origin == \"\" {\n+                     return true\n+             }\n+             if origin == \"null\" {\n+                     return false\n+             }\n+             u, err := url.Parse(origin)\n+             if err != nil {\n+                     return false\n+             }\n+             return strings.EqualFold(reqHostname(u.Host), reqHostname(r.Host))\n+     },\n }\n```\n\n### 4. Bound the proxy-mode UI/API to loopback and removed the wildcard host exception\n\nThe 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`)\n\n```diff\n+// In proxy mode the UI/API binds to loopback only: --bind governs the proxy\n+// port, and remote collaboration is listener/teamserver mode (bearer-token auth).\n+uiBind := s.cfg.BindAddr\n+if !s.listenerMode {\n+     uiBind = \"127.0.0.1\"\n+}\n+\n var handler http.Handler = mux\n ...\n s.srv = &http.Server{\n-     Addr:              fmt.Sprintf(\"%s:%d\", s.cfg.BindAddr, s.cfg.UIPort),\n+     Addr:              fmt.Sprintf(\"%s:%d\", uiBind, s.cfg.UIPort),\n```\n\n```diff\n func hostAllowed(reqHost, bindAddr string) bool {\n      h := reqHostname(reqHost)\n      if h == \"\" {\n              return false\n      }\n      switch h {\n      case \"localhost\", \"127.0.0.1\", \"::1\":\n              return true\n      }\n-     switch bindAddr {\n-     case \"\", \"0.0.0.0\", \"::\":\n-             return true\n-     }\n      return strings.EqualFold(h, reqHostname(bindAddr))\n }\n```","aliases":["CVE-2026-53649","GO-2026-5947"],"modified":"2026-07-21T19:54:07.125750876Z","published":"2026-07-08T20:27:02Z","database_specific":{"github_reviewed_at":"2026-07-08T20:27:02Z","nvd_published_at":null,"cwe_ids":["CWE-306","CWE-352","CWE-434","CWE-942"],"severity":"CRITICAL","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/BishopFox/joro/security/advisories/GHSA-xqhv-chqm-fhcc"},{"type":"PACKAGE","url":"https://github.com/BishopFox/joro"}],"affected":[{"package":{"name":"github.com/BishopFox/joro","ecosystem":"Go","purl":"pkg:golang/github.com/BishopFox/joro"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.0.0-20260601151442-5c0ca35db828"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-xqhv-chqm-fhcc/GHSA-xqhv-chqm-fhcc.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H"}]}