Skip to content

Commit e50980a

Browse files
authored
Merge pull request #147 from Lykhoyda/fix/134.4-cdp-multiplexer-trust
fix: CDP multiplexer trust boundary — closes 1 HIGH (Phase 134.4)
2 parents 9798cfd + a95d171 commit e50980a

14 files changed

Lines changed: 312 additions & 33 deletions

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
{
1010
"name": "rn-dev-agent",
1111
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
12-
"version": "0.44.33",
12+
"version": "0.44.34",
1313
"source": "./",
1414
"category": "mobile-development",
1515
"homepage": "https://github.com/Lykhoyda/rn-dev-agent"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent",
3-
"version": "0.44.33",
3+
"version": "0.44.34",
44
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
55
"author": {
66
"name": "Anton Lykhoyda",

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,44 @@ All notable changes to rn-dev-agent will be documented in this file.
44

55
Format follows [Keep a Changelog](https://keepachangelog.com/).
66

7+
## [0.44.34] — 2026-05-12
8+
9+
### Fixed (Phase 134.4 — CDP multiplexer trust boundary, closes 1 HIGH)
10+
11+
- **CDP multiplexer now requires a per-instance capability token** in
12+
the WebSocket upgrade path. Previously any process that could
13+
discover the ephemeral loopback port could connect and send
14+
arbitrary CDP commands (`Runtime.evaluate`, `Page.navigate`, etc.)
15+
to the running Hermes runtime, **bypassing Claude Code's
16+
tool-permission prompts entirely**. This included a browser tab
17+
scanning local ports, a sibling shell, or any malicious process
18+
with loopback access.
19+
- The token is 32 bytes of `crypto.randomBytes` (43 char base64url),
20+
unique per multiplexer instance, included in the WebSocket URL
21+
path as `ws://127.0.0.1:<port>/<token>`. The `verifyClient`
22+
handler uses `timingSafeEqual` on equal-length buffers to compare
23+
— no timing side channel leaks the token.
24+
- The exposed `proxyUrl` (from `client.startProxy()`) and the
25+
DevTools URL (from `cdp_open_devtools`) automatically include the
26+
token. Without the token in the path, the multiplexer returns
27+
`401 Unauthorized` at upgrade time.
28+
- **Token never appears in logs** — log lines reference
29+
`ws://127.0.0.1:<port>/<token>` literally, not the actual value.
30+
31+
### Internal
32+
33+
- New exports from `cdp/multiplexer.ts`: `generateCapabilityToken()`
34+
and `verifyConsumerPath(reqUrl, expectedToken)`. Both pure
35+
functions for unit testing. `CDPMultiplexer.token` getter for
36+
callers building DevTools URLs.
37+
- 9 new unit tests cover the token verification truth table:
38+
legitimate token accepted; wrong token / missing token / empty
39+
token / length mismatch / query-style appendage / non-string
40+
inputs all rejected. Plus uniqueness across instances.
41+
- Implements the deepsec recommendation: per-proxy high-entropy
42+
capability token required during WebSocket upgrade, rejection
43+
before consumer registration.
44+
745
## [0.44.33] — 2026-05-12
846

947
### Fixed (Phase 134.3 — path containment, closes 2 HIGH + 3 MEDIUM)

scripts/cdp-bridge/dist/cdp-client.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,13 @@ export class CDPClient {
197197
const multiplexer = new CDPMultiplexer({ hermesUrl, ...opts });
198198
const port = await multiplexer.start();
199199
this._multiplexer = multiplexer;
200-
this._proxyUrl = `ws://127.0.0.1:${port}`;
201-
logger.info('CDP', `Proxy started on ${this._proxyUrl}, soft-reconnecting current session`);
200+
// Phase 134.4: include the per-instance capability token in the
201+
// exposed URL. DevTools (or any other consumer the user authorizes)
202+
// connects to `ws://127.0.0.1:<port>/<token>`. Without the token
203+
// in the path, the multiplexer rejects the WebSocket upgrade.
204+
// The token itself never appears in logs.
205+
this._proxyUrl = `ws://127.0.0.1:${port}/${multiplexer.token}`;
206+
logger.info('CDP', `Proxy started on ws://127.0.0.1:${port}/<token>, soft-reconnecting current session`);
202207
try {
203208
// B132: call `_softReconnectDirect` instead of `this.softReconnect()`. The
204209
// wrapper would observe _proxyUrl just set above and try to suspend the

scripts/cdp-bridge/dist/cdp/multiplexer.js

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,40 @@
11
import { createServer } from 'node:http';
2+
import { randomBytes, timingSafeEqual } from 'node:crypto';
3+
import { Buffer } from 'node:buffer';
24
import WebSocket, { WebSocketServer } from 'ws';
35
import { logger } from '../logger.js';
6+
/**
7+
* Phase 134.4 (deepsec HIGH): generate a per-multiplexer capability
8+
* token that consumers must include in the WebSocket upgrade path.
9+
* 32 bytes of crypto.randomBytes → 43 char base64url string. Never
10+
* logged, never exposed in error messages.
11+
*/
12+
export function generateCapabilityToken() {
13+
return randomBytes(32).toString('base64url');
14+
}
15+
/**
16+
* Pure verification helper. Returns true iff the request path is
17+
* exactly `/<expectedToken>`. Uses timingSafeEqual on equal-length
18+
* buffers to avoid leaking the token via response-timing side
19+
* channels. Fails closed on missing/empty/non-string inputs and on
20+
* empty expectedToken (never accept an unauthenticated multiplexer).
21+
*/
22+
export function verifyConsumerPath(reqUrl, expectedToken) {
23+
if (typeof expectedToken !== 'string' || expectedToken.length === 0)
24+
return false;
25+
if (typeof reqUrl !== 'string' || reqUrl.length === 0)
26+
return false;
27+
if (!reqUrl.startsWith('/'))
28+
return false;
29+
const submitted = reqUrl.slice(1);
30+
if (submitted.length !== expectedToken.length)
31+
return false;
32+
const a = Buffer.from(submitted);
33+
const b = Buffer.from(expectedToken);
34+
if (a.length !== b.length)
35+
return false;
36+
return timingSafeEqual(a, b);
37+
}
438
const HERMES_BUFFER_MAX_DEFAULT = 1000;
539
const ROUTING_TIMEOUT_MS_DEFAULT = 60_000;
640
export class CDPMultiplexer {
@@ -15,6 +49,14 @@ export class CDPMultiplexer {
1549
hermesBuffer = [];
1650
state = 'stopped';
1751
boundPort = null;
52+
/**
53+
* Phase 134.4: per-instance capability token. Required in the
54+
* WebSocket upgrade path. Never logged. Exposed via `token` getter
55+
* so the caller can include it in the URL it hands to DevTools.
56+
*/
57+
capabilityToken = generateCapabilityToken();
58+
/** Phase 134.4: the capability token for this multiplexer instance. */
59+
get token() { return this.capabilityToken; }
1860
routingSweeper = null;
1961
constructor(opts) {
2062
this.opts = {
@@ -74,7 +116,24 @@ export class CDPMultiplexer {
74116
startConsumerServer() {
75117
return new Promise((resolve, reject) => {
76118
this.httpServer = createServer();
77-
this.wss = new WebSocketServer({ server: this.httpServer });
119+
this.wss = new WebSocketServer({
120+
server: this.httpServer,
121+
// Phase 134.4 (deepsec HIGH): reject any upgrade that doesn't
122+
// include the capability token in the path. Any sibling
123+
// process that learned the loopback port (or a browser tab
124+
// scanning local ports) is refused before reaching
125+
// onConsumerConnect / onConsumerMessage. Token comparison
126+
// uses timingSafeEqual to avoid leaking the secret via
127+
// timing side channels.
128+
verifyClient: (info, callback) => {
129+
if (verifyConsumerPath(info.req.url, this.capabilityToken)) {
130+
callback(true);
131+
return;
132+
}
133+
logger.warn(this.opts.logTag, 'rejected upgrade: missing or invalid capability token');
134+
callback(false, 401, 'Unauthorized');
135+
},
136+
});
78137
this.wss.on('connection', (ws) => this.onConsumerConnect(ws));
79138
this.wss.on('error', (err) => {
80139
logger.warn(this.opts.logTag, `WebSocketServer error: ${err instanceof Error ? err.message : err}`);

scripts/cdp-bridge/dist/tools/open-devtools.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,17 @@ export function createOpenDevToolsHandler(getClient) {
7070
// internal state drift — fail loudly rather than return a half-working URL.
7171
return failResult('cdp_open_devtools: multiplexer started but has no bound port');
7272
}
73+
const proxyToken = client.proxyMultiplexer?.token ?? null;
74+
if (proxyToken === null) {
75+
return failResult('cdp_open_devtools: multiplexer started but capability token is missing');
76+
}
7377
// DevTools frontend still lives on Metro (it's static HTML + JS served over HTTP).
7478
// Only the WS destination changes: DevTools → proxy (loopback); proxy → Hermes.
75-
const devtoolsUrl = `http://127.0.0.1:${metroPort}/debugger-frontend/rn_fusebox.html?ws=127.0.0.1:${proxyPort}`;
79+
// Phase 134.4: the proxy now requires a per-instance capability token in
80+
// the WebSocket path. DevTools includes it via the `ws=` query value;
81+
// any browser tab that learns the loopback port without the token gets
82+
// a 401 upgrade rejection from verifyClient.
83+
const devtoolsUrl = `http://127.0.0.1:${metroPort}/debugger-frontend/rn_fusebox.html?ws=127.0.0.1:${proxyPort}/${proxyToken}`;
7684
return okResult({
7785
devtoolsUrl,
7886
inspectorWsUrl: proxyUrl,

scripts/cdp-bridge/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent-cdp",
3-
"version": "0.38.28",
3+
"version": "0.38.29",
44
"type": "module",
55
"main": "dist/index.js",
66
"scripts": {

scripts/cdp-bridge/src/cdp-client.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,8 +237,13 @@ export class CDPClient {
237237
const multiplexer = new CDPMultiplexer({ hermesUrl, ...opts });
238238
const port = await multiplexer.start();
239239
this._multiplexer = multiplexer;
240-
this._proxyUrl = `ws://127.0.0.1:${port}`;
241-
logger.info('CDP', `Proxy started on ${this._proxyUrl}, soft-reconnecting current session`);
240+
// Phase 134.4: include the per-instance capability token in the
241+
// exposed URL. DevTools (or any other consumer the user authorizes)
242+
// connects to `ws://127.0.0.1:<port>/<token>`. Without the token
243+
// in the path, the multiplexer rejects the WebSocket upgrade.
244+
// The token itself never appears in logs.
245+
this._proxyUrl = `ws://127.0.0.1:${port}/${multiplexer.token}`;
246+
logger.info('CDP', `Proxy started on ws://127.0.0.1:${port}/<token>, soft-reconnecting current session`);
242247
try {
243248
// B132: call `_softReconnectDirect` instead of `this.softReconnect()`. The
244249
// wrapper would observe _proxyUrl just set above and try to suspend the

scripts/cdp-bridge/src/cdp/multiplexer.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,40 @@
11
import { createServer, type Server } from 'node:http';
22
import { type AddressInfo } from 'node:net';
3+
import { randomBytes, timingSafeEqual } from 'node:crypto';
4+
import { Buffer } from 'node:buffer';
35
import WebSocket, { WebSocketServer } from 'ws';
46

57
import { logger } from '../logger.js';
68

9+
/**
10+
* Phase 134.4 (deepsec HIGH): generate a per-multiplexer capability
11+
* token that consumers must include in the WebSocket upgrade path.
12+
* 32 bytes of crypto.randomBytes → 43 char base64url string. Never
13+
* logged, never exposed in error messages.
14+
*/
15+
export function generateCapabilityToken(): string {
16+
return randomBytes(32).toString('base64url');
17+
}
18+
19+
/**
20+
* Pure verification helper. Returns true iff the request path is
21+
* exactly `/<expectedToken>`. Uses timingSafeEqual on equal-length
22+
* buffers to avoid leaking the token via response-timing side
23+
* channels. Fails closed on missing/empty/non-string inputs and on
24+
* empty expectedToken (never accept an unauthenticated multiplexer).
25+
*/
26+
export function verifyConsumerPath(reqUrl: unknown, expectedToken: string): boolean {
27+
if (typeof expectedToken !== 'string' || expectedToken.length === 0) return false;
28+
if (typeof reqUrl !== 'string' || reqUrl.length === 0) return false;
29+
if (!reqUrl.startsWith('/')) return false;
30+
const submitted = reqUrl.slice(1);
31+
if (submitted.length !== expectedToken.length) return false;
32+
const a = Buffer.from(submitted);
33+
const b = Buffer.from(expectedToken);
34+
if (a.length !== b.length) return false;
35+
return timingSafeEqual(a, b);
36+
}
37+
738
/**
839
* CDP Multiplexer proxy (M1 / Phase 90 Tier 1).
940
*
@@ -53,6 +84,15 @@ export class CDPMultiplexer {
5384
private hermesBuffer: string[] = [];
5485
private state: 'stopped' | 'starting' | 'running' | 'stopping' = 'stopped';
5586
private boundPort: number | null = null;
87+
/**
88+
* Phase 134.4: per-instance capability token. Required in the
89+
* WebSocket upgrade path. Never logged. Exposed via `token` getter
90+
* so the caller can include it in the URL it hands to DevTools.
91+
*/
92+
private readonly capabilityToken: string = generateCapabilityToken();
93+
94+
/** Phase 134.4: the capability token for this multiplexer instance. */
95+
get token(): string { return this.capabilityToken; }
5696
private routingSweeper: NodeJS.Timeout | null = null;
5797

5898
constructor(opts: MultiplexerOptions) {
@@ -120,7 +160,24 @@ export class CDPMultiplexer {
120160
private startConsumerServer(): Promise<number> {
121161
return new Promise((resolve, reject) => {
122162
this.httpServer = createServer();
123-
this.wss = new WebSocketServer({ server: this.httpServer });
163+
this.wss = new WebSocketServer({
164+
server: this.httpServer,
165+
// Phase 134.4 (deepsec HIGH): reject any upgrade that doesn't
166+
// include the capability token in the path. Any sibling
167+
// process that learned the loopback port (or a browser tab
168+
// scanning local ports) is refused before reaching
169+
// onConsumerConnect / onConsumerMessage. Token comparison
170+
// uses timingSafeEqual to avoid leaking the secret via
171+
// timing side channels.
172+
verifyClient: (info, callback) => {
173+
if (verifyConsumerPath(info.req.url, this.capabilityToken)) {
174+
callback(true);
175+
return;
176+
}
177+
logger.warn(this.opts.logTag, 'rejected upgrade: missing or invalid capability token');
178+
callback(false, 401, 'Unauthorized');
179+
},
180+
});
124181

125182
this.wss.on('connection', (ws) => this.onConsumerConnect(ws));
126183
this.wss.on('error', (err) => {

scripts/cdp-bridge/src/tools/open-devtools.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,17 @@ export function createOpenDevToolsHandler(getClient: () => CDPClient) {
110110
// internal state drift — fail loudly rather than return a half-working URL.
111111
return failResult('cdp_open_devtools: multiplexer started but has no bound port');
112112
}
113+
const proxyToken = client.proxyMultiplexer?.token ?? null;
114+
if (proxyToken === null) {
115+
return failResult('cdp_open_devtools: multiplexer started but capability token is missing');
116+
}
113117
// DevTools frontend still lives on Metro (it's static HTML + JS served over HTTP).
114118
// Only the WS destination changes: DevTools → proxy (loopback); proxy → Hermes.
115-
const devtoolsUrl = `http://127.0.0.1:${metroPort}/debugger-frontend/rn_fusebox.html?ws=127.0.0.1:${proxyPort}`;
119+
// Phase 134.4: the proxy now requires a per-instance capability token in
120+
// the WebSocket path. DevTools includes it via the `ws=` query value;
121+
// any browser tab that learns the loopback port without the token gets
122+
// a 401 upgrade rejection from verifyClient.
123+
const devtoolsUrl = `http://127.0.0.1:${metroPort}/debugger-frontend/rn_fusebox.html?ws=127.0.0.1:${proxyPort}/${proxyToken}`;
116124

117125
return okResult({
118126
devtoolsUrl,

0 commit comments

Comments
 (0)