Medium watch CVSS 6.5 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in WebProtect
DescriptionIncorrect authorization in WebProtect
ComponentWebProtect
Sub-componentDataControls
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.
Tracker503847023
Fix commit8dc8ff8976a1 (chromium/src) +55/-39
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-17 (per vendor advisory)
Fix landed2026-07-08 (public git)
Disclosed2026-08-25
Patch gap48 days — fix public in source before the advisory

Background

Data Controls
An enterprise-policy feature (components/enterprise/data_controls) that lets admins block or warn on data actions such as copy/paste between sources and destinations, based on attributes like URL, incognito, and other_profile.
`ActionSource` / `ActionDestination`
Structs in action_context.cc that describe each end of a data action: a browser tab (url, incognito, other_profile), the OS clipboard (os_clipboard), Gemini in Chrome (gemini_in_chrome), or, on ChromeOS only, a separate component.
`CanBeEvaluated()` / `IsTriggered()`
Attribute-condition methods (exercised in attributes_condition_unittest.cc). A condition is considered only when its source or destination context is non-empty; an empty context means the rule is skipped.
Initial empty document
A tab or popup that has not yet committed a navigation (e.g. one opened via a javascript: URI, per the commit message), so its url is empty even though it belongs to an incognito or other-profile window.

Root Cause Analysis

Before the patch, ActionSource::empty() and ActionDestination::empty() returned true whenever url.is_empty() && !os_clipboard && !gemini_in_chrome (plus component == kUnknownComponent on ChromeOS), ignoring incognito and other_profile. A tab with no committed URL that still had incognito or other_profile set was therefore classified as “empty”. Per the removed test expectations, attribute conditions then returned CanBeEvaluated() == false for it, so rules keyed on incognito/other_profile were silently skipped. This broke the documented contract that a tab context is represented by any of its tab fields.

The fix adds !incognito && !other_profile to both empty() predicates, so empty() returns true in fewer cases. Such contexts are now evaluated, and rules like {"incognito": true} trigger, as the updated IsTriggered assertions show. The effect is that policy enforcement is tightened: the check was not relaxed.

Key insight
The “empty context” short-circuit treated a URL-less tab as having no identity at all, even when it carried the attributes a policy rule matched on. Adding incognito and other_profile to empty() stops those tabs from bypassing evaluation.

Attack Path

  1. Enterprise policy in force An admin configures a Data Controls rule keyed on incognito or other_profile (e.g. block pasting into incognito tabs, or copying between profiles). Without such a policy, the bug is not reachable.
  2. Tab with no committed URL A user or page creates a tab that stays on its initial empty document, e.g. a popup opened via a javascript: URI as the commit message describes, inside an incognito or other-profile window.
  3. Context classified as empty For an action whose source or destination is that tab, the ActionSource/ActionDestination has an empty url but incognito/other_profile set. The old empty() returned true, so CanBeEvaluated() was false and the rule was skipped.
  4. Policy bypass The clipboard or data action goes ahead without the configured block or warn verdict, so data can move into or out of incognito or across profiles despite the admin’s policy (inferred, based on the commit message’s “rules … were skipped”).

Impact Assessment

The result is an enterprise data-loss-prevention policy bypass in the browser process. It is not a memory-safety issue, and it only affects managed deployments that use Data Controls rules on incognito or other_profile. The advisory describes it as “Incorrect authorization in WebProtect”. Exploiting it needs the user’s own data action (e.g. copy/paste) involving a tab with no committed URL, so the realistic attacker is an insider or a page cooperating with a user trying to exfiltrate data (inferred). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 6.5.

Changed Functions

FunctionChangeNotes
TEST
components/enterprise/data_controls/core/browser/conditions/attributes_condition_unittest.cc
modified

Files Changed

  • components/enterprise/data_controls/core/browser/action_context.cc
  • components/enterprise/data_controls/core/browser/conditions/attributes_condition_unittest.cc

Audit Directions

  • "Empty" or "unknown" short-circuits in policy engines
    Look for predicates that skip enforcement when a primary key (URL, origin) is missing even though secondary attributes are present. Fail-open defaults on missing data are a common way to bypass policy.
  • Initial empty document and `about:blank` edge cases
    Check DLP, Safe Browsing and enterprise connectors for how they handle tabs that have not committed a navigation (popups, javascript: and about:blank opens), where GetLastCommittedURL() is empty but the profile or context is known.
  • Struct fields added without updating helpers
    When new fields are added to context structs (incognito, other_profile, component), audit all empty()/operator==/serialization helpers to confirm they account for every field.
From 8dc8ff8976a12c88b735f650fa536b192f8fd7eb Mon Sep 17 00:00:00 2001
From: Dominique Fauteux-Chapleau <domfc@chromium.org>
Date: Wed, 08 Jul 2026 14:03:53 -0700
Subject: [PATCH] [DataControls] Evaluate rules for tabs with no committed URL

ActionSource::empty() and ActionDestination::empty() only checked url,
os_clipboard and gemini_in_chrome, ignoring incognito and other_profile.
A tab on the initial empty document (e.g. a popup opened via a
javascript: URI) has an empty URL but can still have
incognito/other_profile set, so rules keyed on those attributes were
skipped for such tabs.

empty() now matches its documented contract and returns false when any
tab-related field is non-default. Existing AttributesConditionTest
expectations are updated to match.

Fixed: 503847023
Change-Id: I35a83737332d7c9ef08013ee49e544ea43161955
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8026769
Commit-Queue: Shanthanu Bhardwaj <xanth@google.com>
Reviewed-by: Shanthanu Bhardwaj <xanth@google.com>
Cr-Commit-Position: refs/heads/main@{#1659051}
---
diff --git a/components/enterprise/data_controls/core/browser/action_context.cc b/components/enterprise/data_controls/core/browser/action_context.cc
index 16a02593..ddb6dd3a 100644
--- a/components/enterprise/data_controls/core/browser/action_context.cc
+++ b/components/enterprise/data_controls/core/browser/action_context.cc
@@ -8,27 +8,29 @@
 
 bool ActionSource::empty() const {
   // `ActionSource` should represent either:
-  // - A browser tab with the `url` field, and possible `incognito` and/or
-  //   `other_profile` set to true.
+  // - A browser tab with the `url`, `incognito` and/or `other_profile` fields.
+  //   The `url` field can be empty if the tab has no committed navigation.
   // - The OS clipboard with `os_clipboard` set to true.
   // - The integrated Gemini browser agent (Glic) with `gemini_in_chrome` set
   //   to true.
-  return url.is_empty() && !os_clipboard && !gemini_in_chrome;
+  return url.is_empty() && !incognito && !other_profile && !os_clipboard &&
+         !gemini_in_chrome;
 }
 
 bool ActionDestination::empty() const {
   // `ActionDestination` should represent either:
-  // - A browser tab with the `url` field, and possible `incognito` and/or
-  //   `other_profile` set to true.
+  // - A browser tab with the `url`, `incognito` and/or `other_profile` fields.
+  //   The `url` field can be empty if the tab has no committed navigation.
   // - The OS clipboard with `os_clipboard` set to true.
   // - The integrated Gemini browser agent (Glic) with `gemini_in_chrome` set
   //   to true.
   // - A separate application represented by `component` (CrOS-only).
 #if BUILDFLAG(IS_CHROMEOS)
-  return url.is_empty() && !os_clipboard && !gemini_in_chrome &&
-         component == Component::kUnknownComponent;
+  return url.is_empty() && !incognito && !other_profile && !os_clipboard &&
+         !gemini_in_chrome && component == Component::kUnknownComponent;
 #else
-  return url.is_empty() && !os_clipboard && !gemini_in_chrome;
+  return url.is_empty() && !incognito && !other_profile && !os_clipboard &&
+         !gemini_in_chrome;
 #endif  // BUILDFLAG(IS_CHROMEOS)
 }
 
diff --git a/components/enterprise/data_controls/core/browser/conditions/attributes_condition_unittest.cc b/components/enterprise/data_controls/core/browser/conditions/attributes_condition_unittest.cc
index 82c73c3..e829898 100644
--- a/components/enterprise/data_controls/core/browser/conditions/attributes_condition_unittest.cc
+++ b/components/enterprise/data_controls/core/browser/conditions/attributes_condition_unittest.cc
@@ -305,14 +305,15 @@
 #endif  // BUILDFLAG(IS_CHROMEOS)
 
 TEST(AttributesConditionTest, IncognitoDestination) {
-  // A context with only "incognito" and no URL shouldn't be evaluated.
+  // A destination tab can have `incognito` set even without a committed URL
+  // (e.g. the initial empty document), so a context with only `incognito` set
+  // should still be evaluated.
   auto incognito_dst = DestinationAttributesCondition::Create(CreateDict(R"(
       {
         "incognito": true,
       })"));
   ASSERT_TRUE(incognito_dst);
-  ASSERT_FALSE(
-      incognito_dst->CanBeEvaluated({.destination = {.incognito = true}}));
+  ASSERT_TRUE(incognito_dst->IsTriggered({.destination = {.incognito = true}}));
   ASSERT_FALSE(
       incognito_dst->CanBeEvaluated({.destination = {.incognito = false}}));
   ASSERT_FALSE(incognito_dst->CanBeEvaluated({.source = {.incognito = true}}));
@@ -323,9 +324,11 @@
         "incognito": false,
       })"));
   ASSERT_TRUE(non_incognito_dst);
-  ASSERT_FALSE(
+  ASSERT_TRUE(
       non_incognito_dst->CanBeEvaluated({.destination = {.incognito = true}}));
   ASSERT_FALSE(
+      non_incognito_dst->IsTriggered({.destination = {.incognito = true}}));
+  ASSERT_FALSE(
       non_incognito_dst->CanBeEvaluated({.destination = {.incognito = false}}));
   ASSERT_FALSE(
       non_incognito_dst->CanBeEvaluated({.source = {.incognito = true}}));
@@ -334,7 +337,9 @@
 }
 
 TEST(AttributesConditionTest, IncognitoSource) {
-  // A context with only "incognito" and no URL shouldn't be evaluated.
+  // A source tab can have `incognito` set even without a committed URL (e.g.
+  // the initial empty document), so a context with only `incognito` set should
+  // still be evaluated.
   auto incognito_src = SourceAttributesCondition::Create(CreateDict(R"(
       {
         "incognito": true,
@@ -344,7 +349,7 @@
       incognito_src->CanBeEvaluated({.destination = {.incognito = true}}));
   ASSERT_FALSE(
       incognito_src->CanBeEvaluated({.destination = {.incognito = false}}));
-  ASSERT_FALSE(incognito_src->CanBeEvaluated({.source = {.incognito = true}}));
+  ASSERT_TRUE(incognito_src->IsTriggered({.source = {.incognito = true}}));
   ASSERT_FALSE(incognito_src->CanBeEvaluated({.source = {.incognito = false}}));
 
   auto non_incognito_src = SourceAttributesCondition::Create(CreateDict(R"(
@@ -356,8 +361,9 @@
       non_incognito_src->CanBeEvaluated({.destination = {.incognito = true}}));
   ASSERT_FALSE(
       non_incognito_src->CanBeEvaluated({.destination = {.incognito = false}}));
-  ASSERT_FALSE(
+  ASSERT_TRUE(
       non_incognito_src->CanBeEvaluated({.source = {.incognito = true}}));
+  ASSERT_FALSE(non_incognito_src->IsTriggered({.source = {.incognito = true}}));
   ASSERT_FALSE(
       non_incognito_src->CanBeEvaluated({.source = {.incognito = false}}));
 }
@@ -381,7 +387,7 @@
       {.destination = {.url = GURL(kChromiumUrl), .incognito = false}}));
   ASSERT_FALSE(url_and_incognito->IsTriggered(
       {.destination = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(
+  ASSERT_TRUE(
       url_and_incognito->CanBeEvaluated({.destination = {.incognito = true}}));
   ASSERT_FALSE(
       url_and_incognito->CanBeEvaluated({.destination = {.incognito = false}}));
@@ -405,7 +411,7 @@
       {.destination = {.url = GURL(kChromiumUrl), .incognito = false}}));
   ASSERT_FALSE(url_and_not_incognito->IsTriggered(
       {.destination = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(url_and_not_incognito->CanBeEvaluated(
+  ASSERT_TRUE(url_and_not_incognito->CanBeEvaluated(
       {.destination = {.incognito = true}}));
   ASSERT_FALSE(url_and_not_incognito->CanBeEvaluated(
       {.destination = {.incognito = false}}));
@@ -430,7 +436,7 @@
       {.source = {.url = GURL(kChromiumUrl), .incognito = false}}));
   ASSERT_FALSE(
       url_and_incognito->IsTriggered({.source = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(
+  ASSERT_TRUE(
       url_and_incognito->CanBeEvaluated({.source = {.incognito = true}}));
   ASSERT_FALSE(
       url_and_incognito->CanBeEvaluated({.source = {.incognito = false}}));
@@ -453,7 +459,7 @@
       {.source = {.url = GURL(kChromiumUrl), .incognito = false}}));
   ASSERT_FALSE(url_and_not_incognito->IsTriggered(
       {.source = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(
+  ASSERT_TRUE(
       url_and_not_incognito->CanBeEvaluated({.source = {.incognito = true}}));
   ASSERT_FALSE(
       url_and_not_incognito->CanBeEvaluated({.source = {.incognito = false}}));
@@ -472,7 +478,7 @@
   ASSERT_TRUE(any_url->IsTriggered(
       {.destination = {.url = GURL(kGoogleUrl), .incognito = false}}));
   ASSERT_TRUE(any_url->IsTriggered({.destination = {.url = GURL(kGoogleUrl)}}));
-  ASSERT_FALSE(any_url->CanBeEvaluated({.destination = {.incognito = true}}));
+  ASSERT_TRUE(any_url->CanBeEvaluated({.destination = {.incognito = true}}));
   ASSERT_FALSE(any_url->CanBeEvaluated({.destination = {.incognito = false}}));
   ASSERT_FALSE(any_url->CanBeEvaluated({.destination = {}}));
 }
@@ -490,20 +496,22 @@
   ASSERT_TRUE(any_url->IsTriggered(
       {.source = {.url = GURL(kGoogleUrl), .incognito = false}}));
   ASSERT_TRUE(any_url->IsTriggered({.source = {.url = GURL(kGoogleUrl)}}));
-  ASSERT_FALSE(any_url->CanBeEvaluated({.source = {.incognito = true}}));
+  ASSERT_TRUE(any_url->CanBeEvaluated({.source = {.incognito = true}}));
   ASSERT_FALSE(any_url->CanBeEvaluated({.source = {.incognito = false}}));
   ASSERT_FALSE(any_url->CanBeEvaluated({.source = {}}));
 }
 
 TEST(AttributesConditionTest, OtherProfileDestination) {
-  // A context with only "other_profile" and no URL shouldn't be evaluated.
+  // A destination tab can have `other_profile` set even without a committed
+  // URL (e.g. the initial empty document), so a context with only
+  // `other_profile` set should still be evaluated.
   auto other_profile_dst = DestinationAttributesCondition::Create(CreateDict(R"(
       {
         "other_profile": true,
       })"));
   ASSERT_TRUE(other_profile_dst);
-  ASSERT_FALSE(other_profile_dst->CanBeEvaluated(
-      {.destination = {.other_profile = true}}));
+  ASSERT_TRUE(
+      other_profile_dst->IsTriggered({.destination = {.other_profile = true}}));
   ASSERT_FALSE(other_profile_dst->CanBeEvaluated(
       {.destination = {.other_profile = false}}));
   ASSERT_FALSE(
@@ -517,7 +525,9 @@
         "other_profile": false,
       })"));
   ASSERT_TRUE(non_other_profile_dst);
-  ASSERT_FALSE(non_other_profile_dst->CanBeEvaluated(
+  ASSERT_TRUE(non_other_profile_dst->CanBeEvaluated(
+      {.destination = {.other_profile = true}}));
+  ASSERT_FALSE(non_other_profile_dst->IsTriggered(
       {.destination = {.other_profile = true}}));
   ASSERT_FALSE(non_other_profile_dst->CanBeEvaluated(
       {.destination = {.other_profile = false}}));
@@ -528,7 +538,9 @@
 }
 
 TEST(AttributesConditionTest, OtherProfileSource) {
-  // A context with only "other_profile" and no URL shouldn't be evaluated.
+  // A source tab can have `other_profile` set even without a committed URL
+  // (e.g. the initial empty document), so a context with only `other_profile`
+  // set should still be evaluated.
   auto other_profile_src = SourceAttributesCondition::Create(CreateDict(R"(
       {
         "other_profile": true,
@@ -538,8 +550,8 @@
       {.destination = {.other_profile = true}}));
   ASSERT_FALSE(other_profile_src->CanBeEvaluated(
       {.destination = {.other_profile = false}}));
-  ASSERT_FALSE(
-      other_profile_src->CanBeEvaluated({.source = {.other_profile = true}}));
+  ASSERT_TRUE(
+      other_profile_src->IsTriggered({.source = {.other_profile = true}}));
   ASSERT_FALSE(
       other_profile_src->CanBeEvaluated({.source = {.other_profile = false}}));
 
@@ -552,8 +564,10 @@
       {.destination = {.other_profile = true}}));
   ASSERT_FALSE(non_other_profile_src->CanBeEvaluated(
       {.destination = {.other_profile = false}}));
-  ASSERT_FALSE(non_other_profile_src->CanBeEvaluated(
+  ASSERT_TRUE(non_other_profile_src->CanBeEvaluated(
       {.source = {.other_profile = true}}));
+  ASSERT_FALSE(
+      non_other_profile_src->IsTriggered({.source = {.other_profile = true}}));
   ASSERT_FALSE(non_other_profile_src->CanBeEvaluated(
       {.source = {.other_profile = false}}));
 }
@@ -578,7 +592,7 @@
       {.destination = {.url = GURL(kChromiumUrl), .other_profile = false}}));
   ASSERT_FALSE(url_and_other_profile->IsTriggered(
       {.destination = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(url_and_other_profile->CanBeEvaluated(
+  ASSERT_TRUE(url_and_other_profile->CanBeEvaluated(
       {.destination = {.other_profile = true}}));
   ASSERT_FALSE(url_and_other_profile->CanBeEvaluated(
       {.destination = {.other_profile = false}}));
@@ -602,7 +616,7 @@
       {.destination = {.url = GURL(kChromiumUrl), .other_profile = false}}));
   ASSERT_FALSE(url_and_not_other_profile->IsTriggered(
       {.destination = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(url_and_not_other_profile->CanBeEvaluated(
+  ASSERT_TRUE(url_and_not_other_profile->CanBeEvaluated(
       {.destination = {.other_profile = true}}));
   ASSERT_FALSE(url_and_not_other_profile->CanBeEvaluated(
       {.destination = {.other_profile = false}}));
@@ -627,7 +641,7 @@
       {.source = {.url = GURL(kChromiumUrl), .other_profile = false}}));
   ASSERT_FALSE(url_and_other_profile->IsTriggered(
       {.source = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(url_and_other_profile->CanBeEvaluated(
+  ASSERT_TRUE(url_and_other_profile->CanBeEvaluated(
       {.source = {.other_profile = true}}));
   ASSERT_FALSE(url_and_other_profile->CanBeEvaluated(
       {.source = {.other_profile = false}}));
@@ -651,7 +665,7 @@
       {.source = {.url = GURL(kChromiumUrl), .other_profile = false}}));
   ASSERT_FALSE(url_and_not_other_profile->IsTriggered(
       {.source = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(url_and_not_other_profile->CanBeEvaluated(
+  ASSERT_TRUE(url_and_not_other_profile->CanBeEvaluated(
       {.source = {.other_profile = true}}));
   ASSERT_FALSE(url_and_not_other_profile->CanBeEvaluated(
       {.source = {.other_profile = false}}));
@@ -670,7 +684,7 @@
   ASSERT_TRUE(any_url->IsTriggered(
       {.destination = {.url = GURL(kGoogleUrl), .other_profile = false}}));
   ASSERT_TRUE(any_url->IsTriggered({.destination = {.url = GURL(kGoogleUrl)}}));
-  ASSERT_FALSE(
+  ASSERT_TRUE(
       any_url->CanBeEvaluated({.destination = {.other_profile = true}}));
   ASSERT_FALSE(
       any_url->CanBeEvaluated({.destination = {.other_profile = false}}));
@@ -690,7 +704,7 @@
   ASSERT_TRUE(any_url->IsTriggered(
       {.source = {.url = GURL(kGoogleUrl), .other_profile = false}}));
   ASSERT_TRUE(any_url->IsTriggered({.source = {.url = GURL(kGoogleUrl)}}));
-  ASSERT_FALSE(any_url->CanBeEvaluated({.source = {.other_profile = true}}));
+  ASSERT_TRUE(any_url->CanBeEvaluated({.source = {.other_profile = true}}));
   ASSERT_FALSE(any_url->CanBeEvaluated({.source = {.other_profile = false}}));
   ASSERT_FALSE(any_url->CanBeEvaluated({.source = {}}));
 }
@@ -723,10 +737,10 @@
       {.source = {.url = GURL(kChromiumUrl), .other_profile = true}}));
   ASSERT_FALSE(condition->IsTriggered({.source = {.url = GURL(kGoogleUrl)}}));
   ASSERT_FALSE(condition->IsTriggered({.source = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(condition->CanBeEvaluated({.source = {.incognito = true}}));
+  ASSERT_TRUE(condition->CanBeEvaluated({.source = {.incognito = true}}));
   ASSERT_FALSE(condition->CanBeEvaluated({.source = {.incognito = false}}));
 
-  ASSERT_FALSE(condition->CanBeEvaluated({.source = {.other_profile = true}}));
+  ASSERT_TRUE(condition->CanBeEvaluated({.source = {.other_profile = true}}));
   ASSERT_FALSE(condition->CanBeEvaluated({.source = {.other_profile = false}}));
 }
 
@@ -761,11 +775,11 @@
       condition->IsTriggered({.destination = {.url = GURL(kGoogleUrl)}}));
   ASSERT_FALSE(
       condition->IsTriggered({.destination = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(condition->CanBeEvaluated({.destination = {.incognito = true}}));
+  ASSERT_TRUE(condition->CanBeEvaluated({.destination = {.incognito = true}}));
   ASSERT_FALSE(
       condition->CanBeEvaluated({.destination = {.incognito = false}}));
 
-  ASSERT_FALSE(
+  ASSERT_TRUE(
       condition->CanBeEvaluated({.destination = {.other_profile = true}}));
   ASSERT_FALSE(
       condition->CanBeEvaluated({.destination = {.other_profile = false}}));
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/enterprise/data_controls/core/browser/conditions/attributes_condition_unittest.cc b/components/enterprise/data_controls/core/browser/conditions/attributes_condition_unittest.cc
index 82c73c3..e829898 100644
--- a/components/enterprise/data_controls/core/browser/conditions/attributes_condition_unittest.cc
+++ b/components/enterprise/data_controls/core/browser/conditions/attributes_condition_unittest.cc
@@ -305,14 +305,15 @@
 #endif  // BUILDFLAG(IS_CHROMEOS)
 
 TEST(AttributesConditionTest, IncognitoDestination) {
-  // A context with only "incognito" and no URL shouldn't be evaluated.
+  // A destination tab can have `incognito` set even without a committed URL
+  // (e.g. the initial empty document), so a context with only `incognito` set
+  // should still be evaluated.
   auto incognito_dst = DestinationAttributesCondition::Create(CreateDict(R"(
       {
         "incognito": true,
       })"));
   ASSERT_TRUE(incognito_dst);
-  ASSERT_FALSE(
-      incognito_dst->CanBeEvaluated({.destination = {.incognito = true}}));
+  ASSERT_TRUE(incognito_dst->IsTriggered({.destination = {.incognito = true}}));
   ASSERT_FALSE(
       incognito_dst->CanBeEvaluated({.destination = {.incognito = false}}));
   ASSERT_FALSE(incognito_dst->CanBeEvaluated({.source = {.incognito = true}}));
@@ -323,9 +324,11 @@
         "incognito": false,
       })"));
   ASSERT_TRUE(non_incognito_dst);
-  ASSERT_FALSE(
+  ASSERT_TRUE(
       non_incognito_dst->CanBeEvaluated({.destination = {.incognito = true}}));
   ASSERT_FALSE(
+      non_incognito_dst->IsTriggered({.destination = {.incognito = true}}));
+  ASSERT_FALSE(
       non_incognito_dst->CanBeEvaluated({.destination = {.incognito = false}}));
   ASSERT_FALSE(
       non_incognito_dst->CanBeEvaluated({.source = {.incognito = true}}));
@@ -334,7 +337,9 @@
 }
 
 TEST(AttributesConditionTest, IncognitoSource) {
-  // A context with only "incognito" and no URL shouldn't be evaluated.
+  // A source tab can have `incognito` set even without a committed URL (e.g.
+  // the initial empty document), so a context with only `incognito` set should
+  // still be evaluated.
   auto incognito_src = SourceAttributesCondition::Create(CreateDict(R"(
       {
         "incognito": true,
@@ -344,7 +349,7 @@
       incognito_src->CanBeEvaluated({.destination = {.incognito = true}}));
   ASSERT_FALSE(
       incognito_src->CanBeEvaluated({.destination = {.incognito = false}}));
-  ASSERT_FALSE(incognito_src->CanBeEvaluated({.source = {.incognito = true}}));
+  ASSERT_TRUE(incognito_src->IsTriggered({.source = {.incognito = true}}));
   ASSERT_FALSE(incognito_src->CanBeEvaluated({.source = {.incognito = false}}));
 
   auto non_incognito_src = SourceAttributesCondition::Create(CreateDict(R"(
@@ -356,8 +361,9 @@
       non_incognito_src->CanBeEvaluated({.destination = {.incognito = true}}));
   ASSERT_FALSE(
       non_incognito_src->CanBeEvaluated({.destination = {.incognito = false}}));
-  ASSERT_FALSE(
+  ASSERT_TRUE(
       non_incognito_src->CanBeEvaluated({.source = {.incognito = true}}));
+  ASSERT_FALSE(non_incognito_src->IsTriggered({.source = {.incognito = true}}));
   ASSERT_FALSE(
       non_incognito_src->CanBeEvaluated({.source = {.incognito = false}}));
 }
@@ -381,7 +387,7 @@
       {.destination = {.url = GURL(kChromiumUrl), .incognito = false}}));
   ASSERT_FALSE(url_and_incognito->IsTriggered(
       {.destination = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(
+  ASSERT_TRUE(
       url_and_incognito->CanBeEvaluated({.destination = {.incognito = true}}));
   ASSERT_FALSE(
       url_and_incognito->CanBeEvaluated({.destination = {.incognito = false}}));
@@ -405,7 +411,7 @@
       {.destination = {.url = GURL(kChromiumUrl), .incognito = false}}));
   ASSERT_FALSE(url_and_not_incognito->IsTriggered(
       {.destination = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(url_and_not_incognito->CanBeEvaluated(
+  ASSERT_TRUE(url_and_not_incognito->CanBeEvaluated(
       {.destination = {.incognito = true}}));
   ASSERT_FALSE(url_and_not_incognito->CanBeEvaluated(
       {.destination = {.incognito = false}}));
@@ -430,7 +436,7 @@
       {.source = {.url = GURL(kChromiumUrl), .incognito = false}}));
   ASSERT_FALSE(
       url_and_incognito->IsTriggered({.source = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(
+  ASSERT_TRUE(
       url_and_incognito->CanBeEvaluated({.source = {.incognito = true}}));
   ASSERT_FALSE(
       url_and_incognito->CanBeEvaluated({.source = {.incognito = false}}));
@@ -453,7 +459,7 @@
       {.source = {.url = GURL(kChromiumUrl), .incognito = false}}));
   ASSERT_FALSE(url_and_not_incognito->IsTriggered(
       {.source = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(
+  ASSERT_TRUE(
       url_and_not_incognito->CanBeEvaluated({.source = {.incognito = true}}));
   ASSERT_FALSE(
       url_and_not_incognito->CanBeEvaluated({.source = {.incognito = false}}));
@@ -472,7 +478,7 @@
   ASSERT_TRUE(any_url->IsTriggered(
       {.destination = {.url = GURL(kGoogleUrl), .incognito = false}}));
   ASSERT_TRUE(any_url->IsTriggered({.destination = {.url = GURL(kGoogleUrl)}}));
-  ASSERT_FALSE(any_url->CanBeEvaluated({.destination = {.incognito = true}}));
+  ASSERT_TRUE(any_url->CanBeEvaluated({.destination = {.incognito = true}}));
   ASSERT_FALSE(any_url->CanBeEvaluated({.destination = {.incognito = false}}));
   ASSERT_FALSE(any_url->CanBeEvaluated({.destination = {}}));
 }
@@ -490,20 +496,22 @@
   ASSERT_TRUE(any_url->IsTriggered(
       {.source = {.url = GURL(kGoogleUrl), .incognito = false}}));
   ASSERT_TRUE(any_url->IsTriggered({.source = {.url = GURL(kGoogleUrl)}}));
-  ASSERT_FALSE(any_url->CanBeEvaluated({.source = {.incognito = true}}));
+  ASSERT_TRUE(any_url->CanBeEvaluated({.source = {.incognito = true}}));
   ASSERT_FALSE(any_url->CanBeEvaluated({.source = {.incognito = false}}));
   ASSERT_FALSE(any_url->CanBeEvaluated({.source = {}}));
 }
 
 TEST(AttributesConditionTest, OtherProfileDestination) {
-  // A context with only "other_profile" and no URL shouldn't be evaluated.
+  // A destination tab can have `other_profile` set even without a committed
+  // URL (e.g. the initial empty document), so a context with only
+  // `other_profile` set should still be evaluated.
   auto other_profile_dst = DestinationAttributesCondition::Create(CreateDict(R"(
       {
         "other_profile": true,
       })"));
   ASSERT_TRUE(other_profile_dst);
-  ASSERT_FALSE(other_profile_dst->CanBeEvaluated(
-      {.destination = {.other_profile = true}}));
+  ASSERT_TRUE(
+      other_profile_dst->IsTriggered({.destination = {.other_profile = true}}));
   ASSERT_FALSE(other_profile_dst->CanBeEvaluated(
       {.destination = {.other_profile = false}}));
   ASSERT_FALSE(
@@ -517,7 +525,9 @@
         "other_profile": false,
       })"));
   ASSERT_TRUE(non_other_profile_dst);
-  ASSERT_FALSE(non_other_profile_dst->CanBeEvaluated(
+  ASSERT_TRUE(non_other_profile_dst->CanBeEvaluated(
+      {.destination = {.other_profile = true}}));
+  ASSERT_FALSE(non_other_profile_dst->IsTriggered(
       {.destination = {.other_profile = true}}));
   ASSERT_FALSE(non_other_profile_dst->CanBeEvaluated(
       {.destination = {.other_profile = false}}));
@@ -528,7 +538,9 @@
 }
 
 TEST(AttributesConditionTest, OtherProfileSource) {
-  // A context with only "other_profile" and no URL shouldn't be evaluated.
+  // A source tab can have `other_profile` set even without a committed URL
+  // (e.g. the initial empty document), so a context with only `other_profile`
+  // set should still be evaluated.
   auto other_profile_src = SourceAttributesCondition::Create(CreateDict(R"(
       {
         "other_profile": true,
@@ -538,8 +550,8 @@
       {.destination = {.other_profile = true}}));
   ASSERT_FALSE(other_profile_src->CanBeEvaluated(
       {.destination = {.other_profile = false}}));
-  ASSERT_FALSE(
-      other_profile_src->CanBeEvaluated({.source = {.other_profile = true}}));
+  ASSERT_TRUE(
+      other_profile_src->IsTriggered({.source = {.other_profile = true}}));
   ASSERT_FALSE(
       other_profile_src->CanBeEvaluated({.source = {.other_profile = false}}));
 
@@ -552,8 +564,10 @@
       {.destination = {.other_profile = true}}));
   ASSERT_FALSE(non_other_profile_src->CanBeEvaluated(
       {.destination = {.other_profile = false}}));
-  ASSERT_FALSE(non_other_profile_src->CanBeEvaluated(
+  ASSERT_TRUE(non_other_profile_src->CanBeEvaluated(
       {.source = {.other_profile = true}}));
+  ASSERT_FALSE(
+      non_other_profile_src->IsTriggered({.source = {.other_profile = true}}));
   ASSERT_FALSE(non_other_profile_src->CanBeEvaluated(
       {.source = {.other_profile = false}}));
 }
@@ -578,7 +592,7 @@
       {.destination = {.url = GURL(kChromiumUrl), .other_profile = false}}));
   ASSERT_FALSE(url_and_other_profile->IsTriggered(
       {.destination = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(url_and_other_profile->CanBeEvaluated(
+  ASSERT_TRUE(url_and_other_profile->CanBeEvaluated(
       {.destination = {.other_profile = true}}));
   ASSERT_FALSE(url_and_other_profile->CanBeEvaluated(
       {.destination = {.other_profile = false}}));
@@ -602,7 +616,7 @@
       {.destination = {.url = GURL(kChromiumUrl), .other_profile = false}}));
   ASSERT_FALSE(url_and_not_other_profile->IsTriggered(
       {.destination = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(url_and_not_other_profile->CanBeEvaluated(
+  ASSERT_TRUE(url_and_not_other_profile->CanBeEvaluated(
       {.destination = {.other_profile = true}}));
   ASSERT_FALSE(url_and_not_other_profile->CanBeEvaluated(
       {.destination = {.other_profile = false}}));
@@ -627,7 +641,7 @@
       {.source = {.url = GURL(kChromiumUrl), .other_profile = false}}));
   ASSERT_FALSE(url_and_other_profile->IsTriggered(
       {.source = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(url_and_other_profile->CanBeEvaluated(
+  ASSERT_TRUE(url_and_other_profile->CanBeEvaluated(
       {.source = {.other_profile = true}}));
   ASSERT_FALSE(url_and_other_profile->CanBeEvaluated(
       {.source = {.other_profile = false}}));
@@ -651,7 +665,7 @@
       {.source = {.url = GURL(kChromiumUrl), .other_profile = false}}));
   ASSERT_FALSE(url_and_not_other_profile->IsTriggered(
       {.source = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(url_and_not_other_profile->CanBeEvaluated(
+  ASSERT_TRUE(url_and_not_other_profile->CanBeEvaluated(
       {.source = {.other_profile = true}}));
   ASSERT_FALSE(url_and_not_other_profile->CanBeEvaluated(
       {.source = {.other_profile = false}}));
@@ -670,7 +684,7 @@
   ASSERT_TRUE(any_url->IsTriggered(
       {.destination = {.url = GURL(kGoogleUrl), .other_profile = false}}));
   ASSERT_TRUE(any_url->IsTriggered({.destination = {.url = GURL(kGoogleUrl)}}));
-  ASSERT_FALSE(
+  ASSERT_TRUE(
       any_url->CanBeEvaluated({.destination = {.other_profile = true}}));
   ASSERT_FALSE(
       any_url->CanBeEvaluated({.destination = {.other_profile = false}}));
@@ -690,7 +704,7 @@
   ASSERT_TRUE(any_url->IsTriggered(
       {.source = {.url = GURL(kGoogleUrl), .other_profile = false}}));
   ASSERT_TRUE(any_url->IsTriggered({.source = {.url = GURL(kGoogleUrl)}}));
-  ASSERT_FALSE(any_url->CanBeEvaluated({.source = {.other_profile = true}}));
+  ASSERT_TRUE(any_url->CanBeEvaluated({.source = {.other_profile = true}}));
   ASSERT_FALSE(any_url->CanBeEvaluated({.source = {.other_profile = false}}));
   ASSERT_FALSE(any_url->CanBeEvaluated({.source = {}}));
 }
@@ -723,10 +737,10 @@
       {.source = {.url = GURL(kChromiumUrl), .other_profile = true}}));
   ASSERT_FALSE(condition->IsTriggered({.source = {.url = GURL(kGoogleUrl)}}));
   ASSERT_FALSE(condition->IsTriggered({.source = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(condition->CanBeEvaluated({.source = {.incognito = true}}));
+  ASSERT_TRUE(condition->CanBeEvaluated({.source = {.incognito = true}}));
   ASSERT_FALSE(condition->CanBeEvaluated({.source = {.incognito = false}}));
 
-  ASSERT_FALSE(condition->CanBeEvaluated({.source = {.other_profile = true}}));
+  ASSERT_TRUE(condition->CanBeEvaluated({.source = {.other_profile = true}}));
   ASSERT_FALSE(condition->CanBeEvaluated({.source = {.other_profile = false}}));
 }
 
@@ -761,11 +775,11 @@
       condition->IsTriggered({.destination = {.url = GURL(kGoogleUrl)}}));
   ASSERT_FALSE(
       condition->IsTriggered({.destination = {.url = GURL(kChromiumUrl)}}));
-  ASSERT_FALSE(condition->CanBeEvaluated({.destination = {.incognito = true}}));
+  ASSERT_TRUE(condition->CanBeEvaluated({.destination = {.incognito = true}}));
   ASSERT_FALSE(
       condition->CanBeEvaluated({.destination = {.incognito = false}}));
 
-  ASSERT_FALSE(
+  ASSERT_TRUE(
       condition->CanBeEvaluated({.destination = {.other_profile = true}}));
   ASSERT_FALSE(
       condition->CanBeEvaluated({.destination = {.other_profile = false}}));
Loading diff…

Original Bug Report

reported by li...@chromium.org

Universal Enterprise Policy Bypass via Empty URL Contexts in Data Controls

Flapjack, 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 without the Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.

Overview: A logic flaw in Enterprise Data Controls allows users to bypass cross-profile and incognito paste restrictions. When a paste occurs in a tab with an empty committed URL (e.g., opened via a javascript: URI), the policy engine incorrectly determines the context is empty and skips evaluating the blocking rules. This enables exfiltration of sensitive data to unauthorized profiles.

Affected files:

  • components/enterprise/data_controls/core/browser/action_context.cc
  • components/enterprise/data_controls/core/browser/conditions/attributes_condition.cc

Estimated timestamp from git blame: 2024-03-21

Description

A logic flaw in the Enterprise Data Controls engine allows for a potential universal bypass of policy restrictions when an action (like a clipboard paste) occurs in a context with an empty URL. The engine incorrectly determines that such a context is “empty” and consequently skips the evaluation of rules that should apply based on other attributes, such as cross-profile or Incognito status.

In components/enterprise/data_controls/core/browser/action_context.cc, the ActionSource::empty() and ActionDestination::empty() methods check only the URL and the OS clipboard flag, while completely ignoring the incognito and other_profile fields:

bool ActionSource::empty() const {
  return url.is_empty() && !os_clipboard;
}

This implementation assumes that a valid browser tab context must have a non-empty URL. However, a context can be valid and restricted (e.g., belonging to an unmanaged profile) even if the URL is an empty GURL(). When empty() returns true, DestinationAttributesCondition::CanBeEvaluated() returns false, causing the policy engine to skip the condition entirely and allow the action.

An attacker or malicious page can intentionally create a tab with an empty last_committed_url_ by opening a new window and executing a javascript: URI. Because javascript: URIs are executed without committing a standard navigation, the RenderFrameHost’s last_committed_url_ remains uninitialized (GURL()).

Potential Exploitation Scenario

This flaw can be exploited to bypass cross-profile Data Loss Prevention (DLP) rules:

  1. Policy: An administrator sets a rule to block clipboard actions when the destination is another profile (e.g., {"destinations": {"other_profile": true}, "restrictions": [{"class": "CLIPBOARD", "level": "BLOCK"}]}).
  2. Preparation: In an unmanaged, secondary browser profile, a user or a webpage opens a new window using JavaScript: window.open('javascript:document.write("<body contenteditable></body>")'). This creates a new RenderFrameHost whose last_committed_url_ is an empty GURL().
  3. Copy: The user copies sensitive data from a tab within the managed profile.
  4. Paste: The user switches to the unmanaged profile and pastes the data into the blank, contenteditable tab.
  5. Bypass:
    • The browser calls CreateDataEndpoint() to get the destination URL, which returns std::nullopt because last_committed_url_ is empty.
    • ExtractPasteActionContext successfully detects the destination is a different profile and sets action.other_profile = true, but leaves action.url empty.
    • ActionDestination::empty() returns true because the URL is empty and it ignores other_profile.
    • The DestinationAttributesCondition fails to evaluate, the block rule is skipped, and the sensitive data is successfully exfiltrated to the unauthorized profile.

(Note: These are suggested steps based on static analysis. Our tooling agent does not currently have the capability to run a working Proof of Concept).

The empty() methods for ActionSource and ActionDestination in components/enterprise/data_controls/core/browser/action_context.cc must be updated to account for the incognito and other_profile flags. A context should only be considered empty if all its identifying attributes indicate no context is present.

bool ActionSource::empty() const {
  return url.is_empty() && !os_clipboard && !incognito && !other_profile;
}

(Apply the equivalent fix to ActionDestination::empty().)

Evaluated with Chrome root at commit: c1eba8ce379f5d218a2948fafbb5dd72cfa30529


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.

Raised in root component due to access or custom field issues on 1974931

View on issue tracker