CVE-2026-78907
Overview
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, andother_profile. - `ActionSource` / `ActionDestination`
- Structs in
action_context.ccthat 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 separatecomponent. - `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 itsurlis 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.
incognito and other_profile to empty() stops those tabs from bypassing evaluation.Attack Path
- Enterprise policy in force
An admin configures a Data Controls rule keyed on
incognitoorother_profile(e.g. block pasting into incognito tabs, or copying between profiles). Without such a policy, the bug is not reachable. - 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. - Context classified as empty
For an action whose source or destination is that tab, the
ActionSource/ActionDestinationhas an emptyurlbutincognito/other_profileset. The oldempty()returned true, soCanBeEvaluated()was false and the rule was skipped. - 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
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
| Function | Change | Notes |
|---|---|---|
TESTcomponents/enterprise/data_controls/core/browser/conditions/attributes_condition_unittest.cc |
modified |
Files Changed
components/enterprise/data_controls/core/browser/action_context.cccomponents/enterprise/data_controls/core/browser/conditions/attributes_condition_unittest.cc
Audit Directions
- "Empty" or "unknown" short-circuits in policy enginesLook 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 casesCheck DLP, Safe Browsing and enterprise connectors for how they handle tabs that have not committed a navigation (popups,
javascript:andabout:blankopens), whereGetLastCommittedURL()is empty but the profile or context is known. - Struct fields added without updating helpersWhen new fields are added to context structs (
incognito,other_profile,component), audit allempty()/operator==/serialization helpers to confirm they account for every field.
Patch
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}}));
Regression Test / PoC
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}}));
Original Bug Report
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.cccomponents/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:
- 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"}]}). - 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 newRenderFrameHostwhoselast_committed_url_is an emptyGURL(). - Copy: The user copies sensitive data from a tab within the managed profile.
- Paste: The user switches to the unmanaged profile and pastes the data into the blank,
contenteditabletab. - Bypass:
- The browser calls
CreateDataEndpoint()to get the destination URL, which returnsstd::nulloptbecauselast_committed_url_is empty. ExtractPasteActionContextsuccessfully detects the destination is a different profile and setsaction.other_profile = true, but leavesaction.urlempty.ActionDestination::empty()returnstruebecause the URL is empty and it ignoresother_profile.- The
DestinationAttributesConditionfails to evaluate, the block rule is skipped, and the sensitive data is successfully exfiltrated to the unauthorized profile.
- The browser calls
(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).
Recommended Fix
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