CVE-2026-79053
Overview
Files Changed
front_end/panels/lighthouse/LighthouseProtocolService.test.tsfront_end/panels/lighthouse/LighthouseProtocolService.ts
Patch
From d1213e5a2279016b3429f933b39cc0979f6266d1 Mon Sep 17 00:00:00 2001
From: Lusa Zhan <lusazhan@chromium.org>
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 <dsv@chromium.org>
Commit-Queue: Lusa Zhan <lusazhan@chromium.org>
Reviewed-by: Simon Zünd <szuend@chromium.org>
---
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);
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.