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 <[email protected]> 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 <[email protected]> Commit-Queue: Yuheng Huang <[email protected]> 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