Medium chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in PermissionElement
DescriptionIncorrect authorization in PermissionElement
ComponentPermissionElement
Sub-componentInternals>Permissions>PermissionElement
Bug ClassAuthorization Error
Tracker517366426
Fix commit32fdc2573db9 (chromium/src) +329/-16
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
third_party/blink/renderer/core/html/html_capability_element_metrics_util.cc
modified
TEST_F
third_party/blink/renderer/core/html/html_capability_element_base_test.cc
modified
for
third_party/blink/renderer/core/html/html_capability_element_base_test.cc
modified
EXPECT_TRUE
third_party/blink/renderer/core/html/html_capability_element_base_test.cc
modified
TEST_F
third_party/blink/renderer/core/html/html_install_element_test.cc
modified
if
third_party/blink/renderer/core/html/html_install_element_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/html/html_install_element_test.cc
  • third_party/blink/renderer/core/html/html_capability_element_base.cc
  • third_party/blink/web_tests/external/wpt/html/semantics/permission-element/install/iframe-not-allowed.tentative.html
  • third_party/blink/renderer/core/html/html_capability_element_base_test.cc
  • third_party/blink/renderer/core/html/html_capability_element_metrics_util.cc
  • third_party/blink/web_tests/external/wpt/html/semantics/permission-element/install/sandbox-not-allowed.tentative.html
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)
Loading diff…

Regression Test / PoC

shipped with the fix
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)
Loading diff…

Original Bug Report

reported by [email protected]

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 WebInstallService Mojo interface for a sandboxed top-level document (such as a popup inheriting sandbox flags).
  • The browser-side binder WebInstallServiceImpl::CreateIfAllowed only 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 with triggered_from_element set to true, 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.

  1. 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’s WebInstallService Mojo remote.
  2. The browser-side WebInstallServiceImpl::CreateIfAllowed accepts the connection because the popup is a primary main frame, failing to verify the frame’s sandbox status.
  3. The compromised renderer directly calls the Mojo method InstallFromElement with the target app’s installation parameters.
  4. 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.

View on issue tracker
Links in the report