Medium watch CVSS 3.1 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactMissing authorization in Lighthouse
DescriptionMissing authorization in Lighthouse
ComponentLighthouse
Sub-componentLighthouse
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker513850062
Fix commitd1213e5a2279 (devtools/devtools-frontend) +175/-12
CWECWE-862
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-16 (per vendor advisory)
Fix landed2026-07-15 (public git)
Disclosed2026-08-25
Patch gap41 days — fix public in source before the advisory

Background

Lighthouse panel
the DevTools front-end panel that audits a page. It runs Lighthouse in a separate Worker and gives that worker Chrome DevTools Protocol (CDP) access through a proxy, ProtocolService in LighthouseProtocolService.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(), ProtocolService calls Target.attachToTarget with flatten: true on the inspected target. This creates a dedicated sessionId the Lighthouse run is meant to drive.
Session-scoped proxying
dispatchProtocolMessage forwards connection traffic into the worker, and sendProtocolMessage relays worker commands onto the connection. Which sessionIds 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’s sessionId.
  • It grows only from Target.attachedToTarget events received on an already-known session, or from a successful worker Target.attachToTarget sent on a known session.
  • It shrinks on Target.detachedFromTarget and 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.

Key insight
The proxy that let the Lighthouse worker talk to the DevTools protocol had no notion of which sessions belonged to the run, so the worker could command, and receive events from, any session on DevTools’ shared connection, including the root. The fix allow-lists the run’s own session tree in both directions.

Attack Path

  1. 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.
  2. Worker emits an out-of-scope command the worker sends a sendProtocolMessage payload with no sessionId or with a session other than the Lighthouse one. The tests use Target.setAutoAttach on the root and Runtime.enable on OTHER_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).
  3. Command executes with DevTools' privileges before the fix, the proxy forwarded the command onto the shared CDPConnection unchanged. It ran against the root or another session with the full authority of the DevTools front-end connection.
  4. 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

The advisory describes “Missing authorization in Lighthouse” and gives no in-the-wild status. The patch shows that the Lighthouse worker inside the DevTools front-end could issue CDP commands to, and receive events from, sessions beyond the one created for its run, including the root session. That is broader access than the audit needs. Any concrete escalation (controlling or reading other targets through privileged CDP) depends on an attacker influencing the worker’s protocol traffic and is inferred. Reaching it requires the user to open DevTools and run a Lighthouse audit, so ordinary browsing does not reach it.

Changed Functions

FunctionChangeNotes
if
front_end/panels/lighthouse/LighthouseProtocolService.ts
modified

Files Changed

  • front_end/panels/lighthouse/LighthouseProtocolService.test.ts
  • front_end/panels/lighthouse/LighthouseProtocolService.ts

Audit Directions

  • Unscoped protocol proxies
    review 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 sessionId and method against an allow-list rather than forwarding raw messages.
  • Root-session reachability
    look for paths where a missing or empty sessionId defaults to the root/browser session. Treat “no session” as a distinct, highly privileged target that needs explicit authorization.
  • Session-tree tracking
    where 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.
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);
     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);
Loading diff…

Original Bug Report

reported by vm...@google.com

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.ts
  • third_party/devtools-frontend/src/front_end/core/protocol_client/DevToolsCDPConnection.ts
  • third_party/devtools-frontend/src/front_end/services/puppeteer/PuppeteerConnection.ts
  • third_party/devtools-frontend/src/front_end/entrypoints/lighthouse_worker/LighthouseWorkerService.ts
  • content/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.

  1. Compromise a renderer process (e.g., via a separate V8 exploit).
  2. Wait for the user to open the DevTools Lighthouse panel and start an audit on the compromised page.
  3. Retrieve the renderer’s CDP session_id (e.g., "S_page").
  4. From the renderer, use the DevToolsSessionHost Mojo interface to dispatch a forged Target.attachedToTarget notification containing an empty internal sessionId and tagged with "S_page".
  5. Observe if the DevTools frontend issues privileged commands (like Target.setAutoAttach) on its root connection without a sessionId field.

Suggested Fix

  1. Verify Provenance: Update LighthouseProtocolService.ts to ensure only events originating from the backend (and not the renderer) are forwarded to the worker.
  2. Strict Validation: In DevToolsCDPConnection.ts, use a strict check for sessionId (e.g., sessionId !== undefined) instead of a truthiness check to ensure empty strings are not stripped.
  3. Session-Aware Response Matching: Modify DevToolsCDPConnection.ts to include the sessionId in the command callback map and verify it matches the sessionId in 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.

View on issue tracker