Overview

Low
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in DevTools
DescriptionIncorrect authorization in DevTools
ComponentDevTools
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.
Tracker522413520
Fix commitb9f05e68bd58 (devtools/devtools-frontend) +89/-13
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-06-10 (per vendor advisory)
Fix landed2026-08-27 (public git)
Disclosed2026-09-22
Patch gap26 days — fix public in source before the advisory

Background

DevTools AI assistance
DevTools feature where an AiConversation sends page/context data to a backend model, gated so a conversation only handles data from a single locked origin.
Origin lock
the invariant that once an AiConversation establishes an origin, subsequent contexts must match it, enforced by AiAgent::isOriginAllowed.
`SecurityOrigin`
an SDK object modeling a page’s security origin, which may be opaque (e.g. about:, data:, blob:null, null) and therefore not equivalent to any real origin.
Opaque origin
a non-canonical origin that must never be treated as same-origin with another, so it cannot be used to unlock or match a locked conversation.

Root Cause Analysis

The origin used to lock and gate an AiConversation was derived from a file’s URL string via extractContextOrigin(this.getURL()), and a FileContext whose file URL did not reflect its real security origin could present a spoofed origin string. Because the comparison path (areOriginsEquivalent) operated on raw strings and lacked a robust opaque-origin check, a crafted or mismatched URL origin could satisfy isOriginAllowed against an established origin, violating the invariant that context data must come only from the conversation’s locked origin.

The fix introduces SecurityOrigin::isOpaque and threads a real SDK.SecurityOrigin.SecurityOrigin object (preferring the project’s securityOrigin over the file URL) through getOrigin, isOriginAllowed, and #origin, so authorization now uses isSameOriginWith on canonical origins and rejects opaque ones outright. isSameOriginWith was also hardened to return false whenever either side is opaque, closing the case where two spoofed/opaque origins would previously compare as equivalent. This works because a file can no longer masquerade as a different or non-canonical origin to slip context into a conversation locked to a legitimate origin.

Key insight
The core mistake was authorizing AI-conversation context by comparing an origin string taken from a file’s (spoofable) URL instead of the file’s actual SecurityOrigin, so an opaque or mismatched URL origin could bypass the origin lock. The fix prioritizes the project’s real securityOrigin, compares canonical SecurityOrigin objects with isSameOriginWith, and rejects opaque origins via the new isOpaque check.

Attack Path

  1. Open a conversation locked to a target origin An AiConversation establishes #origin from a legitimate page/context, locking the conversation to that origin.
  2. Introduce a spoofed FileContext The attacker supplies a FileContext whose file URL origin does not match its true security origin, so extractContextOrigin(getURL()) yields an origin string that differs from the file’s actual SecurityOrigin.
  3. Pass the origin-lock check AiAgent::isOriginAllowed compares the spoofed string against the established origin using areOriginsEquivalent, and with weak/opaque handling the mismatched or opaque origin is accepted as equivalent.
  4. Cross-origin context inclusion Data from the spoofed file context is authorized into the conversation locked to the target origin, and origins may also be forwarded to storage handling via resolveTargetOrigins. (inferred)
  5. Exfiltrate cross-origin data The model backend receives context/data attributed to an origin the file does not legitimately belong to, breaking the per-origin isolation guarantee. (inferred)

Impact Assessment

An attacker gains incorrect authorization in the DevTools AI-assistance flow, allowing a file/context whose URL origin is spoofed or opaque to be accepted by an AiConversation that is locked to a different, legitimate origin (a logic error in the renderer/DevTools-frontend context). The vendor advisory rates this low severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3. A context with a mismatched or opaque origin is presented; the advisory states only “Incorrect authorization in DevTools” and does not report exploitation in the wild or assign a severity here. Any escalation to concrete cross-origin data exfiltration beyond the authorization bypass the patch demonstrates is (inferred).

Changed Functions

FunctionChangeNotes
if
front_end/core/sdk/SecurityOrigin.ts
modified
if
front_end/models/ai_assistance/AiConversation.ts
modified
if
front_end/models/ai_assistance/agents/AiAgent.ts
modified
if
front_end/models/ai_assistance/agents/StorageAgent.ts
modified

Files Changed

  • front_end/core/sdk/SecurityOrigin.ts
  • front_end/models/ai_assistance/AiConversation.ts
  • front_end/models/ai_assistance/agents/AiAgent.ts
  • front_end/models/ai_assistance/agents/StorageAgent.ts
  • front_end/models/ai_assistance/agents/StylingAgent.ts
  • front_end/models/ai_assistance/contexts/FileContext.test.ts
  • front_end/models/ai_assistance/contexts/FileContext.ts

Audit Directions

  • Origin-string comparison
    Audit any authorization or isolation check that compares origins as strings (e.g. areOriginsEquivalent, extractContextOrigin) rather than canonical SecurityOrigin objects, since URL-derived strings can be spoofed.
  • Opaque-origin handling
    Look for code that treats empty, null, data:, about:, or blob: origins as ordinary origins, as these should never satisfy same-origin or unlock a locked context.
  • Context/URL origin mismatch
    Review other ConversationContext/context sources (files, projects, storage) that derive origin from a URL instead of the object’s true securityOrigin, where the two can diverge.
From b9f05e68bd58670cb9c6e03d82fa673113314493 Mon Sep 17 00:00:00 2001
From: Danil Somsikov <[email protected]>
Date: Thu, 27 Aug 2026 05:45:08 -0700
Subject: [PATCH] Use project securityOrigin for FileContext origin if available

Updates FileContext to prioritize the project's securityOrigin over the file's URL origin when determining the context's origin. This helps prevent origin spoofing where a file's URL might not match its actual security origin. A unit test has been added to verify this fallback behavior.

Bug: 522413520
Change-Id: I1f51d0a60fb232ba937df67f0cfe98d777ce442e
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/8297159
Reviewed-by: Simon Zünd <[email protected]>
Commit-Queue: Danil Somsikov <[email protected]>
Auto-Submit: Danil Somsikov <[email protected]>
---
diff --git a/front_end/core/sdk/SecurityOrigin.ts b/front_end/core/sdk/SecurityOrigin.ts
index 9796a13..4b34d07 100644
--- a/front_end/core/sdk/SecurityOrigin.ts
+++ b/front_end/core/sdk/SecurityOrigin.ts
@@ -23,9 +23,25 @@
     if (this.#opaqueUuid || other.#opaqueUuid) {
       return this.#opaqueUuid === other.#opaqueUuid;
     }
+    if (this.isOpaque() || other.isOpaque()) {
+      return false;
+    }
     return this.#urlOrigin === other.#urlOrigin && this.#urlOrigin !== undefined;
   }
 
+  isOpaque(): boolean {
+    if (this.#opaqueUuid !== undefined) {
+      return true;
+    }
+    if (this.#urlOrigin) {
+      const lower = this.#urlOrigin.toLowerCase();
+      return lower === '' || lower === 'null' || lower === 'data:' || lower.startsWith('about') ||
+          lower.startsWith('blob:about') || lower.startsWith('blob:data') || lower.startsWith('blob:null') ||
+          lower.startsWith('detached') || lower.startsWith('undefined');
+    }
+    return false;
+  }
+
   siteId(): string {
     return this.#opaqueUuid ?? (this.#urlOrigin ?? 'unknown-origin');
   }
diff --git a/front_end/models/ai_assistance/AiConversation.ts b/front_end/models/ai_assistance/AiConversation.ts
index 9e3a009..9f1e829 100644
--- a/front_end/models/ai_assistance/AiConversation.ts
+++ b/front_end/models/ai_assistance/AiConversation.ts
@@ -107,7 +107,7 @@
 
   #aidaClient: Host.AidaClient.AidaClient;
   #changeManager: ChangeManager|undefined;
-  #origin?: string;
+  #origin?: string|SDK.SecurityOrigin.SecurityOrigin;
   #navigationOccurredDuringRun = false;
 
   #contexts: Array<ConversationContext<unknown>> = [];
@@ -181,7 +181,7 @@
     return this.history.length === 0;
   }
 
-  #setOriginIfEmpty(newOrigin: string|undefined): void {
+  #setOriginIfEmpty(newOrigin: string|SDK.SecurityOrigin.SecurityOrigin|undefined): void {
     if (!this.#origin) {
       this.#origin = newOrigin;
     }
@@ -544,7 +544,7 @@
   }
 
   get origin(): string|undefined {
-    return this.#origin;
+    return this.#origin instanceof SDK.SecurityOrigin.SecurityOrigin ? this.#origin.siteId() : this.#origin;
   }
 
   get type(): ConversationType {
@@ -556,11 +556,11 @@
       return {blocked: true};
     }
     if (this.#origin) {
-      return {origin: this.#origin};
+      return {origin: this.origin};
     }
     this.#origin = getPrimaryPageOrigin(this.#targetManager);
 
-    return {origin: this.#origin};
+    return {origin: this.origin};
   };
 }
 
diff --git a/front_end/models/ai_assistance/agents/AiAgent.ts b/front_end/models/ai_assistance/agents/AiAgent.ts
index a7d7758..417daa1 100644
--- a/front_end/models/ai_assistance/agents/AiAgent.ts
+++ b/front_end/models/ai_assistance/agents/AiAgent.ts
@@ -205,7 +205,7 @@
     return true;
   }
 
-  getOrigin(): string {
+  getOrigin(): string|SDK.SecurityOrigin.SecurityOrigin {
     return extractContextOrigin(this.getURL());
   }
 
@@ -221,15 +221,31 @@
    * @param establishedOrigin The origin that the current conversation is locked to.
    * If undefined, the conversation has not yet been locked to an origin.
    */
-  isOriginAllowed(establishedOrigin: string|undefined): boolean {
+  isOriginAllowed(establishedOrigin: string|SDK.SecurityOrigin.SecurityOrigin|undefined): boolean {
     const origin = this.getOrigin();
+
+    if (origin instanceof SDK.SecurityOrigin.SecurityOrigin) {
+      if (origin.isOpaque()) {
+        return false;
+      }
+      if (!establishedOrigin) {
+        return true;
+      }
+      const established = establishedOrigin instanceof SDK.SecurityOrigin.SecurityOrigin ?
+          establishedOrigin :
+          SDK.SecurityOrigin.SecurityOrigin.create(establishedOrigin);
+      return origin.isSameOriginWith(established);
+    }
+
     // If no origin is established yet, this context will be the one to lock the conversation.
     // Opaque origins are never allowed to be used as context.
     if (!establishedOrigin) {
       return !isOpaqueOrigin(origin);
     }
     // Only allow data that matches the origin the conversation is already locked to.
-    return areOriginsEquivalent(origin, establishedOrigin);
+    const establishedString =
+        establishedOrigin instanceof SDK.SecurityOrigin.SecurityOrigin ? establishedOrigin.siteId() : establishedOrigin;
+    return areOriginsEquivalent(origin, establishedString);
   }
 
   /**
diff --git a/front_end/models/ai_assistance/agents/StorageAgent.ts b/front_end/models/ai_assistance/agents/StorageAgent.ts
index 4acba25..1cbb1a2 100644
--- a/front_end/models/ai_assistance/agents/StorageAgent.ts
+++ b/front_end/models/ai_assistance/agents/StorageAgent.ts
@@ -79,7 +79,9 @@
 
 function resolveTargetOrigins(context?: ConversationContext<StorageItem>, origins?: string[]): string[] {
   const primaryOrigin = context?.getOrigin();
-  const rawList = (origins && origins.length > 0) ? origins : (primaryOrigin ? [primaryOrigin] : []);
+  const primaryString =
+      primaryOrigin instanceof SDK.SecurityOrigin.SecurityOrigin ? primaryOrigin.siteId() : primaryOrigin;
+  const rawList = (origins && origins.length > 0) ? origins : (primaryString ? [primaryString] : []);
   const uniqueOrigins = Array.from(new Set(rawList));
   return uniqueOrigins.slice(0, MAX_TARGET_ORIGINS);
 }
@@ -660,8 +662,8 @@
  * @param storageKey Optional. If specified, resolves only the partition exactly matching this unique key, bypassing origin comparison.
  */
 
-export async function getCookiesForDomain(
-    target: SDK.Target.Target, origin: string): Promise<SDK.Cookie.Cookie[]|null> {
+export async function getCookiesForDomain(target: SDK.Target.Target,
+                                          origin: string): Promise<SDK.Cookie.Cookie[]|null> {
   const cookieModel = target.model(SDK.CookieModel.CookieModel);
   if (!cookieModel) {
     return null;
diff --git a/front_end/models/ai_assistance/agents/StylingAgent.ts b/front_end/models/ai_assistance/agents/StylingAgent.ts
index 87db174..331fb16 100644
--- a/front_end/models/ai_assistance/agents/StylingAgent.ts
+++ b/front_end/models/ai_assistance/agents/StylingAgent.ts
@@ -4,7 +4,7 @@
 
 import * as Host from '../../../core/host/host.js';
 import * as Root from '../../../core/root/root.js';
-import type * as SDK from '../../../core/sdk/sdk.js';
+import * as SDK from '../../../core/sdk/sdk.js';
 import {ChangeManager} from '../ChangeManager.js';
 import {ExtensionScope} from '../ExtensionScope.js';
 import {AI_ASSISTANCE_CSS_CLASS_NAME} from '../injected.js';
@@ -155,7 +155,10 @@
         return await getStylesTool.handler(args, {
           conversationContext: context,
           getTarget: () => this.targetManager.primaryPageTarget() ?? context.getItem().domModel().target(),
-          getEstablishedOrigin: () => context.getOrigin(),
+          getEstablishedOrigin: () => {
+            const origin = context.getOrigin();
+            return origin instanceof SDK.SecurityOrigin.SecurityOrigin ? origin.siteId() : origin;
+          },
         });
       },
     });
diff --git a/front_end/models/ai_assistance/contexts/FileContext.test.ts b/front_end/models/ai_assistance/contexts/FileContext.test.ts
index b73c735..7eab0a6 100644
--- a/front_end/models/ai_assistance/contexts/FileContext.test.ts
+++ b/front_end/models/ai_assistance/contexts/FileContext.test.ts
@@ -6,6 +6,7 @@
 import sinon from 'sinon';
 
 import * as Platform from '../../../core/platform/platform.js';
+import * as SDK from '../../../core/sdk/sdk.js';
 import {createUISourceCode} from '../../../testing/AiAssistanceHelpers.js';
 import {setupSettingsHooks} from '../../../testing/SettingsHelpers.js';
 import {TestUniverse} from '../../../testing/TestUniverse.js';
@@ -86,4 +87,36 @@
 
     sinon.assert.calledOnce(requestContentDataSpy);
   });
+
+  it('should prioritize project securityOrigin over spoofable URL origin if available', async () => {
+    const uiSourceCode = await createUISourceCode({
+      url: urlString`https://trusted-site.com/spoofed.js`,
+      content: 'console.log("spoof");',
+    });
+
+    sinon.stub(uiSourceCode.project(), 'securityOrigin')
+        .returns(SDK.SecurityOrigin.SecurityOrigin.create('http://attacker.com'));
+
+    const context = new AiAssistance.FileContext.FileContext(uiSourceCode, universe.debuggerWorkspaceBinding);
+
+    const origin = context.getOrigin();
+    const siteId = origin instanceof SDK.SecurityOrigin.SecurityOrigin ? origin.siteId() : origin;
+    assert.strictEqual(siteId, 'http://attacker.com');
+  });
+
+  it('should fall back to URL origin if project securityOrigin is absent', async () => {
+    const uiSourceCode = await createUISourceCode({
+      url: urlString`https://trusted-site.com/script.js`,
+      content: 'console.log("no spoof");',
+    });
+
+    // Mock project securityOrigin natively returning null (i.e. regular first-party file bucket)
+    sinon.stub(uiSourceCode.project(), 'securityOrigin').returns(null);
+
+    const context = new AiAssistance.FileContext.FileContext(uiSourceCode, universe.debuggerWorkspaceBinding);
+
+    const origin = context.getOrigin();
+    const siteId = origin instanceof SDK.SecurityOrigin.SecurityOrigin ? origin.siteId() : origin;
+    assert.strictEqual(siteId, 'https://trusted-site.com');
+  });
 });
diff --git a/front_end/models/ai_assistance/contexts/FileContext.ts b/front_end/models/ai_assistance/contexts/FileContext.ts
index 50e2211..c0a0fbf 100644
--- a/front_end/models/ai_assistance/contexts/FileContext.ts
+++ b/front_end/models/ai_assistance/contexts/FileContext.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 type * as SDK from '../../../core/sdk/sdk.js';
 import type * as Bindings from '../../bindings/bindings.js';
 import type * as Workspace from '../../workspace/workspace.js';
 import {type ContextDetail, ConversationContext} from '../agents/AiAgent.js';
@@ -18,6 +19,11 @@
     this.#debuggerWorkspaceBinding = debuggerWorkspaceBinding;
   }
 
+  override getOrigin(): string|SDK.SecurityOrigin.SecurityOrigin {
+    const fallbackOrigin = super.getOrigin();
+    return this.#file.project()?.securityOrigin?.() ?? fallbackOrigin;
+  }
+
   override getURL(): string {
     return this.#file.url();
   }
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.