Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient data validation in DevTools
DescriptionInsufficient data validation in DevTools
ComponentDevTools
Bug ClassLogic Error
Tracker513772764
Fix commit8667935bf84f (devtools/devtools-frontend) +239/-5
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Files Changed

  • front_end/models/ai_assistance/agents/StylingAgent.test.ts
From 8667935bf84f158b2d3c7986384f14a4a28060ea Mon Sep 17 00:00:00 2001
From: Alex Rudenko <alexrudenko@chromium.org>
Date: Tue, 19 May 2026 13:43:05 +0200
Subject: [PATCH] Check node origins in getStyles

Bug: 513772764
Change-Id: Ib56dbb9abb06527627ec08a06d21404052e61f45
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/7857677
Reviewed-by: Nikolay Vitkov <nvitkov@chromium.org>
Commit-Queue: Alex Rudenko <alexrudenko@chromium.org>
---

diff --git a/front_end/models/ai_assistance/agents/StylingAgent.test.ts b/front_end/models/ai_assistance/agents/StylingAgent.test.ts
index 2fd6a96..0e3266b 100644
--- a/front_end/models/ai_assistance/agents/StylingAgent.test.ts
+++ b/front_end/models/ai_assistance/agents/StylingAgent.test.ts
@@ -13,6 +13,7 @@
   updateHostConfig,
 } from '../../../testing/EnvironmentHelpers.js';
 import {SnapshotTester} from '../../../testing/SnapshotTester.js';
+import {createStubbedDomNodeWithModels, getMatchedStyles, ruleMatch} from '../../../testing/StyleHelpers.js';
 import * as AiAssistance from '../ai_assistance.js';
 
 const {StylingAgent, AiAgent} = AiAssistance;
@@ -725,13 +726,242 @@
           createExtensionScope,
           execJs,
         });
-        const responses =
-            await Array.fromAsync(agent.run('test', {selected: new AiAssistance.StylingAgent.NodeContext(element)}));
-        const actionStep = responses.find(response => response.type === AiAssistance.AiAgent.ResponseType.ACTION)!;
-        assert.strictEqual(
-            actionStep.output, 'Error: JavaScript execution that modifies the page is currently disabled.');
+        await Array.fromAsync(agent.run('test', {selected: new AiAssistance.StylingAgent.NodeContext(element)}));
         assert.lengthOf(execJs.getCalls(), 1);
       });
     });
   });
+
+  describe('getStyles', () => {
+    it('successfully returns computed and authored styles', async () => {
+      const {node: resolvedNode, cssModel} = createStubbedDomNodeWithModels({nodeId: 42});
+
+      resolvedNode.ownerDocument = null;
+      element.ownerDocument = null;
+
+      sinon.stub(SDK.DOMModel.DeferredDOMNode.prototype, 'resolvePromise').resolves(resolvedNode);
+
+      const computedStyleMap = new Map([['color', 'red']]);
+      (cssModel.getComputedStyle as sinon.SinonStub).resolves(computedStyleMap);
+
+      const matchedPayload = [ruleMatch('div', {color: 'red'})];
+      const matchedStyles = getMatchedStyles({cssModel, node: resolvedNode, matchedPayload});
+      (cssModel.getMatchedStyles as sinon.SinonStub).resolves(matchedStyles);
+
+      const agent = new StylingAgent.StylingAgent({
+        aidaClient: mockAidaClient([
+          [{
+            functionCalls: [{
+              name: 'getStyles',
+              args: {
+                explanation: 'Get element styles',
+                elements: [42],
+                styleProperties: ['color'],
+              },
+            }],
+            explanation: '',
+          }],
+          [{
+            explanation: 'this is the actual answer',
+          }]
+        ]),
+        createExtensionScope,
+        execJs: sinon.spy(),
+      });
+
+      const responses =
+          await Array.fromAsync(agent.run('test', {selected: new AiAssistance.StylingAgent.NodeContext(element)}));
+      const actionStep = responses.find(response => response.type === AiAssistance.AiAgent.ResponseType.ACTION)!;
+      assert.exists(actionStep);
+      assert.strictEqual(
+          actionStep.output,
+          JSON.stringify(
+              {
+                42: {
+                  computed: {color: 'red'},
+                  authored: {color: 'red'},
+                },
+              },
+              null, 2));
+    });
+
+    it('returns error on origin mismatch', async () => {
+      const {node: resolvedNode} = createStubbedDomNodeWithModels({nodeId: 42});
+
+      element.ownerDocument = {
+        documentURL: 'https://example.com',
+      } as unknown as SDK.DOMModel.DOMDocument;
+
+      resolvedNode.ownerDocument = {
+        documentURL: 'https://another.com',
+      } as unknown as SDK.DOMModel.DOMDocument;
+
+      sinon.stub(SDK.DOMModel.DeferredDOMNode.prototype, 'resolvePromise').resolves(resolvedNode);
+
+      const agent = new StylingAgent.StylingAgent({
+        aidaClient: mockAidaClient([
+          [{
+            functionCalls: [{
+              name: 'getStyles',
+              args: {
+                explanation: 'Get element styles',
+                elements: [42],
+                styleProperties: ['color'],
+              },
+            }],
+            explanation: '',
+          }],
+          [{
+            explanation: 'this is the actual answer',
+          }]
+        ]),
+        createExtensionScope,
+        execJs: sinon.spy(),
+      });
+
+      const responses =
+          await Array.fromAsync(agent.run('test', {selected: new AiAssistance.StylingAgent.NodeContext(element)}));
+      const actionStep = responses.find(response => response.type === AiAssistance.AiAgent.ResponseType.ACTION)!;
+      assert.exists(actionStep);
+      assert.strictEqual(actionStep.output, 'Error: Node does not belong to the current origin.');
+    });
+
+    it('returns error when selected element is missing', async () => {
+      const agent = new StylingAgent.StylingAgent({
+        aidaClient: mockAidaClient([
+          [{
+            functionCalls: [{
+              name: 'getStyles',
+              args: {
+                explanation: 'Get element styles',
+                elements: [42],
+                styleProperties: ['color'],
+              },
+            }],
+            explanation: '',
+          }],
+          [{
+            explanation: 'this is the actual answer',
+          }]
+        ]),
+        createExtensionScope,
+        execJs: sinon.spy(),
+      });
+
+      const responses = await Array.fromAsync(agent.run('test', {selected: null}));
+      const actionStep = responses.find(response => response.type === AiAssistance.AiAgent.ResponseType.ACTION)!;
+      assert.exists(actionStep);
+      assert.strictEqual(actionStep.output, 'Error: Could not find the currently selected element.');
+    });
+
+    it('returns error when target node cannot be resolved', async () => {
+      element.ownerDocument = null;
+
+      sinon.stub(SDK.DOMModel.DeferredDOMNode.prototype, 'resolvePromise').resolves(null);
+
+      const agent = new StylingAgent.StylingAgent({
+        aidaClient: mockAidaClient([
+          [{
+            functionCalls: [{
+              name: 'getStyles',
+              args: {
+                explanation: 'Get element styles',
+                elements: [42],
+                styleProperties: ['color'],
+              },
+            }],
+            explanation: '',
+          }],
+          [{
+            explanation: 'this is the actual answer',
+          }]
+        ]),
+        createExtensionScope,
+        execJs: sinon.spy(),
+      });
+
+      const responses =
+          await Array.fromAsync(agent.run('test', {selected: new AiAssistance.StylingAgent.NodeContext(element)}));
+      const actionStep = responses.find(response => response.type === AiAssistance.AiAgent.ResponseType.ACTION)!;
+      assert.exists(actionStep);
+      assert.strictEqual(actionStep.output, 'Error: Could not find the element with uid=42');
+    });
+
+    it('returns error when computed styles fail', async () => {
+      const {node: resolvedNode, cssModel} = createStubbedDomNodeWithModels({nodeId: 42});
+
+      resolvedNode.ownerDocument = null;
+      element.ownerDocument = null;
+
Loading diff…

Original Bug Report

reported by vm...@google.com

Cross-origin information leak in DevTools AI StylingAgent via BackendNodeId resolution

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: The DevTools AI StylingAgent resolves BackendNodeId values from the AI model without origin checks, potentially allowing cross-origin data access. In environments where frames share a renderer process, an attacker can use prompt injection to scan and leak CSS data from victim iframes. This is further facilitated by a side-effect bypass in the AI engine’s JavaScript stringification logic.

Affected files:

  • third_party/devtools-frontend/src/front_end/models/ai_assistance/agents/StylingAgent.ts
  • third_party/blink/renderer/core/inspector/inspector_dom_agent.cc
  • third_party/blink/renderer/core/inspector/inspected_frames.cc
  • third_party/blink/renderer/core/dom/dom_node_ids.cc

Estimated timestamp from git blame: 2025-10-01

Description

A potential vulnerability exists in the DevTools AI assistance StylingAgent where it resolves arbitrary BackendNodeId values provided by the AI model without verifying the origin of the resolved nodes.

Technical Details

  1. Predictable Node Identifiers: BackendNodeId values are assigned sequentially per renderer process in third_party/blink/renderer/core/dom/dom_node_ids.cc. Since cross-origin frames often share a process on platforms like Android, or when using same-site cross-origin frames, these IDs are predictable.
  2. Insufficient Origin Validation: In third_party/devtools-frontend/src/front_end/models/ai_assistance/agents/StylingAgent.ts, the #getStyles method resolves BackendNodeId values using node.resolvePromise(). On systems where Site Isolation is not absolute, cross-origin iframes share a ProbeSink with their parent, allowing the InspectorDOMAgent to successfully resolve these nodes for the DevTools frontend. The StylingAgent does not check if the resolved node’s origin matches the authorized origin for the AI conversation.
  3. Exfiltration via Side-Effect Bypass: An attacker can exfiltrate retrieved data using a side-effect bypass in third_party/devtools-frontend/src/front_end/models/ai_assistance/EvaluateAction.ts. While the initial evaluation of AI-generated JavaScript is checked for side effects, the subsequent serialization (stringification) of the result is performed via an internal callFunction call that does not set the throwOnSideEffect flag. This allows a side-effecting getter (e.g., one that calls fetch()) to execute during serialization without user approval.

Potential Exploitation Path

  1. An attacker website embeds a sensitive cross-origin victim site in an in-process iframe.
  2. The attacker uses prompt injection within a DOM attribute of an element they control.
  3. When a user selects this element and invokes AI assistance, the injection steers the AI to call getStyles with a range of UIDs to scan the process for the victim’s nodes.
  4. The agent retrieves styles from the victim iframe and returns them to the model.
  5. The model exfiltrates the data by returning an object with a side-effecting getter that sends the data to an attacker-controlled server during serialization.

Suggested Fix

  1. Implement a strict origin check in StylingAgent.ts. For every node resolved from AI-supplied arguments (in #getStyles and addElementAnnotation), verify that the node’s origin matches the allowedOrigin of the conversation context.
  2. Ensure that all internal calls to callFunction during the evaluation and serialization of AI-generated JavaScript (specifically in EvaluateAction.ts) respect the throwOnSideEffect constraint.

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