CVE-2026-79053
Overview
Background
- Lighthouse panel
- the DevTools front-end panel that audits a page. It runs Lighthouse in a separate
Workerand gives that worker Chrome DevTools Protocol (CDP) access through a proxy,ProtocolServiceinLighthouseProtocolService.ts. - Shared `CDPConnection`
- the DevTools front-end’s single protocol connection. It carries commands and events for every DevTools session: the root (no
sessionId), the main page session, and any child/auto-attached sessions. - Parallel Lighthouse session
- in
attach(),ProtocolServicecallsTarget.attachToTargetwithflatten: trueon the inspected target. This creates a dedicatedsessionIdthe Lighthouse run is meant to drive. - Session-scoped proxying
dispatchProtocolMessageforwards connection traffic into the worker, andsendProtocolMessagerelays worker commands onto the connection. WhichsessionIds each direction accepts sets the worker’s effective CDP privileges.
Root Cause Analysis
Before the patch, ProtocolService was an unscoped relay. sendProtocolMessage parsed {id, method, params, sessionId} from the worker and passed it straight to this.connection.send(method, params, sessionId) with no check on sessionId. The worker could therefore issue commands on the root session (no sessionId) or on any other DevTools session sharing the connection. In the other direction, dispatchProtocolMessage forwarded every message that had any sessionId, plus root-session Target.* messages, into the worker. The worker thus received events from unrelated sessions. The violated invariant is that the Lighthouse worker should only see and control the parallel session created for its run and that session’s descendants. The fix adds a #knownSessionIds set:
attach()seeds it with the run’ssessionId.- It grows only from
Target.attachedToTargetevents received on an already-known session, or from a successful workerTarget.attachToTargetsent on a known session. - It shrinks on
Target.detachedFromTargetand is cleared on teardown.
Both proxy directions now require membership. Unknown or empty session ids get a synthetic SESSION_NOT_FOUND error rather than being sent, and their events are dropped.
Attack Path
- User runs Lighthouse
a user opens DevTools on a page and starts a Lighthouse audit.
ProtocolService.attach()creates the parallel session and starts proxying for the worker. - Worker emits an out-of-scope command
the worker sends a
sendProtocolMessagepayload with nosessionIdor with a session other than the Lighthouse one. The tests useTarget.setAutoAttachon the root andRuntime.enableonOTHER_SESSION. How an attacker would come to influence the worker’s messages (for example via page content Lighthouse processes) is not shown by the patch (inferred). - Command executes with DevTools' privileges
before the fix, the proxy forwarded the command onto the shared
CDPConnectionunchanged. It ran against the root or another session with the full authority of the DevTools front-end connection. - Cross-session observation events from unrelated DevTools sessions were also forwarded into the worker, exposing traffic from targets outside the audit. This could be leveraged for information disclosure or for further control over other inspected targets (inferred).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
iffront_end/panels/lighthouse/LighthouseProtocolService.ts |
modified |
Files Changed
front_end/panels/lighthouse/LighthouseProtocolService.test.tsfront_end/panels/lighthouse/LighthouseProtocolService.ts
Audit Directions
- Unscoped protocol proxiesreview other DevTools components that relay CDP between a less-trusted context (workers, iframes, extensions, remote front-ends, recorder/performance insights helpers) and the shared connection. Each should check the target
sessionIdand method against an allow-list rather than forwarding raw messages. - Root-session reachabilitylook for paths where a missing or empty
sessionIddefaults to the root/browser session. Treat “no session” as a distinct, highly privileged target that needs explicit authorization. - Session-tree trackingwhere child sessions are adopted from
Target.attachedToTarget, make sure adoption only happens for events arriving on already-trusted sessions and that detaching or teardown removes them, to avoid confused-deputy bypasses.
Patch
From d1213e5a2279016b3429f933b39cc0979f6266d1 Mon Sep 17 00:00:00 2001 From: Lusa Zhan <[email protected]> Date: Thu, 09 Jul 2026 15:02:39 +0000 Subject: [PATCH] [lighthouse] Scope ProtocolService proxy to the run's sessions ProtocolService observes the front-end's shared CDPConnection and previously forwarded every event with a sessionId into the Lighthouse worker, and relayed every worker command back onto the connection regardless of which session it addressed. Track the parallel session created in attach() plus any child sessions introduced via Target.attachedToTarget / Target.attachToTarget on already-known sessions, and restrict both directions of the proxy to that set. Worker commands that target an unknown session (including the root session) are answered with a SESSION_NOT_FOUND error instead of being sent. This also stops events from unrelated DevTools sessions being broadcast into the worker, which the worker discarded anyway. Bug: 513850062 Change-Id: I69393978006683361ba356667d7392b54b437418 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/8070877 Reviewed-by: Danil Somsikov <[email protected]> Commit-Queue: Lusa Zhan <[email protected]> Reviewed-by: Simon Zünd <[email protected]> --- diff --git a/front_end/panels/lighthouse/LighthouseProtocolService.test.ts b/front_end/panels/lighthouse/LighthouseProtocolService.test.ts index a0a216d..92e88f6 100644 --- a/front_end/panels/lighthouse/LighthouseProtocolService.test.ts +++ b/front_end/panels/lighthouse/LighthouseProtocolService.test.ts @@ -6,6 +6,7 @@ import sinon from 'sinon'; import * as Platform from '../../core/platform/platform.js'; +import type * as ProtocolClient from '../../core/protocol_client/protocol_client.js'; import * as SDK from '../../core/sdk/sdk.js'; import type * as Protocol from '../../generated/protocol.js'; import {createTarget, describeWithEnvironment} from '../../testing/EnvironmentHelpers.js'; @@ -147,4 +148,125 @@ sinon.assert.notCalled(handleDialogStub); }); + + describe('worker protocol proxying', () => { + const MAIN_SESSION_ID = 'LH_MAIN_SESSION' as Protocol.Target.SessionID; + const CHILD_SESSION_ID = 'LH_CHILD_SESSION' as Protocol.Target.SessionID; + const OTHER_SESSION_ID = 'OTHER_SESSION' as Protocol.Target.SessionID; + + let connection: MockCDPConnection; + let connectionSend: sinon.SinonStub; + let workerPostMessage: sinon.SinonStub; + let mockWorker: Worker; + let workerStub: sinon.SinonStub; + let service: LighthouseModule.LighthouseProtocolService.ProtocolService; + + function workerSend(payload: {id: number, method: string, params?: object, sessionId?: string}): void { + mockWorker.dispatchEvent(new MessageEvent('message', { + data: {action: 'sendProtocolMessage', args: {message: JSON.stringify(payload)}}, + })); + } + + function attachedToTargetEvent(outerSessionId: string, innerSessionId: string): + ProtocolClient.CDPConnection.CDPEvent<'Target.attachedToTarget'> { + return { + method: 'Target.attachedToTarget', + params: { + sessionId: innerSessionId as Protocol.Target.SessionID, + targetInfo: { + targetId: 'x' as Protocol.Target.TargetID, + type: 'page', + title: '', + url: 'http://example.com/', + attached: true, + canAccessOpener: false, + }, + waitingForDebugger: true, + }, + sessionId: outerSessionId, + }; + } + + beforeEach(async () => { + mockWorker = new EventTarget() as unknown as Worker; + workerPostMessage = sinon.stub(); + mockWorker.postMessage = workerPostMessage as unknown as typeof mockWorker.postMessage; + mockWorker.terminate = sinon.stub() as unknown as typeof mockWorker.terminate; + workerStub = sinon.stub(globalThis, 'Worker').returns(mockWorker); + + const router = rootTarget.router(); + assert.exists(router); + connection = router.connection as MockCDPConnection; + + connection.setSuccessHandler('Target.attachToTarget', () => ({sessionId: MAIN_SESSION_ID})); + + service = new Lighthouse.LighthouseProtocolService.ProtocolService(); + await service.attach(urlString`https://example.com/page`); + + connectionSend = sinon.stub(connection, 'send').resolves({result: {}}); + + // Ensure the worker promise is resolved so that send() does not queue. + const workerReady = service.ensureWorkerExists(); + mockWorker.dispatchEvent(new MessageEvent('message', {data: 'workerReady'})); + await workerReady; + workerPostMessage.resetHistory(); + }); + + afterEach(() => { + workerStub.restore(); + }); + + it('only relays worker commands targeting sessions created for the run', () => { + workerSend({id: 1, method: 'Runtime.enable', sessionId: MAIN_SESSION_ID}); + sinon.assert.calledOnceWithExactly(connectionSend, 'Runtime.enable', undefined, MAIN_SESSION_ID); + + connectionSend.resetHistory(); + workerSend({id: 2, method: 'Target.setAutoAttach', params: {autoAttach: true}}); + workerSend({id: 3, method: 'Target.setAutoAttach', params: {autoAttach: true}, sessionId: ''}); + workerSend({id: 4, method: 'Runtime.enable', sessionId: OTHER_SESSION_ID}); + sinon.assert.notCalled(connectionSend); + }); + + it('relays worker commands targeting auto-attached child sessions', () => { + service.onEvent(attachedToTargetEvent(MAIN_SESSION_ID, CHILD_SESSION_ID)); + + workerSend({id: 1, method: 'Runtime.enable', sessionId: CHILD_SESSION_ID}); + sinon.assert.calledOnceWithExactly(connectionSend, 'Runtime.enable', undefined, CHILD_SESSION_ID); + + connectionSend.resetHistory(); + service.onEvent({ + method: 'Target.detachedFromTarget', + params: {sessionId: CHILD_SESSION_ID, targetId: 'x' as Protocol.Target.TargetID}, + sessionId: MAIN_SESSION_ID, + }); + workerSend({id: 2, method: 'Runtime.enable', sessionId: CHILD_SESSION_ID}); + sinon.assert.notCalled(connectionSend); + }); + + it('does not adopt empty child session ids from attachedToTarget', () => { + service.onEvent(attachedToTargetEvent(MAIN_SESSION_ID, '')); + workerSend({id: 1, method: 'Target.setAutoAttach', sessionId: ''}); + workerSend({id: 2, method: 'Target.setAutoAttach'}); + sinon.assert.notCalled(connectionSend); + }); + + it('only forwards events from sessions created for the run', async () => { + service.onEvent({method: 'Runtime.executionContextsCleared', params: undefined, sessionId: MAIN_SESSION_ID} as + ProtocolClient.CDPConnection.CDPEvent<'Runtime.executionContextsCleared'>); + service.onEvent({method: 'Runtime.executionContextsCleared', params: undefined, sessionId: OTHER_SESSION_ID} as + ProtocolClient.CDPConnection.CDPEvent<'Runtime.executionContextsCleared'>); + service.onEvent(attachedToTargetEvent(OTHER_SESSION_ID, CHILD_SESSION_ID)); + await new Promise(resolve => setTimeout(resolve, 0)); + + const forwarded = workerPostMessage.getCalls() + .map(call => call.args[0]) + .filter(message => message.action === 'dispatchProtocolMessage'); + assert.lengthOf(forwarded, 1); + assert.strictEqual(forwarded[0].args.message.sessionId, MAIN_SESSION_ID); + + // Child session ids introduced by events on unrelated sessions are not adopted. + workerSend({id: 1, method: 'Runtime.enable', sessionId: CHILD_SESSION_ID}); + sinon.assert.notCalled(connectionSend); + }); + }); }); diff --git a/front_end/panels/lighthouse/LighthouseProtocolService.ts b/front_end/panels/lighthouse/LighthouseProtocolService.ts index 795010c..5372d77 100644 --- a/front_end/panels/lighthouse/LighthouseProtocolService.ts +++ b/front_end/panels/lighthouse/LighthouseProtocolService.ts @@ -5,7 +5,7 @@ import * as Common from '../../core/common/common.js'; import * as i18n from '../../core/i18n/i18n.js'; import type * as Platform from '../../core/platform/platform.js'; -import type * as ProtocolClient from '../../core/protocol_client/protocol_client.js'; +import * as ProtocolClient from '../../core/protocol_client/protocol_client.js'; import * as SDK from '../../core/sdk/sdk.js'; import type * as Protocol from '../../generated/protocol.js'; import type * as LighthouseModel from '../../models/lighthouse/lighthouse.js'; @@ -79,6 +79,14 @@ private connection?: ProtocolClient.CDPConnection.CDPConnection; /** + * Session ids that belong to this Lighthouse run. The proxy only relays + * worker commands and connection events that target one of these sessions + * so that traffic stays scoped to the parallel session created in + * `attach()` and its descendants. + */ + readonly #knownSessionIds = new Set<string>(); + + /** * Tracks pending requests to the Lighthouse worker. * Key: The message ID sent to the worker. * Value: The rejection function for the corresponding promise. @@ -119,6 +127,8 @@ const rootTargetId = await rootChildTargetManager.getParentTargetId(); const {sessionId} = await rootTarget.targetAgent().invoke_attachToTarget({targetId: rootTargetId, flatten: true}); + this.#knownSessionIds.clear(); + this.#knownSessionIds.add(sessionId); this.connection = connection; this.connection.observe(this); @@ -217,6 +227,7 @@ this.rootTarget = undefined; this.connection?.unobserve(this); this.connection = undefined; + this.#knownSessionIds.clear(); if (oldLighthouseWorker) { (await oldLighthouseWorker).terminate(); @@ -237,18 +248,29 @@ } private dispatchProtocolMessage(message: ProtocolClient.CDPConnection.CDPReceivableMessage): void { - // A message without a sessionId is the main session of the main target (call it "Main session"). - // A parallel connection and session was made that connects to the same main target (call it "Lighthouse session"). - // Messages from the "Lighthouse session" have a sessionId. - // Without some care, there is a risk of sending the same events for the same main frame to Lighthouse–the backend - // will create events for the "Main session" and the "Lighthouse session". - // The workaround–only send message to Lighthouse if: - // * the message has a sessionId (is not for the "Main session") - // * the message does not have a sessionId (is for the "Main session"), but only for the Target domain - // (to kickstart autoAttach in LH). - if (message.sessionId || ('method' in message && message.method?.startsWith('Target'))) { - void this.send('dispatchProtocolMessage', {message}); + // The shared CDPConnection carries traffic for every DevTools session. A + // parallel session is created for the Lighthouse run in `attach()`, and the + // worker only ever needs traffic that belongs to that session or one of the + // child sessions it subsequently attaches. Filtering on `#knownSessionIds` + // keeps the proxy scoped to the run instead of broadcasting unrelated + // session traffic into the worker. + if (!message.sessionId || !this.#knownSessionIds.has(message.sessionId)) { + return; } + if ('method' in message) { + if (message.method === 'Target.attachedToTarget') { + const childSessionId = (message.params as Protocol.Target.AttachedToTargetEvent | undefined)?.sessionId; + if (childSessionId) { + this.#knownSessionIds.add(childSessionId); + } + } else if (message.method === 'Target.detachedFromTarget') { + const childSessionId = (message.params as Protocol.Target.DetachedFromTargetEvent | undefined)?.sessionId; + if (childSessionId) { + this.#knownSessionIds.delete(childSessionId); + } + } + } + void this.send('dispatchProtocolMessage', {message}); } onDisconnect(): void { @@ -303,11 +325,30 @@ private sendProtocolMessage(message: string): void { const {id, method, params, sessionId} = JSON.parse(message); + // The worker is expected to only address the parallel session created in + // `attach()` or sessions discovered beneath it. Anything else, including + // the root session, is outside the scope of the run. + if (!sessionId || !this.#knownSessionIds.has(sessionId)) { + void this.send('dispatchProtocolMessage', { + message: { + id, + sessionId, + error: { + code: ProtocolClient.CDPConnection.CDPErrorStatus.SESSION_NOT_FOUND, + message: `Unknown session id: ${sessionId}`, + }, + }, + }); + return; + } // CDPConnection manages it's own message IDs and it's important, otherwise we'd clash // with the rest of the DevTools traffic. // Instead, we ignore the ID coming from the worker when sending the command, but // patch it back in when sending the response back to the worker. void this.connection?.send(method, params, sessionId).then(response => { + if ('result' in response && method === 'Target.attachToTarget' && response.result?.sessionId) { + this.#knownSessionIds.add(response.result.sessionId); + } const message = 'result' in response ? {id, sessionId, result: response.result} : {id, sessionId, error: response.error}; this.dispatchProtocolMessage(message);
Original Bug Report
Renderer-to-Tab Privilege Escalation via DevTools Lighthouse Panel
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A logic vulnerability in the DevTools Lighthouse panel may allow a compromised renderer to issue privileged Chrome DevTools Protocol (CDP) commands on the root Tab connection. By forging protocol events, an attacker could exploit the Lighthouse worker as a confused deputy to perform unauthorized operations and spoof responses.
Affected files:
third_party/devtools-frontend/src/front_end/panels/lighthouse/LighthouseProtocolService.tsthird_party/devtools-frontend/src/front_end/core/protocol_client/DevToolsCDPConnection.tsthird_party/devtools-frontend/src/front_end/services/puppeteer/PuppeteerConnection.tsthird_party/devtools-frontend/src/front_end/entrypoints/lighthouse_worker/LighthouseWorkerService.tscontent/browser/devtools/devtools_session.cc
Estimated timestamp from git blame: 2025-11-03
Summary
A potential vulnerability has been identified in the interaction between the DevTools Lighthouse panel and its back-end worker. It appears that a compromised renderer can issue privileged Chrome DevTools Protocol (CDP) commands on the frontend’s root connection (typically the Tab target) by performing a ‘confused deputy’ attack. This is achieved by forging renderer-originated protocol events that are forwarded to the Lighthouse worker’s Puppeteer instance, which then issues commands targeting the root session due to insufficient sessionId validation.
Technical Details
1. Lack of Event Provenance in LighthouseProtocolService
LighthouseProtocolService.ts observes the DevTools shared root CDP connection. It forwards events to the Lighthouse worker if they contain a sessionId or if the method starts with Target. However, it does not appear to verify the provenance (origin) of these events:
// LighthouseProtocolService.ts
private dispatchProtocolMessage(message) {
if (message.sessionId || ('method' in message && message.method?.startsWith('Target'))) {
void this.send('dispatchProtocolMessage', {message});
}
}
A compromised renderer knows its own sessionId (provided via AttachDevToolsSession) and can forge a Target.attachedToTarget notification. While the browser validates the outer sessionId, it does not prevent the renderer from emitting Target.* domain events inside that session.
2. Confused Deputy via Empty sessionId
When the Lighthouse worker’s Puppeteer instance receives a forged Target.attachedToTarget event with an empty internal sessionId (e.g., ""), it may create a corresponding session state. In DevToolsCDPConnection.ts, the check if (sessionId) evaluates as false for an empty string, causing outgoing commands for that session to be dispatched on the root connection without a sessionId field. This results in the command being executed on the privileged root target (e.g., the Tab).
// DevToolsCDPConnection.ts
if (sessionId) {
messageObject.sessionId = sessionId;
}
3. Cross-Session Response Spoofing
DevToolsCDPConnection.ts matches incoming protocol responses to pending commands solely by a numeric id. It fails to verify that the sessionId in the response matches the sessionId associated with the original command:
// DevToolsCDPConnection.ts
if ('id' in messageObject && messageObject.id !== undefined) {
const callback = this.#callbacks.get(messageObject.id);
// ...
callback.resolve(messageObject);
}
Potential Impact
An attacker who has compromised a renderer could potentially use the DevTools frontend as a confused deputy to issue privileged commands, such as Target.setAutoAttach, on the root Tab connection. This could allow the attacker to attach to and control other tabs and frames, bypassing Site Isolation. Additionally, the attacker could spoof results for privileged CDP commands by guessing sequential message IDs.
Suggested Potential Steps to Reproduce
Note: These steps are theoretical as a functional proof-of-concept has not been executed.
- Compromise a renderer process (e.g., via a separate V8 exploit).
- Wait for the user to open the DevTools Lighthouse panel and start an audit on the compromised page.
- Retrieve the renderer’s CDP
session_id(e.g.,"S_page"). - From the renderer, use the
DevToolsSessionHostMojo interface to dispatch a forgedTarget.attachedToTargetnotification containing an empty internalsessionIdand tagged with"S_page". - Observe if the DevTools frontend issues privileged commands (like
Target.setAutoAttach) on its root connection without asessionIdfield.
Suggested Fix
- Verify Provenance: Update
LighthouseProtocolService.tsto ensure only events originating from the backend (and not the renderer) are forwarded to the worker. - Strict Validation: In
DevToolsCDPConnection.ts, use a strict check forsessionId(e.g.,sessionId !== undefined) instead of a truthiness check to ensure empty strings are not stripped. - Session-Aware Response Matching: Modify
DevToolsCDPConnection.tsto include thesessionIdin the command callback map and verify it matches thesessionIdin incoming responses.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.