Medium watch CVSS 3.1 chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Editing
DescriptionImproper input validation in Editing
ComponentEditing
Bug ClassInput Validation
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker513192145
Fix commitb9715f6261da (chromium/src) +40/-4
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-14 (per vendor advisory)
Fix landed2026-07-06 (public git)
Disclosed2026-08-25
Patch gap50 days — fix public in source before the advisory

Background

`CompleteURLs`
a helper in third_party/blink/renderer/core/editing/serializers/serialization.cc that walks a DocumentFragment and rewrites every non-empty URL attribute (per Element::IsURLAttribute) to its absolute form resolved against a supplied base URL.
Strictly processed fragments
markup sanitised for untrusted transfer paths such as clipboard paste and drag-and-drop (exercised by CreateStrictlyProcessedFragmentFromMarkupWithContext and CreateStrictlyProcessedMarkupWithContext in the test), where the parser already strips script-bearing attributes.
`javascript:` URL
a URL whose activation (e.g. clicking an href) executes script in the context of the document containing it; KURL::ProtocolIsJavaScript() detects the scheme.
Relative resolution against an opaque base
a fragment-only reference such as #x resolved against javascript:alert(1)// yields javascript:alert(1)//#x, keeping the base’s script and turning the fragment into a comment.

Root Cause Analysis

Before the patch, CompleteURLs parsed base_url into parsed_base_url and, for every URL attribute, unconditionally pushed an AttributeChange with KURL(parsed_base_url, attribute.Value()).GetString(), never checking the scheme of either the base or the result. Sanitisation upstream removes javascript: attribute values in the markup itself, but it cannot see a script URL that only appears after resolution, so a benign-looking href='#x' became javascript:alert(1)//#x when the base URL was a javascript: URL (the regression test’s case). This breaks the invariant that strictly processed (sanitised) fragments contain no script-executing URLs.

The fix adds an early return when parsed_base_url.ProtocolIsJavaScript() — before any attribute is visited, so no attribute is rewritten at all — and, as defense-in-depth, skips (continue) any individual attribute whose completed_url is a javascript: URL; the commit message states only the base-URL case is reachable from current callers.

Key insight
URL completion was performed after sanitisation without re-validating its output, so a javascript: base URL could manufacture a script URL from harmless relative attributes. The fix refuses to complete URLs against a javascript: base and never writes back a completed javascript: URL.

Attack Path

  1. Attacker controls the paste/drop base URL The fragment’s base URL comes from the transfer source (e.g. source-URL metadata accompanying clipboard or drag-and-drop HTML); the attacker arranges for it to be a javascript: URL, though exactly which sources let web content set it is not shown in the diff (inferred).
  2. Markup survives sanitisation The accompanying HTML contains only innocuous relative URL attributes such as <a href='#x'>, which the strict sanitiser has no reason to remove.
  3. `CompleteURLs` resolves into `javascript:` During strictly processed fragment creation or serialization (the test uses ResolveUrls::kAll), the relative href is resolved against the javascript: base and written back as a javascript: URL.
  4. Victim activates the link After the user pastes or drops the content into an editable region of a victim page, clicking the resulting link would execute script in that page’s context (inferred; the patch shows only that the javascript: URL is produced).

Impact Assessment

An attacker who can influence the base URL of pasted or dropped HTML could plant javascript: links into a victim page’s editable content, giving a script-injection (XSS-style) primitive in the renderer within the target page’s origin if the user then activates the link (inferred). It requires user interaction (a paste or drop, and likely a click) and control of the transfer’s base URL; the advisory classifies it as “Improper input validation in Editing” and does not report in-the-wild exploitation. The per-attribute guard is described by the commit as hardening for a case not reachable from current callers.

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_test.cc
  • third_party/blink/renderer/core/editing/serializers/serialization.cc

Audit Directions

  • Post-sanitisation transformations
    Look for any step that rewrites attributes after sanitisation (URL completion, srcset rewriting, base-href handling, image data replacement in paste paths) and confirm the output is re-validated against the same rules as the input.
  • Untrusted base URLs
    Audit other callers that resolve relative URLs against a base supplied by clipboard, drag data, or DataTransfer metadata, including srcset and CSS url() handling, for missing scheme checks on the base.
  • Opaque-scheme resolution quirks
    Check URL attribute handling for bases with opaque paths (javascript:, data:, blob:) where fragment-only or empty references inherit the base’s scheme and content.
From b9715f6261da998f4ecf164b38024fd355c071ae Mon Sep 17 00:00:00 2001
From: Ashish Kumar <ashishkum@microsoft.com>
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 <ashishkum@microsoft.com>
Reviewed-by: Kent Tamura <tkent@chromium.org>
Reviewed-by: Rohan Raja <roraja@microsoft.com>
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 vm...@google.com

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