CVE-2026-79190
Overview
Background
- Glic private API (`glicPrivate`)
- A privileged component-extension API in Chrome that lets an eligible signed-in surface invoke Glic (Gemini-in-Chrome) actions such as prompt invocation, addressed by a
document_id. - `externally_connectable`
- A manifest mechanism that lets specified web origins (here
*.google.com) message an extension; it is frame-scoped, so a matching subframe can reach the extension even when the top-level frame cannot. - `RenderFrameHost::IsInPrimaryMainFrame()`
- Returns true only when the frame is the primary (top-level, non-prerender, non-fenced) main frame of its tab, distinguishing it from subframes.
- `GetRfhForDocumentId` / `IsAccountConsistent`
GetRfhForDocumentIdresolves a caller-supplieddocument_idto aRenderFrameHost;IsAccountConsistentchecks that the frame’s account matches the profile’s identity.
Root Cause Analysis
Three Glic private API entry points in glic_private_api.cc resolved the caller’s document_id to a RenderFrameHost and then only checked that rfh was non-null and that IsAccountConsistent held, before proceeding. Neither check constrained the frame to be the tab’s primary main frame, so a subframe’s RenderFrameHost was accepted. Because externally_connectable is frame-scoped, a *.google.com subframe embedded inside a non-connectable top-level page (e.g. example.com) can reach the Glic component extension and, via background.ts, forward its own subframe documentId into glicPrivate.invoke. The API therefore acted on a request originating from a subframe that is not the authoritative top-level Glic surface, violating the intended “primary main frame only” authorization.
The fix introduces ValidateRenderFrameHost, which returns kLocalInvalidDocumentId when !rfh || !rfh->IsInPrimaryMainFrame() and kLocalAccountMismatch on account inconsistency, and routes all three call sites through it so subframe callers are rejected before any action runs. The shipped browser test embeds a gemini.google.com subframe under example.com, invokes from the subframe, and expects local-invalid-document-id.
ValidateRenderFrameHost’s IsInPrimaryMainFrame() gate closes it.Attack Path
- Embed connectable subframe
An attacker page at a non-connectable origin (
example.com) embeds a*.google.comsubframe (e.g.gemini.google.com), which, being frame-scoped-connectable, can reach the Glic component extension viachrome.runtime.sendMessage. - Forward subframe documentId
The subframe sends a
glicPrivate.invokemessage; the extension’sbackground.tsforwardssender.documentId— the subframe’s document — intochrome.glicPrivate.invoke. - Missing frame-type check
GetRfhForDocumentIdresolves the subframe’sRenderFrameHost, and the pre-patch code accepted it because it was non-null and (given a normally signed-in Glic user)IsAccountConsistentpassed, with noIsInPrimaryMainFrame()requirement. - Unauthorized invocation The Glic API action executed on behalf of a subframe that is not the primary Glic surface, allowing a non-top-level context to invoke the privileged API (inferred as to the concrete downstream effect; the patch demonstrates the authorization gap and its rejection, not a specific data-exfiltration primitive).
Impact Assessment
*.google.com subframe embedded within an arbitrary non-connectable page to invoke the privileged glicPrivate API, which should be reachable only from the primary main frame — an incorrect-authorization issue in the Extensions component. The advisory classifies it as an AuthorizationError (“Incorrect authorization in Extensions”) and does not report exploitation in the wild. Preconditions include a signed-in Glic-eligible user (so IsAccountConsistent passes) and the relevant Glic feature flags enabled; further escalation beyond unauthorized API invocation is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/extensions/api/glic_private/glic_private_api.cc |
modified | |
GlicSubframeInvokeBrowserTestchrome/browser/extensions/api/glic_private/glic_messaging_browsertest.cc |
modified | |
GlicSubframeInvokeBrowserTestchrome/browser/extensions/api/glic_private/glic_messaging_browsertest.cc |
modified | |
ifchrome/browser/extensions/api/glic_private/glic_messaging_browsertest.cc |
modified |
Files Changed
chrome/browser/extensions/api/glic_private/glic_messaging_browsertest.ccchrome/browser/extensions/api/glic_private/glic_private_api.cc
Audit Directions
- Frame-scoped connectable + top-level assumptionsAudit extension APIs reachable via
externally_connectablefor implicit assumptions that the caller is a top-level frame, since subframes at connectable origins can reach them. - document_id trustReview all
GetRfhForDocumentIdcallers to ensure the resolved frame is validated for frame type (primary main frame vs subframe/prerender/fenced) in addition to existence and account consistency. - Centralized vs scattered checksLook for privileged APIs that repeat the same validation inline at multiple call sites, where one site may omit a check; consolidating them (as
ValidateRenderFrameHostdoes) prevents divergent gaps.
Patch
From 7955fc07e9eee837c60349240875b64e0270ea99 Mon Sep 17 00:00:00 2001
From: Yuheng Huang <yuhengh@chromium.org>
Date: Tue, 07 Jul 2026 18:29:23 -0700
Subject: [PATCH] Only allow primrary main frame to call glic API
Limit glic API to be only callable in primary main frame.
Bug: 513172858
Change-Id: If2575ee181b6d7b697c5e098fbddac6b4baff179
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8028151
Reviewed-by: Justin DeWitt <dewittj@chromium.org>
Commit-Queue: Yuheng Huang <yuhengh@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1658433}
---
diff --git a/chrome/browser/extensions/api/glic_private/glic_private_api.cc b/chrome/browser/extensions/api/glic_private/glic_private_api.cc
index 83d39bd..6651ce6 100644
--- a/chrome/browser/extensions/api/glic_private/glic_private_api.cc
+++ b/chrome/browser/extensions/api/glic_private/glic_private_api.cc
@@ -358,6 +358,19 @@
document_id);
}
+std::optional<extensions::api::glic_private::ErrorCode> ValidateRenderFrameHost(
+ Profile* profile,
+ content::RenderFrameHost* rfh) {
+ if (!rfh || !rfh->IsInPrimaryMainFrame()) {
+ return api::glic_private::ErrorCode::kLocalInvalidDocumentId;
+ }
+ if (!IsAccountConsistent(IdentityManagerFactory::GetForProfile(profile),
+ *rfh)) {
+ return api::glic_private::ErrorCode::kLocalAccountMismatch;
+ }
+ return std::nullopt;
+}
+
} // namespace
GlicPrivateFunction::GlicPrivateFunction() = default;
@@ -385,15 +398,10 @@
Profile* profile = Profile::FromBrowserContext(browser_context());
content::RenderFrameHost* rfh = GetRfhForDocumentId(params->document_id);
- if (!rfh) {
- return RespondNow(Error(api::glic_private::ToString(
- api::glic_private::ErrorCode::kLocalInvalidDocumentId)));
- }
-
- if (!IsAccountConsistent(IdentityManagerFactory::GetForProfile(profile),
- *rfh)) {
- return RespondNow(Error(api::glic_private::ToString(
- api::glic_private::ErrorCode::kLocalAccountMismatch)));
+ std::optional<extensions::api::glic_private::ErrorCode> validation_error =
+ ValidateRenderFrameHost(profile, rfh);
+ if (validation_error.has_value()) {
+ return RespondNow(Error(api::glic_private::ToString(*validation_error)));
}
api::glic_private::InvocationSource invocation_source =
@@ -463,15 +471,10 @@
}
content::RenderFrameHost* rfh =
GetRfhForDocumentId(params->details.document_id);
- if (!rfh) {
- return RespondNow(GetPromptResponseValueAndLog(
- api::glic_private::ErrorCode::kLocalInvalidDocumentId));
- }
-
- if (!IsAccountConsistent(IdentityManagerFactory::GetForProfile(profile),
- *rfh)) {
- return RespondNow(GetPromptResponseValueAndLog(
- api::glic_private::ErrorCode::kLocalAccountMismatch));
+ std::optional<extensions::api::glic_private::ErrorCode> validation_error =
+ ValidateRenderFrameHost(profile, rfh);
+ if (validation_error.has_value()) {
+ return RespondNow(GetPromptResponseValueAndLog(*validation_error));
}
glic::mojom::InvocationSource source =
@@ -571,16 +574,10 @@
tabs::TabInterface* tab_interface = nullptr;
content::RenderFrameHost* rfh = GetRfhForDocumentId(document_id);
- if (!rfh) {
- Respond(GetPromptResponseValueAndLog(
- api::glic_private::ErrorCode::kLocalInvalidDocumentId));
- return;
- }
-
- if (!IsAccountConsistent(IdentityManagerFactory::GetForProfile(profile),
- *rfh)) {
- Respond(GetPromptResponseValueAndLog(
- api::glic_private::ErrorCode::kLocalAccountMismatch));
+ std::optional<extensions::api::glic_private::ErrorCode> validation_error =
+ ValidateRenderFrameHost(profile, rfh);
+ if (validation_error.has_value()) {
+ Respond(GetPromptResponseValueAndLog(*validation_error));
return;
}
diff --git a/chrome/browser/extensions/api/glic_private/glic_messaging_browsertest.cc b/chrome/browser/extensions/api/glic_private/glic_messaging_browsertest.cc
index 5a65407..a922c98 100644
--- a/chrome/browser/extensions/api/glic_private/glic_messaging_browsertest.cc
+++ b/chrome/browser/extensions/api/glic_private/glic_messaging_browsertest.cc
@@ -31,6 +31,7 @@
#include "components/guest_view/browser/test_guest_view_manager.h"
#include "components/keyed_service/content/browser_context_dependency_manager.h"
#include "components/signin/public/identity_manager/identity_test_utils.h"
+#include "content/public/browser/render_frame_host.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/url_loader_interceptor.h"
@@ -788,6 +789,146 @@
0, std::move(detached_tab), AddTabTypes::ADD_NONE);
}
+// -----------------------------------------------------------------------------
+// Verify that glicPrivate.invoke requires the calling frame to be the primary
+// main frame.
+//
+// If a connectable origin (e.g. *.google.com) subframe is embedded inside a
+// non-connectable top-level frame (e.g. example.com), the subframe can reach
+// the Glic component extension via frame-scoped externally_connectable.
+// However, calling glicPrivate.invoke from the subframe must be rejected by the
+// browser because it is not a primary main frame.
+// -----------------------------------------------------------------------------
+class GlicSubframeInvokeBrowserTest : public GlicPrivateApiTestBase {
+ public:
+ GlicSubframeInvokeBrowserTest() {
+ scoped_feature_list_.InitWithFeaturesAndParameters(
+ {{extensions_features::kApiGlicPrivate, {}},
+ {extensions_features::kApiGlicAccessFromGoogleWebpage, {}},
+ {features::kGlicActor,
+ {{"glic_actor_policy_control_exemption", "true"}}}},
+ {});
+ }
+
+ void SetUpBrowserContextKeyedServices(
+ content::BrowserContext* context) override {
+ GlicPrivateApiTestBase::SetUpBrowserContextKeyedServices(context);
+ // Bind the SigninClient to our TestURLLoaderFactory so that
+ // SetCookieAccounts() can intercept the ListAccounts request.
+ ChromeSigninClientFactory::GetInstance()->SetTestingFactory(
+ context, base::BindRepeating(&BuildChromeSigninClientWithURLLoader,
+ &test_url_loader_factory_));
+ }
+
+ void SetUpOnMainThread() override {
+ GlicPrivateApiTestBase::SetUpOnMainThread();
+ // GlicTestEnvironment has already signed in a primary account with the
+ // Glic capability. Additionally:
+ // - Put that account in the Gaia cookie jar so that
+ // IsAccountConsistent() passes for *.google.com URLs (this is the
+ // normal state for a signed-in Glic user; it is a precondition, not a
+ // security gate against this bug).
+ // - Enable automatic OAuth token issuance so the prompt EndpointFetcher
+ // completes (production users have a real refresh token).
+ auto* identity_manager = IdentityManagerFactory::GetForProfile(profile());
+ CoreAccountInfo primary =
+ identity_manager->GetPrimaryAccountInfo(signin::ConsentLevel::kSignin);
+ ASSERT_FALSE(primary.IsEmpty());
+ identity_adaptor_ =
+ std::make_unique<IdentityTestEnvironmentProfileAdaptor>(profile());
+ identity_adaptor_->identity_test_env()->SetTestURLLoaderFactory(
+ &test_url_loader_factory_);
+ identity_adaptor_->identity_test_env()->SetCookieAccounts(
+ {{primary.email, primary.gaia}});
+ identity_adaptor_->identity_test_env()->SetAutomaticIssueOfAccessTokens(
+ true);
+ }
+
+ void TearDownOnMainThread() override {
+ identity_adaptor_.reset();
+ GlicPrivateApiTestBase::TearDownOnMainThread();
+ }
+
+ private:
+ glic::GlicTestEnvironment glic_test_environment_;
+ base::test::ScopedFeatureList scoped_feature_list_;
+ network::TestURLLoaderFactory test_url_loader_factory_;
+ std::unique_ptr<IdentityTestEnvironmentProfileAdaptor> identity_adaptor_;
+};
+
+IN_PROC_BROWSER_TEST_F(GlicSubframeInvokeBrowserTest,
+ SubframeInNonConnectableOuterTab) {
+ // The Glic component extension must be loaded (kApiGlicPrivate enabled).
+ const Extension* extension =
+ ExtensionRegistry::Get(profile())->enabled_extensions().GetByID(
+ extension_misc::kGlicExtensionId);
+ ASSERT_TRUE(extension);
+
+ // Intercept the prompt-fetch endpoint so OnPromptRetrieved runs to
+ // completion (this is a server response, not a security check; in
+ // production a Glic-eligible signed-in user would receive a real prompt).
+ auto interceptor = CreateMockPromptResponseInterceptor("test prompt");
+
+ // 1) Top-level navigation to an origin that is not in
+ // externally_connectable.matches (example.com).
+ content::WebContents* tab = GetActiveWebContents();
+ ASSERT_TRUE(NavigateToURL(tab, GURL("https://example.com/iframe.html")));
+ content::RenderFrameHost* main_frame = tab->GetPrimaryMainFrame();
+ ASSERT_EQ("example.com", main_frame->GetLastCommittedURL().host());
+
+ // The top-level main frame CANNOT reach the Glic extension.
+ EXPECT_EQ("no_runtime",
+ content::EvalJs(main_frame,
+ "(chrome.runtime && chrome.runtime.sendMessage) "
+ "? 'has_runtime' : 'no_runtime'"));
+
+ // 2) Embed a *.google.com SUBFRAME inside the top-level tab.
+ ASSERT_TRUE(content::NavigateIframeToURL(
+ tab, "test", GURL("https://gemini.google.com/empty.html")));
+ content::RenderFrameHost* subframe = content::ChildFrameAt(main_frame, 0);
+ ASSERT_TRUE(subframe);
+ ASSERT_EQ("gemini.google.com", subframe->GetLastCommittedURL().host());
+ ASSERT_FALSE(subframe->IsInPrimaryMainFrame());
+
+ // The *.google.com SUBFRAME *can* reach the Glic extension
+ // (externally_connectable is frame-scoped, not top-level-scoped).
+ ASSERT_EQ("has_runtime",
+ content::EvalJs(subframe,
+ "(chrome.runtime && chrome.runtime.sendMessage) "
+ "? 'has_runtime' : 'no_runtime'"));
+
+ // 3) From the SUBFRAME, send glicPrivate.invoke to the component extension.
+ // background.ts forwards sender.documentId (the SUBFRAME's document) to
+ // chrome.glicPrivate.invoke.
+ std::string invoke_script = base::StringPrintf(
+ R"(
+ new Promise((resolve) => {
+ chrome.runtime.sendMessage(
+ '%s',
+ {type: 'glicPrivate.invoke',
+ args: {promptId: 'p1', invocationSource: 'universal-cart'}},
+ (response) => {
+ if (chrome.runtime.lastError) {
+ resolve('error: ' + chrome.runtime.lastError.message);
+ } else {
+ resolve('success');
+ }
+ });
+ })
+ )",
+ extension_misc::kGlicExtensionId);
+
+ std::string result = content::EvalJs(subframe, invoke_script).ExtractString();
+
+ // The browser rejects the request because the calling document is not the
+ // tab's primary main frame.
+ EXPECT_EQ("error: Uncaught Error: local-invalid-document-id", result);
+
+ // Verify that the top-level frame remains example.com.
+ EXPECT_EQ("example.com",
+ tab->GetPrimaryMainFrame()->GetLastCommittedURL().host());
+}
+
#endif // !BUILDFLAG(IS_ANDROID)
} // namespace extensions
Regression Test / PoC
diff --git a/chrome/browser/extensions/api/glic_private/glic_messaging_browsertest.cc b/chrome/browser/extensions/api/glic_private/glic_messaging_browsertest.cc
index 5a65407..a922c98 100644
--- a/chrome/browser/extensions/api/glic_private/glic_messaging_browsertest.cc
+++ b/chrome/browser/extensions/api/glic_private/glic_messaging_browsertest.cc
@@ -31,6 +31,7 @@
#include "components/guest_view/browser/test_guest_view_manager.h"
#include "components/keyed_service/content/browser_context_dependency_manager.h"
#include "components/signin/public/identity_manager/identity_test_utils.h"
+#include "content/public/browser/render_frame_host.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/url_loader_interceptor.h"
@@ -788,6 +789,146 @@
0, std::move(detached_tab), AddTabTypes::ADD_NONE);
}
+// -----------------------------------------------------------------------------
+// Verify that glicPrivate.invoke requires the calling frame to be the primary
+// main frame.
+//
+// If a connectable origin (e.g. *.google.com) subframe is embedded inside a
+// non-connectable top-level frame (e.g. example.com), the subframe can reach
+// the Glic component extension via frame-scoped externally_connectable.
+// However, calling glicPrivate.invoke from the subframe must be rejected by the
+// browser because it is not a primary main frame.
+// -----------------------------------------------------------------------------
+class GlicSubframeInvokeBrowserTest : public GlicPrivateApiTestBase {
+ public:
+ GlicSubframeInvokeBrowserTest() {
+ scoped_feature_list_.InitWithFeaturesAndParameters(
+ {{extensions_features::kApiGlicPrivate, {}},
+ {extensions_features::kApiGlicAccessFromGoogleWebpage, {}},
+ {features::kGlicActor,
+ {{"glic_actor_policy_control_exemption", "true"}}}},
+ {});
+ }
+
+ void SetUpBrowserContextKeyedServices(
+ content::BrowserContext* context) override {
+ GlicPrivateApiTestBase::SetUpBrowserContextKeyedServices(context);
+ // Bind the SigninClient to our TestURLLoaderFactory so that
+ // SetCookieAccounts() can intercept the ListAccounts request.
+ ChromeSigninClientFactory::GetInstance()->SetTestingFactory(
+ context, base::BindRepeating(&BuildChromeSigninClientWithURLLoader,
+ &test_url_loader_factory_));
+ }
+
+ void SetUpOnMainThread() override {
+ GlicPrivateApiTestBase::SetUpOnMainThread();
+ // GlicTestEnvironment has already signed in a primary account with the
+ // Glic capability. Additionally:
+ // - Put that account in the Gaia cookie jar so that
+ // IsAccountConsistent() passes for *.google.com URLs (this is the
+ // normal state for a signed-in Glic user; it is a precondition, not a
+ // security gate against this bug).
+ // - Enable automatic OAuth token issuance so the prompt EndpointFetcher
+ // completes (production users have a real refresh token).
+ auto* identity_manager = IdentityManagerFactory::GetForProfile(profile());
+ CoreAccountInfo primary =
+ identity_manager->GetPrimaryAccountInfo(signin::ConsentLevel::kSignin);
+ ASSERT_FALSE(primary.IsEmpty());
+ identity_adaptor_ =
+ std::make_unique<IdentityTestEnvironmentProfileAdaptor>(profile());
+ identity_adaptor_->identity_test_env()->SetTestURLLoaderFactory(
+ &test_url_loader_factory_);
+ identity_adaptor_->identity_test_env()->SetCookieAccounts(
+ {{primary.email, primary.gaia}});
+ identity_adaptor_->identity_test_env()->SetAutomaticIssueOfAccessTokens(
+ true);
+ }
+
+ void TearDownOnMainThread() override {
+ identity_adaptor_.reset();
+ GlicPrivateApiTestBase::TearDownOnMainThread();
+ }
+
+ private:
+ glic::GlicTestEnvironment glic_test_environment_;
+ base::test::ScopedFeatureList scoped_feature_list_;
+ network::TestURLLoaderFactory test_url_loader_factory_;
+ std::unique_ptr<IdentityTestEnvironmentProfileAdaptor> identity_adaptor_;
+};
+
+IN_PROC_BROWSER_TEST_F(GlicSubframeInvokeBrowserTest,
+ SubframeInNonConnectableOuterTab) {
+ // The Glic component extension must be loaded (kApiGlicPrivate enabled).
+ const Extension* extension =
+ ExtensionRegistry::Get(profile())->enabled_extensions().GetByID(
+ extension_misc::kGlicExtensionId);
+ ASSERT_TRUE(extension);
+
+ // Intercept the prompt-fetch endpoint so OnPromptRetrieved runs to
+ // completion (this is a server response, not a security check; in
+ // production a Glic-eligible signed-in user would receive a real prompt).
+ auto interceptor = CreateMockPromptResponseInterceptor("test prompt");
+
+ // 1) Top-level navigation to an origin that is not in
+ // externally_connectable.matches (example.com).
+ content::WebContents* tab = GetActiveWebContents();
+ ASSERT_TRUE(NavigateToURL(tab, GURL("https://example.com/iframe.html")));
+ content::RenderFrameHost* main_frame = tab->GetPrimaryMainFrame();
+ ASSERT_EQ("example.com", main_frame->GetLastCommittedURL().host());
+
+ // The top-level main frame CANNOT reach the Glic extension.
+ EXPECT_EQ("no_runtime",
+ content::EvalJs(main_frame,
+ "(chrome.runtime && chrome.runtime.sendMessage) "
+ "? 'has_runtime' : 'no_runtime'"));
+
+ // 2) Embed a *.google.com SUBFRAME inside the top-level tab.
+ ASSERT_TRUE(content::NavigateIframeToURL(
+ tab, "test", GURL("https://gemini.google.com/empty.html")));
+ content::RenderFrameHost* subframe = content::ChildFrameAt(main_frame, 0);
+ ASSERT_TRUE(subframe);
+ ASSERT_EQ("gemini.google.com", subframe->GetLastCommittedURL().host());
+ ASSERT_FALSE(subframe->IsInPrimaryMainFrame());
+
+ // The *.google.com SUBFRAME *can* reach the Glic extension
+ // (externally_connectable is frame-scoped, not top-level-scoped).
+ ASSERT_EQ("has_runtime",
+ content::EvalJs(subframe,
+ "(chrome.runtime && chrome.runtime.sendMessage) "
+ "? 'has_runtime' : 'no_runtime'"));
+
+ // 3) From the SUBFRAME, send glicPrivate.invoke to the component extension.
+ // background.ts forwards sender.documentId (the SUBFRAME's document) to
+ // chrome.glicPrivate.invoke.
+ std::string invoke_script = base::StringPrintf(
+ R"(
+ new Promise((resolve) => {
+ chrome.runtime.sendMessage(
+ '%s',
+ {type: 'glicPrivate.invoke',
+ args: {promptId: 'p1', invocationSource: 'universal-cart'}},
+ (response) => {
+ if (chrome.runtime.lastError) {
+ resolve('error: ' + chrome.runtime.lastError.message);
+ } else {
+ resolve('success');
+ }
+ });
+ })
+ )",
+ extension_misc::kGlicExtensionId);
+
+ std::string result = content::EvalJs(subframe, invoke_script).ExtractString();
+
+ // The browser rejects the request because the calling document is not the
+ // tab's primary main frame.
+ EXPECT_EQ("error: Uncaught Error: local-invalid-document-id", result);
+
+ // Verify that the top-level frame remains example.com.
+ EXPECT_EQ("example.com",
+ tab->GetPrimaryMainFrame()->GetLastCommittedURL().host());
+}
+
#endif // !BUILDFLAG(IS_ANDROID)
} // namespace extensions