Skip to content

Commit b679496

Browse files
committed
Restrict read-only CDP guard to an exact-method allowlist
The previous guard allowlisted whole domains (Page/Runtime/DOM/ Accessibility) plus a verb-regex blocklist, so executable Runtime methods whose names carry no write verb — Runtime.callFunctionOn, Runtime.runScript, Runtime.compileScript — passed isReadonlyCdpMethod and could execute arbitrary page JavaScript while counting as read-only. Replace the domain+heuristic scheme with READONLY_CDP_METHODS, an exact-method allowlist enumerating only the primitives the bridge itself sends (Page.enable/navigate, Runtime.enable/evaluate, DOM.enable, Accessibility.enable/getFullAXTree/getRootAXNode). No domain or verb fallback remains; anything unenumerated is refused. Runtime.evaluate stays enumerated because the bridge sends it with bridge-owned constant snapshot expressions only — documented that the guard does not validate expressions, so no path may forward external expressions relying on this check alone. READONLY_CDP_DOMAINS is retained as documentation; FORBIDDEN_CDP_METHODS is retained as a test tripwire list. Adds regression tests: executable Runtime methods and unenumerated read-y methods are refused, near-miss spellings are refused, and every enumerated method is allowed.
1 parent 58da11f commit b679496

2 files changed

Lines changed: 75 additions & 44 deletions

File tree

src/shared/browser-bridge-types.test.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import {
66
toServerBridgeState,
77
FORBIDDEN_CDP_METHODS,
88
READONLY_CDP_DOMAINS,
9+
READONLY_CDP_METHODS,
910
} from './browser-bridge-types';
1011

11-
// The read-only CDP guard is allowlist-based (WS review item 7): a method is
12-
// permitted ONLY when its domain is one of the read-only domains AND it is not
13-
// a forbidden method / write-input-cookie-storage verb. Anything outside the
14-
// allowlisted domains is refused outright — a blocklist alone would let a new
15-
// write surface through.
12+
// The read-only CDP guard is an EXACT-METHOD allowlist (WS review item 7 +
13+
// codex review): a method is permitted ONLY when it is byte-for-byte one of
14+
// READONLY_CDP_METHODS. There is no domain-level or verb-heuristic fallback —
15+
// allowlisted domains like `Runtime` also expose arbitrary-code-execution
16+
// methods (callFunctionOn, runScript) whose names pass any verb regex.
1617
describe('isReadonlyCdpMethod (allowlist)', () => {
1718
it('allows the read-only primitives the bridge uses', () => {
1819
for (const m of [
@@ -56,6 +57,41 @@ describe('isReadonlyCdpMethod (allowlist)', () => {
5657
}
5758
});
5859

60+
it('refuses arbitrary-JS-execution Runtime methods NOT on the exact allowlist', () => {
61+
// These live in the allowlisted `Runtime` domain and their names pass a
62+
// verb heuristic, but they can execute arbitrary page JavaScript (clicks,
63+
// writes, cookie/storage reads). Only exact-method allowlisting blocks
64+
// them — this is the codex P1 regression test.
65+
for (const m of [
66+
'Runtime.callFunctionOn',
67+
'Runtime.runScript',
68+
'Runtime.compileScript',
69+
'Runtime.awaitPromise',
70+
'Runtime.queryObjects',
71+
'Runtime.globalLexicalScopeNames',
72+
]) {
73+
expect(isReadonlyCdpMethod(m), m).toBe(false);
74+
}
75+
});
76+
77+
it('refuses even read-only-looking methods that are not enumerated', () => {
78+
// The guard has NO domain fallback: an unenumerated getter in an
79+
// allowlisted domain is refused until it is added to the exact list.
80+
for (const m of ['DOM.getDocument', 'Page.getNavigationHistory', 'Runtime.getProperties']) {
81+
expect(isReadonlyCdpMethod(m), m).toBe(false);
82+
}
83+
});
84+
85+
it('allows exactly the enumerated methods and nothing else shape-wise', () => {
86+
for (const m of READONLY_CDP_METHODS) {
87+
expect(isReadonlyCdpMethod(m), m).toBe(true);
88+
}
89+
// Near-miss variants of allowed methods must not pass.
90+
expect(isReadonlyCdpMethod('Runtime.Evaluate')).toBe(false);
91+
expect(isReadonlyCdpMethod('Runtime.evaluate ')).toBe(false);
92+
expect(isReadonlyCdpMethod('runtime.evaluate')).toBe(false);
93+
});
94+
5995
it('refuses every explicitly forbidden method', () => {
6096
for (const m of FORBIDDEN_CDP_METHODS) {
6197
expect(isReadonlyCdpMethod(m), m).toBe(false);

src/shared/browser-bridge-types.ts

Lines changed: 34 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -191,16 +191,16 @@ export function toServerBridgeState(state: BridgeState): ServerBridgeState {
191191
return state === 'awaiting-approval' ? 'awaiting_approval' : state;
192192
}
193193

194-
// The read-only CDP domains the bridge is allowed to touch. Any command
195-
// outside this allowlist (Network.getCookies, Storage.*, Input.*, write
196-
// surfaces) is refused with `unsupported_action` and never sent to Chrome.
194+
// The CDP domains the bridge's read-only methods live in. DOCUMENTATION ONLY —
195+
// membership grants nothing: the guard below allowlists exact method names
196+
// (see READONLY_CDP_METHODS), never whole domains, because e.g. `Runtime`
197+
// also contains arbitrary-JS-execution methods (callFunctionOn, runScript).
197198
export const READONLY_CDP_DOMAINS = ['Page', 'Runtime', 'DOM', 'Accessibility'] as const;
198199
export type ReadonlyCdpDomain = (typeof READONLY_CDP_DOMAINS)[number];
199200

200-
// CDP methods that are explicitly forbidden even though their domain IS in the
201-
// read-only allowlist (e.g. Page.setDownloadBehavior lives under the allowed
202-
// `Page` domain but is a write). These are blocked in ADDITION to the
203-
// domain-allowlist check. Used by the primitive guard + asserted by tests.
201+
// High-risk CDP methods asserted by tests to NEVER be sent (cookie/storage
202+
// reads, input dispatch, download control). Redundant with the exact-method
203+
// allowlist below — kept as an explicit tripwire list for test assertions.
204204
export const FORBIDDEN_CDP_METHODS = [
205205
'Network.getCookies',
206206
'Network.getAllCookies',
@@ -214,47 +214,42 @@ export const FORBIDDEN_CDP_METHODS = [
214214
'Input.insertText',
215215
] as const;
216216

217-
// Within the allowlisted read-only domains, these method-name fragments still
218-
// indicate a write / input / consequential surface (e.g. Page.navigate is
219-
// allowed — the link gate governs it — but Page.setBypassCSP, DOM.setNodeValue,
220-
// Runtime.evaluate with a side effect are governed elsewhere; here we block the
221-
// obvious write/input/cookie/storage verbs by name). A method is refused when
222-
// its domain is outside the allowlist OR its name matches a forbidden verb.
223-
const FORBIDDEN_METHOD_VERB = /(set|delete|remove|insert|dispatch|create|clear|add|enable[A-Z]|cookie|storage|download|input|type|click|focus|screenshot|print|capture|emulate|write)/i;
224-
225-
// Allowlist-based read-only CDP guard. A method is allowed ONLY when:
226-
// 1. its domain (the part before the first '.') is in READONLY_CDP_DOMAINS, AND
227-
// 2. it is not in FORBIDDEN_CDP_METHODS, AND
228-
// 3. its method name (the part after the domain) does not match a write/input/
229-
// cookie/storage verb.
230-
// The bridge's own read-only primitives (Page.navigate/enable, Runtime.evaluate,
231-
// Runtime.enable, DOM.enable) are explicitly permitted. Returns true when the
232-
// method is safe to send.
233-
const ALLOWED_METHOD_EXCEPTIONS = new Set([
234-
'Page.navigate',
217+
// EXACT-METHOD allowlist: the only CDP methods the bridge is ever allowed to
218+
// send. There is deliberately NO domain-level or verb-heuristic fallback — an
219+
// allowlisted domain like `Runtime` still exposes arbitrary-code-execution
220+
// surfaces (Runtime.callFunctionOn, Runtime.runScript, Runtime.compileScript)
221+
// whose names pass any verb regex, so anything not enumerated here byte-for-
222+
// byte is refused. Grow this list one concrete method at a time, never by
223+
// domain.
224+
//
225+
// Runtime.evaluate NOTE: it is enumerated because the bridge's own read-only
226+
// primitives (inspect/scroll snapshot extraction) send it with BRIDGE-OWNED
227+
// constant expressions. The expression itself is NOT validated by this guard —
228+
// any path that would forward an externally-supplied expression must NOT rely
229+
// on isReadonlyCdpMethod alone and needs its own gate. The M1 bridge has no
230+
// such path: the server only hands out high-level action types
231+
// (inspect/navigate/scroll/wait); raw CDP methods/expressions never cross the
232+
// wire.
233+
export const READONLY_CDP_METHODS = [
235234
'Page.enable',
235+
'Page.navigate', // targets are governed by the same-site link gate
236236
'Runtime.enable',
237-
'Runtime.evaluate',
237+
'Runtime.evaluate', // bridge-owned constant expressions only — see note
238238
'DOM.enable',
239239
'Accessibility.enable',
240240
'Accessibility.getFullAXTree',
241241
'Accessibility.getRootAXNode',
242-
]);
242+
] as const;
243+
244+
const READONLY_CDP_METHOD_SET: ReadonlySet<string> = new Set(READONLY_CDP_METHODS);
243245

246+
// Allowlist-based read-only CDP guard: a method is allowed ONLY when it is one
247+
// of the exact READONLY_CDP_METHODS. Everything else — including "read-y"
248+
// methods in allowlisted domains (Runtime.callFunctionOn, Runtime.runScript,
249+
// DOM.getDocument) — is refused. Returns true when the method is safe to send.
244250
export function isReadonlyCdpMethod(method: string): boolean {
245251
if (!method || typeof method !== 'string') return false;
246-
const dot = method.indexOf('.');
247-
const domain = dot === -1 ? method : method.slice(0, dot);
248-
const name = dot === -1 ? '' : method.slice(dot + 1);
249-
// Domain must be in the read-only allowlist.
250-
if (!(READONLY_CDP_DOMAINS as readonly string[]).includes(domain)) return false;
251-
// Explicit forbidden list always wins.
252-
if ((FORBIDDEN_CDP_METHODS as readonly string[]).includes(method)) return false;
253-
// Explicitly-permitted read-only primitives bypass the verb heuristic.
254-
if (ALLOWED_METHOD_EXCEPTIONS.has(method)) return true;
255-
// Within an allowlisted domain, reject obvious write/input/cookie verbs.
256-
if (FORBIDDEN_METHOD_VERB.test(name)) return false;
257-
return true;
252+
return READONLY_CDP_METHOD_SET.has(method);
258253
}
259254

260255
// Returns the registrable host ("example.com") of a URL, or '' when it can't

0 commit comments

Comments
 (0)