# 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-27 --- ## Summary Joro'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 Cause Three 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 Chain 1. The operator visits an attacker-controlled page in Firefox on their machine. 2. JavaScript on the page fetches `pwn.so` from the attacker's server (same-origin, no CORS issue). 3. 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. 4. JavaScript POSTs to `http://127.0.0.1:9090/api/v1/system/restart`. Joro re-executes. 5. On restart, `plugin.Open("pwn.so")` calls `init()`, which opens a goroutine and dials back to the attacker's listener. 6. 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. --- ## Impact Unauthenticated, 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. ## Fix The 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`) ```diff var handler http.Handler = mux if s.listenerMode { + // Listener/teamserver: bearer-token auth. handler = team.AuthMiddleware(s.teamToken, handler) +} else { + // Proxy mode: restrict the API to same-origin browser requests. + handler = originGuard(uiBind, handler) } -handler = corsMiddleware(handler) ``` ```diff -// corsMiddleware adds permissive CORS headers for dev usage. -func corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Joro-Nickname") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - next.ServeHTTP(w, r) - }) -} ``` ### 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`) ```go func isMutating(method string) bool { switch method { case http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch: return true default: return false } } func sameOrigin(r *http.Request) bool { switch r.Header.Get("Sec-Fetch-Site") { case "", "same-origin", "none": // Same-origin, a direct navigation, or a non-browser client. default: // "cross-site", "same-site" return false } if origin := r.Header.Get("Origin"); origin != "" { if origin == "null" { return false // opaque/sandboxed cross-origin context } u, err := url.Parse(origin) if err != nil || !strings.EqualFold(reqHostname(u.Host), reqHostname(r.Host)) { return false } } return true } ``` ### 3. Tightened the WebSocket origin check The 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`) ```diff var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + if origin == "null" { + return false + } + u, err := url.Parse(origin) + if err != nil { + return false + } + return strings.EqualFold(reqHostname(u.Host), reqHostname(r.Host)) + }, } ``` ### 4. Bound the proxy-mode UI/API to loopback and removed the wildcard host exception The 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`) ```diff +// In proxy mode the UI/API binds to loopback only: --bind governs the proxy +// port, and remote collaboration is listener/teamserver mode (bearer-token auth). +uiBind := s.cfg.BindAddr +if !s.listenerMode { + uiBind = "127.0.0.1" +} + var handler http.Handler = mux ... s.srv = &http.Server{ - Addr: fmt.Sprintf("%s:%d", s.cfg.BindAddr, s.cfg.UIPort), + Addr: fmt.Sprintf("%s:%d", uiBind, s.cfg.UIPort), ``` ```diff func hostAllowed(reqHost, bindAddr string) bool { h := reqHostname(reqHost) if h == "" { return false } switch h { case "localhost", "127.0.0.1", "::1": return true } - switch bindAddr { - case "", "0.0.0.0", "::": - return true - } return strings.EqualFold(h, reqHostname(bindAddr)) } ```
# 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-27 --- ## Summary Joro'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 Cause Three 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 Chain 1. The operator visits an attacker-controlled page in Firefox on their machine. 2. JavaScript on the page fetches `pwn.so` from the attacker's server (same-origin, no CORS issue). 3. 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. 4. JavaScript POSTs to `http://127.0.0.1:9090/api/v1/system/restart`. Joro re-executes. 5. On restart, `plugin.Open("pwn.so")` calls `init()`, which opens a goroutine and dials back to the attacker's listener. 6. 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. --- ## Impact Unauthenticated, 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. ## Fix The 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`) ```diff var handler http.Handler = mux if s.listenerMode { + // Listener/teamserver: bearer-token auth. handler = team.AuthMiddleware(s.teamToken, handler) +} else { + // Proxy mode: restrict the API to same-origin browser requests. + handler = originGuard(uiBind, handler) } -handler = corsMiddleware(handler) ``` ```diff -// corsMiddleware adds permissive CORS headers for dev usage. -func corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Joro-Nickname") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - next.ServeHTTP(w, r) - }) -} ``` ### 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`) ```go func isMutating(method string) bool { switch method { case http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch: return true default: return false } } func sameOrigin(r *http.Request) bool { switch r.Header.Get("Sec-Fetch-Site") { case "", "same-origin", "none": // Same-origin, a direct navigation, or a non-browser client. default: // "cross-site", "same-site" return false } if origin := r.Header.Get("Origin"); origin != "" { if origin == "null" { return false // opaque/sandboxed cross-origin context } u, err := url.Parse(origin) if err != nil || !strings.EqualFold(reqHostname(u.Host), reqHostname(r.Host)) { return false } } return true } ``` ### 3. Tightened the WebSocket origin check The 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`) ```diff var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + if origin == "null" { + return false + } + u, err := url.Parse(origin) + if err != nil { + return false + } + return strings.EqualFold(reqHostname(u.Host), reqHostname(r.Host)) + }, } ``` ### 4. Bound the proxy-mode UI/API to loopback and removed the wildcard host exception The 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`) ```diff +// In proxy mode the UI/API binds to loopback only: --bind governs the proxy +// port, and remote collaboration is listener/teamserver mode (bearer-token auth). +uiBind := s.cfg.BindAddr +if !s.listenerMode { + uiBind = "127.0.0.1" +} + var handler http.Handler = mux ... s.srv = &http.Server{ - Addr: fmt.Sprintf("%s:%d", s.cfg.BindAddr, s.cfg.UIPort), + Addr: fmt.Sprintf("%s:%d", uiBind, s.cfg.UIPort), ``` ```diff func hostAllowed(reqHost, bindAddr string) bool { h := reqHostname(reqHost) if h == "" { return false } switch h { case "localhost", "127.0.0.1", "::1": return true } - switch bindAddr { - case "", "0.0.0.0", "::": - return true - } return strings.EqualFold(h, reqHostname(bindAddr)) } ```
Update github.com/BishopFox/joro to 0.0.0-20260601151442-5c0ca35db828 if you use the affected versions. Test the change in a non-production environment first.
Local check
hol-guard supply-chain scanJoro: Unauthenticated Cross-Origin Plugin Upload Leads to RCE affects github.com/BishopFox/joro (go). Severity is critical. # 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-27 --- ## Summary Joro'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 Cause Three 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 Chain 1. The operator visits an attacker-controlled page in Firefox on their machine. 2. JavaScript on the page fetches `pwn.so` from the attacker's server (same-origin, no CORS issue). 3. 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. 4. JavaScript POSTs to `http://127.0.0.1:9090/api/v1/system/restart`. Joro re-executes. 5. On restart, `plugin.Open("pwn.so")` calls `init()`, which opens a goroutine and dials back to the attacker's listener. 6. 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. --- ## Impact Unauthenticated, 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. ## Fix The 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`) ```diff var handler http.Handler = mux if s.listenerMode { + // Listener/teamserver: bearer-token auth. handler = team.AuthMiddleware(s.teamToken, handler) +} else { + // Proxy mode: restrict the API to same-origin browser requests. + handler = originGuard(uiBind, handler) } -handler = corsMiddleware(handler) ``` ```diff -// corsMiddleware adds permissive CORS headers for dev usage. -func corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Joro-Nickname") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - next.ServeHTTP(w, r) - }) -} ``` ### 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`) ```go func isMutating(method string) bool { switch method { case http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch: return true default: return false } } func sameOrigin(r *http.Request) bool { switch r.Header.Get("Sec-Fetch-Site") { case "", "same-origin", "none": // Same-origin, a direct navigation, or a non-browser client. default: // "cross-site", "same-site" return false } if origin := r.Header.Get("Origin"); origin != "" { if origin == "null" { return false // opaque/sandboxed cross-origin context } u, err := url.Parse(origin) if err != nil || !strings.EqualFold(reqHostname(u.Host), reqHostname(r.Host)) { return false } } return true } ``` ### 3. Tightened the WebSocket origin check The 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`) ```diff var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + if origin == "null" { + return false + } + u, err := url.Parse(origin) + if err != nil { + return false + } + return strings.EqualFold(reqHostname(u.Host), reqHostname(r.Host)) + }, } ``` ### 4. Bound the proxy-mode UI/API to loopback and removed the wildcard host exception The 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`) ```diff +// In proxy mode the UI/API binds to loopback only: --bind governs the proxy +// port, and remote collaboration is listener/teamserver mode (bearer-token auth). +uiBind := s.cfg.BindAddr +if !s.listenerMode { + uiBind = "127.0.0.1" +} + var handler http.Handler = mux ... s.srv = &http.Server{ - Addr: fmt.Sprintf("%s:%d", s.cfg.BindAddr, s.cfg.UIPort), + Addr: fmt.Sprintf("%s:%d", uiBind, s.cfg.UIPort), ``` ```diff func hostAllowed(reqHost, bindAddr string) bool { h := reqHostname(reqHost) if h == "" { return false } switch h { case "localhost", "127.0.0.1", "::1": return true } - switch bindAddr { - case "", "0.0.0.0", "::": - return true - } return strings.EqualFold(h, reqHostname(bindAddr)) } ```
AI coding agents often install or upgrade packages automatically in go. A critical vulnerability in a dependency can be pulled into a project through a normal install or update without a human reviewing the change, expanding the blast radius from a single package to every agent workspace that depends on it.
| Package | Affected range | Fixed version |
|---|---|---|
| github.com/BishopFox/jorogo | <0.0.0-20260601151442-5c0ca35db828 | 0.0.0-20260601151442-5c0ca35db828 |
Fixed versions are reported by the source feed; confirm compatibility before updating.
Reported by GitHub Security Advisories (ghsa).
HOL Guard can help your team review package activity against supported protection paths.
Explore HOL GuardUpdate github.com/BishopFox/joro to 0.0.0-20260601151442-5c0ca35db828 if you use the affected versions. Test the change in a non-production environment first.
Local check
hol-guard supply-chain scanJoro: Unauthenticated Cross-Origin Plugin Upload Leads to RCE affects github.com/BishopFox/joro (go). Severity is critical. # 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-27 --- ## Summary Joro'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 Cause Three 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 Chain 1. The operator visits an attacker-controlled page in Firefox on their machine. 2. JavaScript on the page fetches `pwn.so` from the attacker's server (same-origin, no CORS issue). 3. 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. 4. JavaScript POSTs to `http://127.0.0.1:9090/api/v1/system/restart`. Joro re-executes. 5. On restart, `plugin.Open("pwn.so")` calls `init()`, which opens a goroutine and dials back to the attacker's listener. 6. 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. --- ## Impact Unauthenticated, 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. ## Fix The 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`) ```diff var handler http.Handler = mux if s.listenerMode { + // Listener/teamserver: bearer-token auth. handler = team.AuthMiddleware(s.teamToken, handler) +} else { + // Proxy mode: restrict the API to same-origin browser requests. + handler = originGuard(uiBind, handler) } -handler = corsMiddleware(handler) ``` ```diff -// corsMiddleware adds permissive CORS headers for dev usage. -func corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Joro-Nickname") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - next.ServeHTTP(w, r) - }) -} ``` ### 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`) ```go func isMutating(method string) bool { switch method { case http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch: return true default: return false } } func sameOrigin(r *http.Request) bool { switch r.Header.Get("Sec-Fetch-Site") { case "", "same-origin", "none": // Same-origin, a direct navigation, or a non-browser client. default: // "cross-site", "same-site" return false } if origin := r.Header.Get("Origin"); origin != "" { if origin == "null" { return false // opaque/sandboxed cross-origin context } u, err := url.Parse(origin) if err != nil || !strings.EqualFold(reqHostname(u.Host), reqHostname(r.Host)) { return false } } return true } ``` ### 3. Tightened the WebSocket origin check The 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`) ```diff var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + if origin == "null" { + return false + } + u, err := url.Parse(origin) + if err != nil { + return false + } + return strings.EqualFold(reqHostname(u.Host), reqHostname(r.Host)) + }, } ``` ### 4. Bound the proxy-mode UI/API to loopback and removed the wildcard host exception The 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`) ```diff +// In proxy mode the UI/API binds to loopback only: --bind governs the proxy +// port, and remote collaboration is listener/teamserver mode (bearer-token auth). +uiBind := s.cfg.BindAddr +if !s.listenerMode { + uiBind = "127.0.0.1" +} + var handler http.Handler = mux ... s.srv = &http.Server{ - Addr: fmt.Sprintf("%s:%d", s.cfg.BindAddr, s.cfg.UIPort), + Addr: fmt.Sprintf("%s:%d", uiBind, s.cfg.UIPort), ``` ```diff func hostAllowed(reqHost, bindAddr string) bool { h := reqHostname(reqHost) if h == "" { return false } switch h { case "localhost", "127.0.0.1", "::1": return true } - switch bindAddr { - case "", "0.0.0.0", "::": - return true - } return strings.EqualFold(h, reqHostname(bindAddr)) } ```
AI coding agents often install or upgrade packages automatically in go. A critical vulnerability in a dependency can be pulled into a project through a normal install or update without a human reviewing the change, expanding the blast radius from a single package to every agent workspace that depends on it.
| Package | Affected range | Fixed version |
|---|---|---|
| github.com/BishopFox/jorogo | <0.0.0-20260601151442-5c0ca35db828 | 0.0.0-20260601151442-5c0ca35db828 |
Fixed versions are reported by the source feed; confirm compatibility before updating.
Reported by GitHub Security Advisories (ghsa).
HOL Guard can help your team review package activity against supported protection paths.
Explore HOL Guard