Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Editing
DescriptionImproper input validation in Editing
ComponentEditing
Bug ClassLogic Error
Tracker513192145
Fix commitb9715f6261da (chromium/src) +40/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
for
third_party/blink/renderer/core/editing/serializers/serialization.cc
modified
TEST_F
third_party/blink/renderer/core/editing/serializers/serialization_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/editing/serializers/serialization.cc
  • third_party/blink/renderer/core/editing/serializers/serialization_test.cc
From b9715f6261da998f4ecf164b38024fd355c071ae Mon Sep 17 00:00:00 2001
From: Ashish Kumar <[email protected]>
Date: Mon, 06 Jul 2026 00:18:07 -0700
Subject: [PATCH] Prevent CompleteURLs from resolving into javascript: URLs

CompleteURLs() resolves relative URL attributes in a DocumentFragment
against a base URL. Previously it would emit the resolved value
unconditionally, which could yield a "javascript:" URL either from a
"javascript:" base URL or from an attribute that resolves to one.

Add defense-in-depth checks so that:
- a "javascript:" base URL causes CompleteURLs to bail out entirely, and
- any individual attribute that completes to a "javascript:" URL is
  skipped rather than written back.

The base-URL case is reachable and is now handled. The per-attribute
case is not reachable from current callers, since the parser strips such
attributes when scripting content is disallowed (e.g. clipboard and
drag-and-drop sanitization), but the guard hardens the function against
unsafe resolution.

Bug: 513192145
Change-Id: Ib8267c4b757ac80f3cf2f9ec34216163bc68e070
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8030860
Commit-Queue: Ashish Kumar <[email protected]>
Reviewed-by: Kent Tamura <[email protected]>
Reviewed-by: Rohan Raja <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1657003}
---

diff --git a/third_party/blink/renderer/core/editing/serializers/serialization.cc b/third_party/blink/renderer/core/editing/serializers/serialization.cc
index ef2cc1b..594e12f5 100644
--- a/third_party/blink/renderer/core/editing/serializers/serialization.cc
+++ b/third_party/blink/renderer/core/editing/serializers/serialization.cc
@@ -158,14 +158,25 @@
 
   KURL parsed_base_url(base_url);
 
+  if (parsed_base_url.ProtocolIsJavaScript()) {
+    return;
+  }
+
   for (Element& element : ElementTraversal::DescendantsOf(fragment)) {
     AttributeCollection attributes = element.Attributes();
     // AttributeCollection::iterator end = attributes.end();
     for (const auto& attribute : attributes) {
-      if (element.IsURLAttribute(attribute) && !attribute.Value().empty())
-        changes.push_back(AttributeChange(
-            &element, attribute.GetName(),
-            KURL(parsed_base_url, attribute.Value()).GetString()));
+      if (element.IsURLAttribute(attribute) && !attribute.Value().empty()) {
+        // Defense-in-depth: never resolve a URL attribute into a
+        // "javascript:" URL. Not reachable from current callers, since the
+        // parser strips such attributes when scripting content is disallowed.
+        KURL completed_url(parsed_base_url, attribute.Value());
+        if (completed_url.ProtocolIsJavaScript()) {
+          continue;
+        }
+        changes.push_back(AttributeChange(&element, attribute.GetName(),
+                                          completed_url.GetString()));
+      }
     }
   }
 
diff --git a/third_party/blink/renderer/core/editing/serializers/serialization_test.cc b/third_party/blink/renderer/core/editing/serializers/serialization_test.cc
index 95e84de..6a654f6 100644
--- a/third_party/blink/renderer/core/editing/serializers/serialization_test.cc
+++ b/third_party/blink/renderer/core/editing/serializers/serialization_test.cc
@@ -224,6 +224,31 @@
       StrictlyProcessedMarkup(markup));
 }
 
+TEST_F(SerializationTest,
+       StrictlyProcessedFragmentDoesNotResolveToJavaScriptURL) {
+  const String base_url = "javascript:alert(1)//";
+  const String markup =
+      "<a href='#x'>link</a>"
+      "<img src='image.png'>";
+
+  DocumentFragment* fragment =
+      CreateStrictlyProcessedFragmentFromMarkupWithContext(
+          GetDocument(), markup, 0, markup.length(), base_url);
+  ASSERT_TRUE(fragment);
+  const auto* anchor = To<Element>(fragment->firstChild());
+  ASSERT_TRUE(anchor);
+  EXPECT_FALSE(
+      ProtocolIsJavaScript(anchor->getAttribute(html_names::kHrefAttr)));
+  const auto* image = To<Element>(anchor->nextSibling());
+  ASSERT_TRUE(image);
+  EXPECT_FALSE(ProtocolIsJavaScript(image->getAttribute(html_names::kSrcAttr)));
+
+  const String final_markup = CreateStrictlyProcessedMarkupWithContext(
+      GetDocument(), markup, 0, markup.length(), base_url, kIncludeNode,
+      ResolveUrls::kAll);
+  EXPECT_EQ(kNotFound, final_markup.find("javascript:")) << final_markup;
+}
+
 // Regression test for https://crbug.com/40840595
 TEST_F(SerializationTest, CSSFontFaceLoadCrash) {
   const String markup =
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/editing/serializers/serialization_test.cc b/third_party/blink/renderer/core/editing/serializers/serialization_test.cc
index 95e84de..6a654f6 100644
--- a/third_party/blink/renderer/core/editing/serializers/serialization_test.cc
+++ b/third_party/blink/renderer/core/editing/serializers/serialization_test.cc
@@ -224,6 +224,31 @@
       StrictlyProcessedMarkup(markup));
 }
 
+TEST_F(SerializationTest,
+       StrictlyProcessedFragmentDoesNotResolveToJavaScriptURL) {
+  const String base_url = "javascript:alert(1)//";
+  const String markup =
+      "<a href='#x'>link</a>"
+      "<img src='image.png'>";
+
+  DocumentFragment* fragment =
+      CreateStrictlyProcessedFragmentFromMarkupWithContext(
+          GetDocument(), markup, 0, markup.length(), base_url);
+  ASSERT_TRUE(fragment);
+  const auto* anchor = To<Element>(fragment->firstChild());
+  ASSERT_TRUE(anchor);
+  EXPECT_FALSE(
+      ProtocolIsJavaScript(anchor->getAttribute(html_names::kHrefAttr)));
+  const auto* image = To<Element>(anchor->nextSibling());
+  ASSERT_TRUE(image);
+  EXPECT_FALSE(ProtocolIsJavaScript(image->getAttribute(html_names::kSrcAttr)));
+
+  const String final_markup = CreateStrictlyProcessedMarkupWithContext(
+      GetDocument(), markup, 0, markup.length(), base_url, kIncludeNode,
+      ResolveUrls::kAll);
+  EXPECT_EQ(kNotFound, final_markup.find("javascript:")) << final_markup;
+}
+
 // Regression test for https://crbug.com/40840595
 TEST_F(SerializationTest, CSSFontFaceLoadCrash) {
   const String markup =
Loading diff…

Original Bug Report

reported by [email protected]

UXSS via HTML clipboard sanitizer bypass using malicious SourceURL

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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A logic flaw in Blink’s HTML clipboard sanitization allowed for potential Universal Cross-Site Scripting (UXSS). The sanitizer filters script attributes before resolving relative URLs, enabling fragment-only references to resolve into executable ‘javascript:’ URLs after the filtering pass. This occurs when the clipboard’s SourceURL is set to a malicious ‘javascript:’ payload.

Affected files:

  • third_party/blink/renderer/core/editing/serializers/serialization.cc
  • third_party/blink/renderer/core/dom/element.cc
  • content/browser/renderer_host/clipboard_host_impl.cc
  • third_party/blink/renderer/core/editing/commands/clipboard_commands.cc
  • third_party/blink/renderer/modules/clipboard/clipboard_reader.cc
  • url/url_canon_relative.cc

Estimated timestamp from git blame: 2020-07-13

Summary

A vulnerability in Blink’s HTML clipboard sanitization logic potentially allows for Universal Cross-Site Scripting (UXSS). The issue arises from the order of operations in CreateFragmentFromMarkup, where scripting attributes are stripped before relative URLs are resolved against the clipboard’s SourceURL (base URL). By providing a javascript: URL as the SourceURL and using fragment-only references (e.g., <a href="#x">) in the HTML markup, an attacker can bypass the script filter and inject live javascript: URLs into a victim’s document upon pasting.

Root Cause Analysis

In third_party/blink/renderer/core/editing/serializers/serialization.cc, the function CreateFragmentFromMarkup() handles the parsing and sanitization of HTML fragments retrieved from the clipboard. The logic follows this sequence:

  1. Parse and Strip: It calls fragment->ParseHTML() with a policy that disallows scripting content. This policy invokes StripScriptingAttributes, which checks if an attribute value is a javascript: URL. At this stage, a relative value like "#x" is considered benign and is not stripped.
  2. URL Resolution: After the initial parsing and filtering, it calls CompleteURLs(*fragment, base_url). This function resolves all URL attributes against the provided base_url (the clipboard’s SourceURL).
// third_party/blink/renderer/core/editing/serializers/serialization.cc
DocumentFragment* CreateFragmentFromMarkup(
    Document& document, const String& markup, const String& base_url,
    ParserContentPolicy parser_content_policy) {
  ...
  fragment->ParseHTML(markup, fake_body, /*registry*/ nullptr,
                      parser_content_policy);          // 1. Strips script attrs here
  if (!base_url.empty() && base_url != BlankUrl() &&
      base_url != document.BaseURL()) {
    CompleteURLs(*fragment, base_url);                // 2. Rewrites attrs AFTERWARDS
  }
  return fragment;
}

Chromium’s URL resolver (in url/url_canon_relative.cc) allows bare-fragment references to resolve against any base, including non-hierarchical schemes like javascript:. For example, resolving "#x" against "javascript:alert(1)//" results in "javascript:alert(1)//#x".

Because CompleteURLs does not perform a post-resolution scheme check, it writes the resulting javascript: URL back into the attribute. The final sanitized fragment contains a live script link that was never inspected by the scripting attribute filter.

Potential Exploit Path

  1. Injection: A compromised renderer writes HTML to the clipboard with a malicious SourceURL. For example:
    • Markup: <a href="#x">Click me</a>
    • SourceURL: javascript:alert(document.domain)// Note: These are suggested/potential steps; our tooling has not run this code.
  2. Paste: A user pastes this content into a victim site’s contenteditable area. ClipboardCommands::GetFragmentFromClipboard retrieves the markup and the malicious SourceURL as the base_url.
  3. Bypass: The sanitizer parses the markup. The href="#x" passes StripScriptingAttributes. Then CompleteURLs rewrites it to javascript:alert(document.domain)//#x.
  4. Execution: The malicious link is inserted into the victim origin. If the user clicks the link, the script executes in the context of the victim origin.

Impact

This bypass could allow cross-origin script execution (UXSS) whenever a user pastes content into a site that uses Blink’s standard HTML sanitization (e.g., Webmail, document editors). It affects both the standard paste mechanism and the Async Clipboard API (navigator.clipboard.read()).

Suggested Fix

CompleteURLs should be updated to perform a scheme check after URL resolution. If a URL resolves to a javascript: scheme and the current policy disallows scripting content, the attribute should be sanitized or the change should be rejected.

Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e


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