CVE-2026-106280
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/html/html_capability_element_metrics_util.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/html/html_capability_element_base_test.cc |
modified | |
forthird_party/blink/renderer/core/html/html_capability_element_base_test.cc |
modified | |
EXPECT_TRUEthird_party/blink/renderer/core/html/html_capability_element_base_test.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/html/html_install_element_test.cc |
modified | |
ifthird_party/blink/renderer/core/html/html_install_element_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/html/html_install_element_test.ccthird_party/blink/renderer/core/html/html_capability_element_base.ccthird_party/blink/web_tests/external/wpt/html/semantics/permission-element/install/iframe-not-allowed.tentative.htmlthird_party/blink/renderer/core/html/html_capability_element_base_test.ccthird_party/blink/renderer/core/html/html_capability_element_metrics_util.ccthird_party/blink/web_tests/external/wpt/html/semantics/permission-element/install/sandbox-not-allowed.tentative.html
Patch
From 32fdc2573db9b38644481707bf9b63e79f840a43 Mon Sep 17 00:00:00 2001 From: Thomas Nguyen <[email protected]> Date: Mon, 07 Sep 2026 11:14:50 -0700 Subject: [PATCH] [CE[ Use HasTagName for tag checks in HTMLCapabilityElementBase HTML elements created with an XML namespace prefix (such as via createElementNS) retain a prefix in their QualifiedName. Direct equality comparisons using TagQName() == html_names::kInstallTag evaluate to false due to the prefix difference, preventing tag-specific logic from matching. Replace direct QualifiedName comparisons with HasTagName() across HTMLCapabilityElementBase. This ensures subframe and sandbox state validation correctly handles prefixed install elements. Fixed: 517366426 Change-Id: Ib1a96940975207c5d23ec789745d0fdc7e8f9550 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8364205 Reviewed-by: Rune Lillesveen <[email protected]> Commit-Queue: Thomas Nguyen <[email protected]> Cr-Commit-Position: refs/heads/main@{#1693416} --- diff --git a/third_party/blink/renderer/core/html/html_capability_element_base.cc b/third_party/blink/renderer/core/html/html_capability_element_base.cc index 74975f3..c12ccdd 100644 --- a/third_party/blink/renderer/core/html/html_capability_element_base.cc +++ b/third_party/blink/renderer/core/html/html_capability_element_base.cc @@ -628,7 +628,7 @@ // disallow in sandboxed documents. If we // continue to block it in all subframes, we should likely create a new issue // type. - if (TagQName() == html_names::kInstallTag && + if (HasTagName(html_names::kInstallTag) && GetExecutionContext()->GetSandboxFlags() != network::mojom::blink::WebSandboxFlags::kNone) { return false; @@ -637,7 +637,7 @@ // TODO(crbug.com/490139152): Evaluate <install> support in subframes. If we // continue to block it in all subframes, we should likely create a new issue // type. - if (TagQName() == html_names::kInstallTag && !frame->IsMainFrame()) { + if (HasTagName(html_names::kInstallTag) && !frame->IsMainFrame()) { return false; } @@ -1183,7 +1183,7 @@ // contexts. For now, check this before `is_registered_in_browser_process_` // so we don't fall through to SecurityChecksFailed, which DevTools // maps to a misleading "quota exceeded" message. - if (TagQName() == html_names::kInstallTag && + if (HasTagName(html_names::kInstallTag) && GetExecutionContext()->GetSandboxFlags() != network::mojom::blink::WebSandboxFlags::kNone) { RecordPermissionElementUserInteractionDeniedReason( @@ -1196,7 +1196,7 @@ // For now, check this before `is_registered_in_browser_process_` // so we don't fall through to SecurityChecksFailed, which DevTools // maps to a misleading "quota exceeded" message. - if (TagQName() == html_names::kInstallTag && !frame->IsMainFrame()) { + if (HasTagName(html_names::kInstallTag) && !frame->IsMainFrame()) { RecordPermissionElementUserInteractionDeniedReason( TagQName(), UserInteractionDeniedReason::kFailedOrHasNotBeenRegistered); @@ -1304,14 +1304,14 @@ // TODO(crbug.com/493534965): Evaluate <install> support in sandboxed // contexts. - if (TagQName() == html_names::kInstallTag && + if (HasTagName(html_names::kInstallTag) && GetExecutionContext()->GetSandboxFlags() != network::mojom::blink::WebSandboxFlags::kNone) { return {false, AtomicString("illegal_sandbox")}; } // TODO(crbug.com/490139152): Evaluate <install> support in subframes. - if (TagQName() == html_names::kInstallTag && !frame->IsMainFrame()) { + if (HasTagName(html_names::kInstallTag) && !frame->IsMainFrame()) { return {false, AtomicString("illegal_subframe")}; } } diff --git a/third_party/blink/renderer/core/html/html_capability_element_metrics_util.cc b/third_party/blink/renderer/core/html/html_capability_element_metrics_util.cc index 79206020..9b5d73c 100644 --- a/third_party/blink/renderer/core/html/html_capability_element_metrics_util.cc +++ b/third_party/blink/renderer/core/html/html_capability_element_metrics_util.cc @@ -25,15 +25,15 @@ CAPABILITY_ELEMENT_BASE_HISTOGRAM_PATTERN ".UserInteraction"; const char* GetCapabilityNameForHistogram(const QualifiedName& tag_name) { - if (tag_name == html_names::kGeolocationTag) { + if (tag_name.Matches(html_names::kGeolocationTag)) { return "Geolocation"; - } else if (tag_name == html_names::kInstallTag) { + } else if (tag_name.Matches(html_names::kInstallTag)) { return "Install"; - } else if (tag_name == html_names::kUsermediaTag) { + } else if (tag_name.Matches(html_names::kUsermediaTag)) { return "UserMedia"; - } else if (tag_name == html_names::kCameraTag) { + } else if (tag_name.Matches(html_names::kCameraTag)) { return "Camera"; - } else if (tag_name == html_names::kMicrophoneTag) { + } else if (tag_name.Matches(html_names::kMicrophoneTag)) { return "Microphone"; } else { NOTREACHED(); @@ -43,15 +43,15 @@ void RecordPermissionElementUseCounter(Document& document, const QualifiedName& tag_name) { - if (tag_name == html_names::kGeolocationTag) { + if (tag_name.Matches(html_names::kGeolocationTag)) { UseCounter::Count(document, WebFeature::kHTMLGeolocationElement); - } else if (tag_name == html_names::kInstallTag) { + } else if (tag_name.Matches(html_names::kInstallTag)) { UseCounter::Count(document, WebFeature::kHTMLInstallElement); - } else if (tag_name == html_names::kUsermediaTag) { + } else if (tag_name.Matches(html_names::kUsermediaTag)) { UseCounter::Count(document, WebFeature::kHTMLUserMediaElement); - } else if (tag_name == html_names::kCameraTag) { + } else if (tag_name.Matches(html_names::kCameraTag)) { UseCounter::Count(document, WebFeature::kHTMLCameraElement); - } else if (tag_name == html_names::kMicrophoneTag) { + } else if (tag_name.Matches(html_names::kMicrophoneTag)) { UseCounter::Count(document, WebFeature::kHTMLMicrophoneElement); } else { NOTREACHED(); diff --git a/third_party/blink/renderer/core/html/html_capability_element_base_test.cc b/third_party/blink/renderer/core/html/html_capability_element_base_test.cc index cceb977..dc2d539 100644 --- a/third_party/blink/renderer/core/html/html_capability_element_base_test.cc +++ b/third_party/blink/renderer/core/html/html_capability_element_base_test.cc @@ -798,6 +798,21 @@ return permission_element; } + HTMLCapabilityElementBase* CreatePrefixedPermissionElement( + Document& document, + const char* local_name, + const char* prefix = "x") { + auto* permission_element = + To<HTMLCapabilityElementBase>(document.createElementNS( + html_names::xhtmlNamespaceURI, + AtomicString(String(prefix) + ":" + local_name), + ASSERT_NO_EXCEPTION)); + document.body()->AppendChild(permission_element); + document.UpdateStyleAndLayout(DocumentUpdateReason::kTest); + GetDocument().View()->UpdateAllLifecyclePhasesForTest(); + return permission_element; + } + PermissionElementTestPermissionService* permission_service() { return &permission_service_; } @@ -1515,6 +1530,186 @@ permission_service()->set_pepc_registered_callback(base::NullCallback()); } +TEST_F(HTMLInstallElementSimTest, + PrefixedInstallNotAllowedInSameOriginSubframe) { + SimRequest main_resource("https://example.test", "text/html"); + LoadURL("https://example.test"); + SimRequest iframe_resource("https://example.test/foo.html", "text/html"); + main_resource.Complete(R"( + <body> + <iframe src='https://example.test/foo.html' + allow="web-app-installation *"> + </iframe> + </body> + )"); + iframe_resource.Finish(); + + auto* child_frame = To<WebLocalFrameImpl>(MainFrame().FirstChild()); + auto* child_doc = child_frame->GetFrame()->GetDocument(); + + auto* install_element = + CreatePrefixedPermissionElement(*child_doc, "install"); + // PEPC registration should NOT be called for prefixed <install> in subframes. + permission_service()->set_pepc_registered_callback( + BindOnce(&NotReachedForPEPCRegistered)); + + // The element should NOT be valid, with reason "illegal_subframe". + EXPECT_TRUE( + base::test::RunUntil([&]() { return !install_element->isValid(); })); + EXPECT_EQ(install_element->invalidReason(), "illegal_subframe"); + + permission_service()->set_pepc_registered_callback(base::NullCallback()); +} + +TEST_F(HTMLInstallElementSimTest, + PrefixedInstallNotAllowedInCrossOriginSubframe) { + SimRequest::Params params; + params.response_http_headers = { + {"content-security-policy", + "frame-ancestors 'self' https://example.test"}}; + SimRequest main_resource("https://example.test", "text/html"); + LoadURL("https://example.test"); + SimRequest iframe_resource("https://cross-example.test/foo.html", "text/html", + params); + main_resource.Complete(R"( + <body> + <iframe src='https://cross-example.test/foo.html' + allow="web-app-installation *"> + </iframe> + </body> + )"); + iframe_resource.Finish(); + + auto* child_frame = To<WebLocalFrameImpl>(MainFrame().FirstChild()); + auto* child_doc = child_frame->GetFrame()->GetDocument(); + + auto* install_element = + CreatePrefixedPermissionElement(*child_doc, "install"); + // PEPC registration should NOT be called for prefixed <install> in subframes. + permission_service()->set_pepc_registered_callback( + BindOnce(&NotReachedForPEPCRegistered)); + + // The element should NOT be valid, with reason "illegal_subframe". + EXPECT_TRUE( + base::test::RunUntil([&]() { return !install_element->isValid(); })); + EXPECT_EQ(install_element->invalidReason(), "illegal_subframe"); + + permission_service()->set_pepc_registered_callback(base::NullCallback()); +} + +TEST_F(HTMLInstallElementSimTest, PrefixedInstallNotAllowedInSandboxedIframe) { + SimRequest main_resource("https://example.test", "text/html"); + LoadURL("https://example.test"); + SimRequest iframe_resource("https://example.test/foo.html", "text/html"); + main_resource.Complete(R"( + <body> + <iframe src='https://example.test/foo.html' + sandbox="allow-scripts allow-same-origin" + allow="web-app-installation *"> + </iframe> + </body> + )"); + iframe_resource.Finish(); + + auto* child_frame = To<WebLocalFrameImpl>(MainFrame().FirstChild()); + auto* child_doc = child_frame->GetFrame()->GetDocument(); + + auto* install_element = + CreatePrefixedPermissionElement(*child_doc, "install"); + // PEPC registration should NOT be called for prefixed <install> in sandboxed + // iframes. + permission_service()->set_pepc_registered_callback( + BindOnce(&NotReachedForPEPCRegistered)); + + EXPECT_TRUE( + base::test::RunUntil([&]() { return !install_element->isValid(); })); + EXPECT_EQ(install_element->invalidReason(), "illegal_sandbox"); + + permission_service()->set_pepc_registered_callback(base::NullCallback()); +} + +TEST_F(HTMLInstallElementSimTest, + PrefixedInstallNotAllowedInSandboxedMainDocument) { + SimRequest::Params params; + params.response_http_headers = { + {"content-security-policy", "sandbox allow-same-origin allow-scripts"}}; + SimRequest main_resource("https://example.test", "text/html", params); + LoadURL("https://example.test"); + main_resource.Complete(R"( + <body> + </body> + )"); + + auto* install_element = + CreatePrefixedPermissionElement(GetDocument(), "install"); + // PEPC registration should NOT be called for prefixed <install> in sandboxed + // documents. + permission_service()->set_pepc_registered_callback( + BindOnce(&NotReachedForPEPCRegistered)); + + EXPECT_TRUE( + base::test::RunUntil([&]() { return !install_element->isValid(); })); + EXPECT_EQ(install_element->invalidReason(), "illegal_sandbox"); + + permission_service()->set_pepc_registered_callback(base::NullCallback()); +} + +TEST_F(HTMLInstallElementSimTest, PrefixedInstallAllowedInMainDocument) { + SimRequest main_resource("https://example.test", "text/html"); + LoadURL("https://example.test"); + main_resource.Complete(R"( + <body> + </body> + )"); + + auto* install_element = + CreatePrefixedPermissionElement(GetDocument(), "install"); + EXPECT_TRUE( + base::test::RunUntil([&]() { return install_element->isValid(); })); + EXPECT_TRUE(install_element->invalidReason().empty()); + EXPECT_TRUE(GetDocument().IsUseCounted(WebFeature::kHTMLInstallElement)); +} + +TEST_F(HTMLCapabilityElementBaseSimTest, + PrefixedCapabilityElementsUseCounters) { + SimRequest main_resource("https://example.test", "text/html"); + LoadURL("https://example.test"); + main_resource.Complete(R"( + <body> + </body> + )"); + + struct TestCase { + const char* tag; + WebFeature feature; + }; + const TestCase kTestCases[] = { + {"geolocation", WebFeature::kHTMLGeolocationElement}, + {"camera", WebFeature::kHTMLCameraElement}, + {"microphone", WebFeature::kHTMLMicrophoneElement}, + }; + + for (const auto& test_case : kTestCases) { + auto* element = + CreatePrefixedPermissionElement(GetDocument(), test_case.tag); + EXPECT_TRUE(base::test::RunUntil([&]() { return element->isValid(); })); + EXPECT_TRUE(element->invalidReason().empty()); + EXPECT_TRUE(GetDocument().IsUseCounted(test_case.feature)); + } + + // Also test usermedia element with type attribute. + auto* usermedia_element = + CreatePrefixedPermissionElement(GetDocument(), "usermedia"); + usermedia_element->setAttribute(html_names::kTypeAttr, + AtomicString("camera")); + GetDocument().UpdateStyleAndLayout(DocumentUpdateReason::kTest); + GetDocument().View()->UpdateAllLifecyclePhasesForTest(); + EXPECT_TRUE( + base::test::RunUntil([&]() { return usermedia_element->isValid(); })); + EXPECT_TRUE(usermedia_element->invalidReason().empty()); + EXPECT_TRUE(GetDocument().IsUseCounted(WebFeature::kHTMLUserMediaElement)); +} + TEST_F(HTMLCapabilityElementBaseSimTest, BlockedByMissingFrameAncestorsCSP) { GetDocument().GetSettings()->SetDefaultFontSize(12); SimRequest::Params params; diff --git a/third_party/blink/renderer/core/html/html_install_element_test.cc b/third_party/blink/renderer/core/html/html_install_element_test.cc index ea4624b..44fc76a 100644 --- a/third_party/blink/renderer/core/html/html_install_element_test.cc +++ b/third_party/blink/renderer/core/html/html_install_element_test.cc @@ -558,4 +558,29 @@ EXPECT_EQ(kResultInvalidData, event->result().AsString()); } +TEST_F(HTMLInstallElementTestBase, PrefixedElementRegistration) { + auto* element = To<HTMLInstallElement>(GetDocument().createElementNS( + html_names::xhtmlNamespaceURI, AtomicString("x:install"), + ASSERT_NO_EXCEPTION)); + WaitForElementRegistration(element); + EXPECT_TRUE(element->isValid()); +} + +TEST_F(HTMLInstallElementTestBase, PrefixedElementActivationSuccess) { + auto* element = To<HTMLInstallElement>(GetDocument().createElementNS( + html_names::xhtmlNamespaceURI, AtomicString("x:install"), + ASSERT_NO_EXCEPTION)); + WaitForElementRegistration(element); + + element->DispatchEvent(*Event::Create(event_type_names::kDOMActivate)); + web_install_service_.WaitForCall(); + web_install_service_.RespondManifestWithSuccess(); + + InstallResultEvent* event = WaitForInstallResultEvent(element); + EXPECT_NE(event, nullptr); + if (event) { + EXPECT_EQ(kResultSuccess, event->result().AsString()); + } +} + } // namespace blink diff --git a/third_party/blink/web_tests/external/wpt/html/semantics/permission-element/install/iframe-not-allowed.tentative.html b/third_party/blink/web_tests/external/wpt/html/semantics/permission-element/install/iframe-not-allowed.tentative.html index d43f72b..d3bd54a 100644 --- a/third_party/blink/web_tests/external/wpt/html/semantics/permission-element/install/iframe-not-allowed.tentative.html +++ b/third_party/blink/web_tests/external/wpt/html/semantics/permission-element/install/iframe-not-allowed.tentative.html @@ -38,5 +38,50 @@ }); document.body.appendChild(iframe); }, "Install element is not allowed in an iframe even with permissions policy"); + + async_test((t) => { + const iframe = document.createElement("iframe"); + iframe.srcdoc = ` + <!DOCTYPE html> + <body> + <script> + const install = document.createElementNS("http://www.w3.org/1999/xhtml", "x:install"); + install.id = "install-element"; + document.body.appendChild(install); + <\/script> + </body> + `; + iframe.onload = t.step_func_done(() => { + const install = iframe.contentDocument.getElementById("install-element"); + assert_false(install.isValid, + "Prefixed install element should not be valid inside an iframe"); + assert_equals(install.invalidReason, "illegal_subframe", + "invalidReason should be 'illegal_subframe'"); + }); + document.body.appendChild(iframe); ... (file diff truncated)
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/html/html_capability_element_base_test.cc b/third_party/blink/renderer/core/html/html_capability_element_base_test.cc
index cceb977..dc2d539 100644
--- a/third_party/blink/renderer/core/html/html_capability_element_base_test.cc
+++ b/third_party/blink/renderer/core/html/html_capability_element_base_test.cc
@@ -798,6 +798,21 @@
return permission_element;
}
+ HTMLCapabilityElementBase* CreatePrefixedPermissionElement(
+ Document& document,
+ const char* local_name,
+ const char* prefix = "x") {
+ auto* permission_element =
+ To<HTMLCapabilityElementBase>(document.createElementNS(
+ html_names::xhtmlNamespaceURI,
+ AtomicString(String(prefix) + ":" + local_name),
+ ASSERT_NO_EXCEPTION));
+ document.body()->AppendChild(permission_element);
+ document.UpdateStyleAndLayout(DocumentUpdateReason::kTest);
+ GetDocument().View()->UpdateAllLifecyclePhasesForTest();
+ return permission_element;
+ }
+
PermissionElementTestPermissionService* permission_service() {
return &permission_service_;
}
@@ -1515,6 +1530,186 @@
permission_service()->set_pepc_registered_callback(base::NullCallback());
}
+TEST_F(HTMLInstallElementSimTest,
+ PrefixedInstallNotAllowedInSameOriginSubframe) {
+ SimRequest main_resource("https://example.test", "text/html");
+ LoadURL("https://example.test");
+ SimRequest iframe_resource("https://example.test/foo.html", "text/html");
+ main_resource.Complete(R"(
+ <body>
+ <iframe src='https://example.test/foo.html'
+ allow="web-app-installation *">
+ </iframe>
+ </body>
+ )");
+ iframe_resource.Finish();
+
+ auto* child_frame = To<WebLocalFrameImpl>(MainFrame().FirstChild());
+ auto* child_doc = child_frame->GetFrame()->GetDocument();
+
+ auto* install_element =
+ CreatePrefixedPermissionElement(*child_doc, "install");
+ // PEPC registration should NOT be called for prefixed <install> in subframes.
+ permission_service()->set_pepc_registered_callback(
+ BindOnce(&NotReachedForPEPCRegistered));
+
+ // The element should NOT be valid, with reason "illegal_subframe".
+ EXPECT_TRUE(
+ base::test::RunUntil([&]() { return !install_element->isValid(); }));
+ EXPECT_EQ(install_element->invalidReason(), "illegal_subframe");
+
+ permission_service()->set_pepc_registered_callback(base::NullCallback());
+}
+
+TEST_F(HTMLInstallElementSimTest,
+ PrefixedInstallNotAllowedInCrossOriginSubframe) {
+ SimRequest::Params params;
+ params.response_http_headers = {
+ {"content-security-policy",
+ "frame-ancestors 'self' https://example.test"}};
+ SimRequest main_resource("https://example.test", "text/html");
+ LoadURL("https://example.test");
+ SimRequest iframe_resource("https://cross-example.test/foo.html", "text/html",
+ params);
+ main_resource.Complete(R"(
+ <body>
+ <iframe src='https://cross-example.test/foo.html'
+ allow="web-app-installation *">
+ </iframe>
+ </body>
+ )");
+ iframe_resource.Finish();
+
+ auto* child_frame = To<WebLocalFrameImpl>(MainFrame().FirstChild());
+ auto* child_doc = child_frame->GetFrame()->GetDocument();
+
+ auto* install_element =
+ CreatePrefixedPermissionElement(*child_doc, "install");
+ // PEPC registration should NOT be called for prefixed <install> in subframes.
+ permission_service()->set_pepc_registered_callback(
+ BindOnce(&NotReachedForPEPCRegistered));
+
+ // The element should NOT be valid, with reason "illegal_subframe".
+ EXPECT_TRUE(
+ base::test::RunUntil([&]() { return !install_element->isValid(); }));
+ EXPECT_EQ(install_element->invalidReason(), "illegal_subframe");
+
+ permission_service()->set_pepc_registered_callback(base::NullCallback());
+}
+
+TEST_F(HTMLInstallElementSimTest, PrefixedInstallNotAllowedInSandboxedIframe) {
+ SimRequest main_resource("https://example.test", "text/html");
+ LoadURL("https://example.test");
+ SimRequest iframe_resource("https://example.test/foo.html", "text/html");
+ main_resource.Complete(R"(
+ <body>
+ <iframe src='https://example.test/foo.html'
+ sandbox="allow-scripts allow-same-origin"
+ allow="web-app-installation *">
+ </iframe>
+ </body>
+ )");
+ iframe_resource.Finish();
+
+ auto* child_frame = To<WebLocalFrameImpl>(MainFrame().FirstChild());
+ auto* child_doc = child_frame->GetFrame()->GetDocument();
+
+ auto* install_element =
+ CreatePrefixedPermissionElement(*child_doc, "install");
+ // PEPC registration should NOT be called for prefixed <install> in sandboxed
+ // iframes.
+ permission_service()->set_pepc_registered_callback(
+ BindOnce(&NotReachedForPEPCRegistered));
+
+ EXPECT_TRUE(
+ base::test::RunUntil([&]() { return !install_element->isValid(); }));
+ EXPECT_EQ(install_element->invalidReason(), "illegal_sandbox");
+
+ permission_service()->set_pepc_registered_callback(base::NullCallback());
+}
+
+TEST_F(HTMLInstallElementSimTest,
+ PrefixedInstallNotAllowedInSandboxedMainDocument) {
+ SimRequest::Params params;
+ params.response_http_headers = {
+ {"content-security-policy", "sandbox allow-same-origin allow-scripts"}};
+ SimRequest main_resource("https://example.test", "text/html", params);
+ LoadURL("https://example.test");
+ main_resource.Complete(R"(
+ <body>
+ </body>
+ )");
+
+ auto* install_element =
+ CreatePrefixedPermissionElement(GetDocument(), "install");
+ // PEPC registration should NOT be called for prefixed <install> in sandboxed
+ // documents.
+ permission_service()->set_pepc_registered_callback(
+ BindOnce(&NotReachedForPEPCRegistered));
+
+ EXPECT_TRUE(
+ base::test::RunUntil([&]() { return !install_element->isValid(); }));
+ EXPECT_EQ(install_element->invalidReason(), "illegal_sandbox");
+
+ permission_service()->set_pepc_registered_callback(base::NullCallback());
+}
+
+TEST_F(HTMLInstallElementSimTest, PrefixedInstallAllowedInMainDocument) {
+ SimRequest main_resource("https://example.test", "text/html");
+ LoadURL("https://example.test");
+ main_resource.Complete(R"(
+ <body>
+ </body>
+ )");
+
+ auto* install_element =
+ CreatePrefixedPermissionElement(GetDocument(), "install");
+ EXPECT_TRUE(
+ base::test::RunUntil([&]() { return install_element->isValid(); }));
+ EXPECT_TRUE(install_element->invalidReason().empty());
+ EXPECT_TRUE(GetDocument().IsUseCounted(WebFeature::kHTMLInstallElement));
+}
+
+TEST_F(HTMLCapabilityElementBaseSimTest,
+ PrefixedCapabilityElementsUseCounters) {
+ SimRequest main_resource("https://example.test", "text/html");
+ LoadURL("https://example.test");
+ main_resource.Complete(R"(
+ <body>
+ </body>
+ )");
+
+ struct TestCase {
+ const char* tag;
+ WebFeature feature;
+ };
+ const TestCase kTestCases[] = {
+ {"geolocation", WebFeature::kHTMLGeolocationElement},
+ {"camera", WebFeature::kHTMLCameraElement},
+ {"microphone", WebFeature::kHTMLMicrophoneElement},
+ };
+
+ for (const auto& test_case : kTestCases) {
+ auto* element =
+ CreatePrefixedPermissionElement(GetDocument(), test_case.tag);
+ EXPECT_TRUE(base::test::RunUntil([&]() { return element->isValid(); }));
+ EXPECT_TRUE(element->invalidReason().empty());
+ EXPECT_TRUE(GetDocument().IsUseCounted(test_case.feature));
+ }
+
+ // Also test usermedia element with type attribute.
+ auto* usermedia_element =
+ CreatePrefixedPermissionElement(GetDocument(), "usermedia");
+ usermedia_element->setAttribute(html_names::kTypeAttr,
+ AtomicString("camera"));
+ GetDocument().UpdateStyleAndLayout(DocumentUpdateReason::kTest);
+ GetDocument().View()->UpdateAllLifecyclePhasesForTest();
+ EXPECT_TRUE(
+ base::test::RunUntil([&]() { return usermedia_element->isValid(); }));
+ EXPECT_TRUE(usermedia_element->invalidReason().empty());
+ EXPECT_TRUE(GetDocument().IsUseCounted(WebFeature::kHTMLUserMediaElement));
+}
+
TEST_F(HTMLCapabilityElementBaseSimTest, BlockedByMissingFrameAncestorsCSP) {
GetDocument().GetSettings()->SetDefaultFontSize(12);
SimRequest::Params params;
diff --git a/third_party/blink/renderer/core/html/html_install_element_test.cc b/third_party/blink/renderer/core/html/html_install_element_test.cc
index ea4624b..44fc76a 100644
--- a/third_party/blink/renderer/core/html/html_install_element_test.cc
+++ b/third_party/blink/renderer/core/html/html_install_element_test.cc
@@ -558,4 +558,29 @@
EXPECT_EQ(kResultInvalidData, event->result().AsString());
}
+TEST_F(HTMLInstallElementTestBase, PrefixedElementRegistration) {
+ auto* element = To<HTMLInstallElement>(GetDocument().createElementNS(
+ html_names::xhtmlNamespaceURI, AtomicString("x:install"),
+ ASSERT_NO_EXCEPTION));
+ WaitForElementRegistration(element);
+ EXPECT_TRUE(element->isValid());
+}
+
+TEST_F(HTMLInstallElementTestBase, PrefixedElementActivationSuccess) {
+ auto* element = To<HTMLInstallElement>(GetDocument().createElementNS(
+ html_names::xhtmlNamespaceURI, AtomicString("x:install"),
+ ASSERT_NO_EXCEPTION));
+ WaitForElementRegistration(element);
+
+ element->DispatchEvent(*Event::Create(event_type_names::kDOMActivate));
+ web_install_service_.WaitForCall();
+ web_install_service_.RespondManifestWithSuccess();
+
+ InstallResultEvent* event = WaitForInstallResultEvent(element);
+ EXPECT_NE(event, nullptr);
+ if (event) {
+ EXPECT_EQ(kResultSuccess, event->result().AsString());
+ }
+}
+
} // namespace blink
diff --git a/third_party/blink/web_tests/external/wpt/html/semantics/permission-element/install/iframe-not-allowed.tentative.html b/third_party/blink/web_tests/external/wpt/html/semantics/permission-element/install/iframe-not-allowed.tentative.html
index d43f72b..d3bd54a 100644
--- a/third_party/blink/web_tests/external/wpt/html/semantics/permission-element/install/iframe-not-allowed.tentative.html
+++ b/third_party/blink/web_tests/external/wpt/html/semantics/permission-element/install/iframe-not-allowed.tentative.html
@@ -38,5 +38,50 @@
});
document.body.appendChild(iframe);
}, "Install element is not allowed in an iframe even with permissions policy");
+
+ async_test((t) => {
+ const iframe = document.createElement("iframe");
+ iframe.srcdoc = `
+ <!DOCTYPE html>
+ <body>
+ <script>
+ const install = document.createElementNS("http://www.w3.org/1999/xhtml", "x:install");
+ install.id = "install-element";
+ document.body.appendChild(install);
+ <\/script>
+ </body>
+ `;
+ iframe.onload = t.step_func_done(() => {
+ const install = iframe.contentDocument.getElementById("install-element");
+ assert_false(install.isValid,
+ "Prefixed install element should not be valid inside an iframe");
+ assert_equals(install.invalidReason, "illegal_subframe",
+ "invalidReason should be 'illegal_subframe'");
+ });
+ document.body.appendChild(iframe);
+ }, "Prefixed install element is not allowed in a same-origin iframe");
+
+ async_test((t) => {
+ const iframe = document.createElement("iframe");
+ iframe.allow = "web-app-installation *";
+ iframe.srcdoc = `
+ <!DOCTYPE html>
+ <body>
+ <script>
+ const install = document.createElementNS("http://www.w3.org/1999/xhtml", "x:install");
+ install.id = "install-element";
+ document.body.appendChild(install);
+ <\/script>
+ </body>
+ `;
+ iframe.onload = t.step_func_done(() => {
+ const install = iframe.contentDocument.getElementById("install-element");
+ assert_false(install.isValid,
+ "Prefixed install element should not be valid inside an iframe even with permissions policy");
+ assert_equals(install.invalidReason, "illegal_subframe",
+ "invalidReason should be 'illegal_subframe'");
+ });
+ document.body.appendChild(iframe);
+ }, "Prefixed install element is not allowed in an iframe even with permissions policy");
... (truncated)
Original Bug Report
Bypass of <install> element sandbox restrictions via prefixed createElementNS
Project Fortify, 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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: A logical policy bypass in Blink’s HTMLCapabilityElementBase allows bypassing <install>-specific embedding restrictions, including sandbox and subframe gates. While a standard renderer will safely crash upon successful element registration, a compromised renderer can exploit this missing validation to trigger native web app installation prompts from a sandboxed frame.
Affected files:
third_party/blink/renderer/core/html/html_capability_element_base.cc
Estimated timestamp from git blame: 2026-03-25
Root Cause
In third_party/blink/renderer/core/html/html_capability_element_base.cc, <install>-specific embedding and sandbox restrictions are gated using tag comparisons such as:
if (TagQName() == html_names::kInstallTag && ...)
This invokes the equality operator in third_party/blink/renderer/core/dom/qualified_name.h:
bool operator==(const QualifiedName& other) const {
return impl_ == other.impl_;
}
Because the comparison is prefix-sensitive, an element created with a namespace prefix (e.g., document.createElementNS('http://www.w3.org/1999/xhtml', 'x:install')) will fail this comparison. Its internal tag_name_ is updated with the "x" prefix via SetTagNameForCreateElementNS, causing TagQName() == html_names::kInstallTag to evaluate to false and bypass all renderer-side checks (specifically at lines 635, 644, 1149, 1162, 1270, and 1277).
Impact Analysis and the Dual-Scenario Behavior
1. In a Non-Compromised Renderer (Deterministic Crash)
When a normal webpage inserts a prefixed <x:install> element into a sandboxed frame, the registration checks are bypassed, and the element successfully registers with the browser process. However, immediately upon registration, the renderer executes:
RecordPermissionElementUseCounter(GetDocument(), TagQName());
Inside RecordPermissionElementUseCounter (located in third_party/blink/renderer/core/html/html_capability_element_metrics_util.cc at line 40), the prefixed tag_name fails the check against the null-prefixed html_names::kInstallTag and falls through to the else block, which contains NOTREACHED(). Because NOTREACHED() is fatal in production, the renderer process is terminated immediately. No user interaction or exploitation can occur.
2. In a Compromised Renderer (Sandbox Security Boundary Bypass)
If an attacker has already achieved arbitrary code execution within the sandboxed renderer process:
- The attacker can bypass standard Blink rendering loops, registration, and metrics callbacks entirely, evading the fatal
NOTREACHED()crash. - The attacker’s compromised renderer can directly bind to the
WebInstallServiceMojo interface for a sandboxed top-level document (such as a popup inheriting sandbox flags). - The browser-side binder
WebInstallServiceImpl::CreateIfAllowedonly verifies that the caller is in the primary main frame (IsInPrimaryMainFrame()). It does not validate the calling frame’s sandbox flags. - By directly calling
InstallFromElement(options, callback)on the Mojo interface withtriggered_from_elementset totrue, the browser-side service skips standard permission policy validation and triggers the native OS/browser-level web application installation prompt, allowing a sandboxed context to successfully show installation prompts to the user.
Potential Attack Steps (Compromised Renderer Scenario)
Note: These are potential steps as our tooling does not yet have the ability to run proof-of-concept code.
- From a sandboxed top-level main frame (e.g., a popup opened from a sandboxed iframe with
allow-popups allow-scripts), the compromised renderer requests connection to the browser’sWebInstallServiceMojo remote. - The browser-side
WebInstallServiceImpl::CreateIfAllowedaccepts the connection because the popup is a primary main frame, failing to verify the frame’s sandbox status. - The compromised renderer directly calls the Mojo method
InstallFromElementwith the target app’s installation parameters. - The browser process presents a native web app installation dialog to the user on behalf of the sandboxed origin.
Suggested Remediation
Replace prefix-sensitive equality checks with prefix-insensitive checks. In third_party/blink/renderer/core/html/html_capability_element_base.cc, change tag checks to use HasTagName or IsA type checks:
// Before:
if (TagQName() == html_names::kInstallTag)
// After:
if (HasTagName(html_names::kInstallTag))
// Or:
if (IsA<HTMLInstallElement>(*this))
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.