CVE-2026-17787
Overview
Files Changed
front_end/models/ai_assistance/AiUtils.test.tsfront_end/models/ai_assistance/AiUtils.tsfront_end/models/ai_assistance/BUILD.gnfront_end/models/ai_assistance/agents/AccessibilityAgent.test.ts
Patch
From 32a24bb2ba47f11049b1ad57c8be87ee8077650e Mon Sep 17 00:00:00 2001 From: Jack Franklin <[email protected]> Date: Thu, 28 May 2026 13:03:06 +0100 Subject: [PATCH] AI: Improve cross-origin data leak check in AccessibilityAgent Replace frame ID check with origin check using documentURL to prevent data leaks from cross-origin iframes while allowing same-origin iframes. Handle data URLs by comparing the full URL string, as they cannot be distinguished by origin alone. This change also adds missing tests for node ID linkification and data URLs, and updates test descriptions to use "origin" instead of "frame". Fixed: 513783632 Change-Id: Ic1ec87f10e159a6d3196b229cf6d29eb5a83daf2 Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/7878867 Commit-Queue: Kim-Anh Tran <[email protected]> Reviewed-by: Kim-Anh Tran <[email protected]> Auto-Submit: Jack Franklin <[email protected]> --- diff --git a/front_end/models/ai_assistance/AiUtils.test.ts b/front_end/models/ai_assistance/AiUtils.test.ts new file mode 100644 index 0000000..e175b3c --- /dev/null +++ b/front_end/models/ai_assistance/AiUtils.test.ts @@ -0,0 +1,43 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import * as Platform from '../../core/platform/platform.js'; + +import * as AiAssistance from './ai_assistance.js'; + +const {urlString} = Platform.DevToolsPath; + +describe('AiUtils', () => { + describe('isSameOrigin', () => { + it('returns true for identical origins', () => { + const url1 = urlString`https://example.com/page1`; + const url2 = urlString`https://example.com/page2`; + assert.isTrue(AiAssistance.AiUtils.isSameOrigin(url1, url2)); + }); + + it('returns false for different origins', () => { + const url1 = urlString`https://example.com`; + const url2 = urlString`https://google.com`; + assert.isFalse(AiAssistance.AiUtils.isSameOrigin(url1, url2)); + }); + + it('returns true for identical data URLs', () => { + const url1 = urlString`data:text/html,hello`; + const url2 = urlString`data:text/html,hello`; + assert.isTrue(AiAssistance.AiUtils.isSameOrigin(url1, url2)); + }); + + it('returns false for different data URLs', () => { + const url1 = urlString`data:text/html,hello`; + const url2 = urlString`data:text/html,world`; + assert.isFalse(AiAssistance.AiUtils.isSameOrigin(url1, url2)); + }); + + it('returns false if one is data URL and other is not', () => { + const url1 = urlString`https://example.com`; + const url2 = urlString`data:text/html,hello`; + assert.isFalse(AiAssistance.AiUtils.isSameOrigin(url1, url2)); + }); + }); +}); diff --git a/front_end/models/ai_assistance/AiUtils.ts b/front_end/models/ai_assistance/AiUtils.ts index 00e9a99..843bd31 100644 --- a/front_end/models/ai_assistance/AiUtils.ts +++ b/front_end/models/ai_assistance/AiUtils.ts @@ -62,3 +62,12 @@ export function getIconName(): string { return isGeminiBranding() ? 'spark' : 'smart-assistant'; } + +export function isSameOrigin(url1: Platform.DevToolsPath.UrlString, url2: Platform.DevToolsPath.UrlString): boolean { + if (url1.startsWith('data:') || url2.startsWith('data:')) { + return url1 === url2; + } + const origin1 = Common.ParsedURL.ParsedURL.extractOrigin(url1); + const origin2 = Common.ParsedURL.ParsedURL.extractOrigin(url2); + return origin1 !== '' && origin1 === origin2; +} diff --git a/front_end/models/ai_assistance/BUILD.gn b/front_end/models/ai_assistance/BUILD.gn index 7ee1723..b1a325c 100644 --- a/front_end/models/ai_assistance/BUILD.gn +++ b/front_end/models/ai_assistance/BUILD.gn @@ -111,6 +111,7 @@ "AgentProject.test.ts", "AiConversation.test.ts", "AiHistoryStorage.test.ts", + "AiUtils.test.ts", "BuiltInAi.test.ts", "ChangeManager.test.ts", "EvaluateAction.test.ts", diff --git a/front_end/models/ai_assistance/agents/AccessibilityAgent.test.ts b/front_end/models/ai_assistance/agents/AccessibilityAgent.test.ts index d393215..8d4ee3c 100644 --- a/front_end/models/ai_assistance/agents/AccessibilityAgent.test.ts +++ b/front_end/models/ai_assistance/agents/AccessibilityAgent.test.ts @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import * as Platform from '../../../core/platform/platform.js'; import * as SDK from '../../../core/sdk/sdk.js'; import type * as Protocol from '../../../generated/protocol.js'; import {mockAidaClient} from '../../../testing/AiAssistanceHelpers.js'; @@ -10,6 +11,8 @@ import type * as LHModel from '../../lighthouse/lighthouse.js'; import * as AiAssistance from '../ai_assistance.js'; +const {urlString} = Platform.DevToolsPath; + describeWithMockConnection('AccessibilityAgent', () => { const mockReport = { lighthouseVersion: '1.0.0', @@ -134,62 +137,7 @@ assert.strictEqual(titleResponse.title, 'Reading accessibility details'); }); - it('getElementAccessibilityDetails yields a DomTreeAiWidget containing the node snapshot', - async () => { - const target = createTarget(); - const aidaClient = mockAidaClient([[{ - explanation: '', - functionCalls: - [{name: 'getElementAccessibilityDetails', args: {path: '1,HTML,1,BODY', explanation: 'testing'}}], - metadata: { - rpcGlobalId: 123, - }, - }]]); - const agent = new AiAssistance.AccessibilityAgent.AccessibilityAgent({ - aidaClient, - }); - const context = new AiAssistance.AccessibilityAgent.AccessibilityContext(mockReport); - - const domModel = target.model(SDK.DOMModel.DOMModel)!; - const accessibilityModel = target.model(SDK.AccessibilityModel.AccessibilityModel)!; - const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel)!; - - const mockNode = sinon.createStubInstance(SDK.DOMModel.DOMNode); - mockNode.domModel.returns(domModel); - mockNode.id = 42 as Protocol.DOM.NodeId; - mockNode.backendNodeId.returns(100 as Protocol.DOM.BackendNodeId); - mockNode.attributes.returns([]); - mockNode.frameId.returns('main' as Protocol.Page.FrameId); - - const mockSnapshot = sinon.createStubInstance(SDK.DOMModel.DOMNodeSnapshot); - mockNode.takeSnapshot.resolves(mockSnapshot); - - sinon.stub(domModel, 'pushNodeByPathToFrontend').resolves(42 as Protocol.DOM.NodeId); - sinon.stub(domModel, 'nodeForId').withArgs(42 as Protocol.DOM.NodeId).returns(mockNode); - resourceTreeModel.mainFrame = {id: 'main' as Protocol.Page.FrameId} as SDK.ResourceTreeModel.ResourceTreeFrame; - - sinon.stub(accessibilityModel, 'requestAndLoadSubTreeToNode').resolves(); - const mockAxNode = sinon.createStubInstance(SDK.AccessibilityModel.AccessibilityNode); - mockAxNode.role.returns({value: 'button', type: 'role' as Protocol.Accessibility.AXValueType}); - mockAxNode.name.returns({ - value: 'Click me', - type: 'string' as Protocol.Accessibility.AXValueType, - sources: [{type: 'attribute' as Protocol.Accessibility.AXValueSourceType}], - }); - mockAxNode.ignored.returns(false); - mockAxNode.ignoredReasons.returns([]); - sinon.stub(accessibilityModel, 'axNodeForDOMNode').withArgs(mockNode).returns(mockAxNode); - - const responses = await Array.fromAsync(agent.run('test', {selected: context})); - const actions = responses.filter(r => r.type === AiAssistance.AiAgent.ResponseType.ACTION); - assert.lengthOf(actions, 1); - assert.exists(actions[0].widgets); - const widget = actions[0].widgets?.find(w => w.name === 'DOM_TREE') as AiAssistance.AiAgent.DomTreeAiWidget; - assert.exists(widget); - assert.strictEqual(widget.data.root, mockSnapshot); - }); - - it('getElementAccessibilityDetails returns an error if the node is in a different frame', async () => { + it('getElementAccessibilityDetails yields a DomTreeAiWidget containing the node snapshot', async () => { const target = createTarget(); const aidaClient = mockAidaClient([[{ explanation: '', @@ -204,16 +152,152 @@ const context = new AiAssistance.AccessibilityAgent.AccessibilityContext(mockReport); const domModel = target.model(SDK.DOMModel.DOMModel)!; - const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel)!; + const accessibilityModel = target.model(SDK.AccessibilityModel.AccessibilityModel)!; + const mockNode = sinon.createStubInstance(SDK.DOMModel.DOMNode); + mockNode.domModel.returns(domModel); + mockNode.id = 42 as Protocol.DOM.NodeId; + mockNode.backendNodeId.returns(100 as Protocol.DOM.BackendNodeId); + mockNode.attributes.returns([]); + const mockDocument = sinon.createStubInstance(SDK.DOMModel.DOMDocument); + mockDocument.documentURL = urlString`https://example.com`; + mockNode.ownerDocument = mockDocument; + + const mockSnapshot = sinon.createStubInstance(SDK.DOMModel.DOMNodeSnapshot);
Original Bug Report
SOP bypass via cross-origin information disclosure in DevTools AI Accessibility Agent
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 Accessibility Agent resolves DOM paths without verifying frame boundaries, potentially allowing it to access in-process cross-origin iframes. This could enable an attacker to exfiltrate sensitive data from another origin using prompt injection.
Affected files:
third_party/devtools-frontend/src/front_end/models/ai_assistance/agents/AccessibilityAgent.tsthird_party/devtools-frontend/src/front_end/panels/ai_assistance/components/AccessibilityAgentMarkdownRenderer.tsthird_party/devtools-frontend/src/front_end/models/ai_assistance/data_formatters/LighthouseFormatter.tsthird_party/blink/renderer/core/inspector/inspector_dom_agent.ccchrome/browser/devtools/features.cc
Estimated timestamp from git blame: 2026-03-19
Summary
A potential vulnerability in the DevTools AI Accessibility Agent allows for cross-origin information disclosure. The agent resolves Lighthouse-style DOM paths provided by the LLM without verifying if the resolved node resides within the expected frame boundary. By utilizing path segments that traverse into child frames, an attacker could potentially use prompt injection to trick the assistant into reading and exfiltrating data from in-process cross-origin iframes.
Root Cause Analysis
The issue exists in AccessibilityAgent.#resolvePathToNode in third_party/devtools-frontend/src/front_end/models/ai_assistance/agents/AccessibilityAgent.ts. It uses domModel.pushNodeByPathToFrontend(path) to resolve a node path:
// front_end/models/ai_assistance/agents/AccessibilityAgent.ts
async #resolvePathToNode(path: string): Promise<SDK.DOMModel.DOMNode|null> {
// ...
const nodeId = await domModel.pushNodeByPathToFrontend(path);
if (!nodeId) { return null; }
return domModel.nodeForId(nodeId); // Lacks verification of node.frameId()
}
The underlying Chrome DevTools Protocol (CDP) implementation in third_party/blink/renderer/core/inspector/inspector_dom_agent.cc supports traversing into child iframes via the d (or d,#document) path segment. If an iframe is in-process (a common scenario for same-site cross-origin frames), DocumentForFrameOwner returns the child Document object, allowing path resolution to cross origin boundaries.
Tools such as getStyles and getElementAccessibilityDetails consume the resolved node and return sensitive data—such as accessible names (which can include rendered text content), computed styles, and ARIA attributes—to the AI model. If an attacker controls the path argument through prompt injection, they can exfiltrate this data.
This appears to be an oversight, as similar logic in the same module (AccessibilityAgentMarkdownRenderer.ts) correctly enforces the frame boundary by checking node.frameId() !== this.mainFrameId before linkifying a node.
Potential Attack Vector
An attacker could potentially trigger this vulnerability using prompt injection:
- Preparation: Host a page at
https://attacker.example.comthat embeds a same-site cross-origin iframe fromhttps://victim.example.com. - Injection: Include a DOM element on the attacker page that intentionally fails a Lighthouse accessibility audit. Place a prompt injection payload in a field that Lighthouse extracts (e.g., a
titleoraria-label). The payload would instruct the LLM to callgetElementAccessibilityDetailson a path targeting the victim iframe (e.g.,1,HTML,1,BODY,0,IFRAME,d,#document,1,HTML,1,BODY,0,H1). - Trigger: A developer runs a Lighthouse audit on the attacker’s page and then uses the AI Accessibility Agent to investigate the report.
- Exfiltration: The LLM, following the injected instructions, fetches the cross-origin data and could be further instructed to exfiltrate it via the
executeJavaScripttool.
Impact
Successful exploitation could lead to a Same-Origin Policy (SOP) bypass, allowing the disclosure of sensitive rendered text, CSS properties, and ARIA attributes from cross-origin in-process iframes.
Suggested Fix
Modify AccessibilityAgent.#resolvePathToNode to verify that the resolved node belongs to the main frame of the page being inspected. The main frame ID can be retrieved from the ResourceTreeModel and compared against node.frameId().
const resourceTreeModel = domModel.target().model(SDK.ResourceTreeModel.ResourceTreeModel);
const mainFrameId = resourceTreeModel?.mainFrame?.id;
if (node.frameId() !== mainFrameId) {
return null;
}
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.