Chrome · Parser
CVE-2026-14058
Logic Error in Parser
Overview
Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
forthird_party/blink/renderer/core/frame/csp/content_security_policy.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/frame/csp/content_security_policy_test.cc |
modified | |
TokenPreloadScannerthird_party/blink/renderer/core/html/parser/html_preload_scanner.h |
modified |
Files Changed
third_party/blink/renderer/core/frame/csp/content_security_policy.ccthird_party/blink/renderer/core/frame/csp/content_security_policy.hthird_party/blink/renderer/core/frame/csp/content_security_policy_test.ccthird_party/blink/renderer/core/html/parser/html_preload_scanner.ccthird_party/blink/renderer/core/html/parser/html_preload_scanner.hthird_party/blink/renderer/core/html/parser/html_preload_scanner_test.cc
Patch
From fbbde71d8f0cf193c6cb18add3a9fa9dd5f6d54b Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <apaseltiner@chromium.org>
Date: Mon, 01 Jun 2026 06:41:44 -0700
Subject: [PATCH] Enforce CSP base-uri in HTML preload scanner
The Blink HTML preload scanner failed to validate <base href> tags
against the Content Security Policy 'base-uri' directive. This allowed
an attacker with HTML injection capabilities to redirect speculative
subresource fetches to an external origin, potentially leaking sensitive
relative resource paths and the page URL.
This patch updates TokenPreloadScanner to enforce the 'base-uri' CSP
directive when processing <base> tags.
Two approaches were considered for this fix:
1. Scanner-side validation (Chosen): Validate the <base> tag immediately
when the scanner encounters it. This ensures the scanner's internal
base URL state is always CSP-compliant. It prevents all consumers
of the scanner's base URL (including CSSPreloadScanner) from using
a malicious origin. This approach requires copying CSP policies to
the background scanner thread via CachedDocumentParameters.
2. Fetch-side validation: Defer validation until PreloadRequest::Start()
on the main thread. While this avoids copying CSP data, it would
result in redundant CSP checks for every relative subresource
discovered, and it would leave the scanner in a potentially
vulnerable internal state that could be exploited by future scanner
features.
Scanner-side validation was chosen for its architectural consistency
with how the main parser handles <base> tags and for its superior
performance characteristics by avoiding redundant checks during resource
resolution.
Fixed: 502354038
Change-Id: Ie157e2351740e65b9f9e1ba97142e39c167764a4
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7782696
Reviewed-by: Antonio Sartori <antoniosartori@chromium.org>
Commit-Queue: Andrew Paseltiner <apaseltiner@chromium.org>
Reviewed-by: Yoav Weiss (@Shopify) <yoavweiss@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1639348}
---
diff --git a/third_party/blink/renderer/core/frame/csp/content_security_policy.cc b/third_party/blink/renderer/core/frame/csp/content_security_policy.cc
index 33399df..06d6e4b 100644
--- a/third_party/blink/renderer/core/frame/csp/content_security_policy.cc
+++ b/third_party/blink/renderer/core/frame/csp/content_security_policy.cc
@@ -1041,6 +1041,22 @@
RedirectStatus::kNoRedirect);
}
+// static
+bool ContentSecurityPolicy::AllowBaseURI(
+ const KURL& url,
+ const Vector<network::mojom::blink::ContentSecurityPolicyPtr>& policies) {
+ for (const auto& policy : policies) {
+ if (!CSPDirectiveListAllowFromSource(
+ *policy, /*policy=*/nullptr, CSPDirectiveName::BaseURI,
+ /*document_url=*/KURL(), url, url, RedirectStatus::kNoRedirect,
+ ReportingDisposition::kSuppressReporting)
+ .IsAllowed()) {
+ return false;
+ }
+ }
+ return true;
+}
+
bool ContentSecurityPolicy::AllowConnectToSource(
const KURL& url,
const KURL& url_before_redirects,
diff --git a/third_party/blink/renderer/core/frame/csp/content_security_policy.h b/third_party/blink/renderer/core/frame/csp/content_security_policy.h
index df7b086..a7c39c8 100644
--- a/third_party/blink/renderer/core/frame/csp/content_security_policy.h
+++ b/third_party/blink/renderer/core/frame/csp/content_security_policy.h
@@ -413,6 +413,10 @@
// main world CSP. See ExecutionContext::GetContentSecurityPolicyForWorld.
static bool ShouldBypassMainWorldDeprecated(const DOMWrapperWorld* world);
+ static bool AllowBaseURI(
+ const KURL&,
+ const Vector<network::mojom::blink::ContentSecurityPolicyPtr>&);
+
static bool IsNonceableElement(const Element*);
static const char* GetDirectiveName(CSPDirectiveName type);
diff --git a/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc b/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc
index 789a9fd..40b04c0 100644
--- a/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc
+++ b/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc
@@ -1886,4 +1886,24 @@
}
}
+TEST_F(ContentSecurityPolicyTest, StaticAllowBaseURI) {
+ KURL allowed_base("https://example.test/");
+ KURL blocked_base("https://not-example.test/");
+
+ // Empty policies should allow everything.
+ Vector<network::mojom::blink::ContentSecurityPolicyPtr> empty_policies;
+ EXPECT_TRUE(
+ ContentSecurityPolicy::AllowBaseURI(allowed_base, empty_policies));
+ EXPECT_TRUE(
+ ContentSecurityPolicy::AllowBaseURI(blocked_base, empty_policies));
+
+ // Policy with base-uri 'self'.
+ Vector<network::mojom::blink::ContentSecurityPolicyPtr> policies =
+ ParseContentSecurityPolicies(
+ "base-uri 'self'", ContentSecurityPolicyType::kEnforce,
+ ContentSecurityPolicySource::kHTTP, *secure_origin);
+ EXPECT_TRUE(ContentSecurityPolicy::AllowBaseURI(allowed_base, policies));
+ EXPECT_FALSE(ContentSecurityPolicy::AllowBaseURI(blocked_base, policies));
+}
+
} // namespace blink
diff --git a/third_party/blink/renderer/core/html/parser/html_preload_scanner.cc b/third_party/blink/renderer/core/html/parser/html_preload_scanner.cc
index 58e071db..88c7e7d 100644
--- a/third_party/blink/renderer/core/html/parser/html_preload_scanner.cc
+++ b/third_party/blink/renderer/core/html/parser/html_preload_scanner.cc
@@ -33,6 +33,7 @@
#include "base/task/sequenced_task_runner.h"
#include "base/trace_event/trace_event.h"
+#include "services/network/public/mojom/content_security_policy.mojom-blink.h"
#include "third_party/blink/public/common/features.h"
#include "third_party/blink/public/mojom/fetch/fetch_api_request.mojom-blink.h"
#include "third_party/blink/public/mojom/script/script_type.mojom-blink.h"
@@ -45,6 +46,7 @@
#include "third_party/blink/renderer/core/css/parser/sizes_attribute_parser.h"
#include "third_party/blink/renderer/core/dom/document.h"
#include "third_party/blink/renderer/core/execution_context/security_context.h"
+#include "third_party/blink/renderer/core/frame/csp/content_security_policy.h"
#include "third_party/blink/renderer/core/frame/local_frame.h"
#include "third_party/blink/renderer/core/frame/settings.h"
#include "third_party/blink/renderer/core/frame/viewport_data.h"
@@ -1161,7 +1163,10 @@
KURL url(document_url_,
StripLeadingAndTrailingHtmlSpaces(href_attribute->Value()));
bool is_valid_base_url =
- url.IsValid() && !url.ProtocolIsData() && !url.ProtocolIsJavaScript();
+ url.IsValid() && !url.ProtocolIsData() && !url.ProtocolIsJavaScript() &&
+ ContentSecurityPolicy::AllowBaseURI(
+ url, document_parameters_->content_security_policy);
+
predicted_base_element_url_ = is_valid_base_url ? url : KURL();
}
}
@@ -1342,6 +1347,14 @@
: kPreloadLazyLoadImageType;
probe::GetDisabledImageTypes(document->GetExecutionContext(),
&disabled_image_types);
+ if (document->GetExecutionContext() &&
+ document->GetExecutionContext()->GetContentSecurityPolicy()) {
+ for (const auto& policy : document->GetExecutionContext()
+ ->GetContentSecurityPolicy()
+ ->GetParsedPolicies()) {
+ content_security_policy.push_back(policy->Clone());
+ }
+ }
}
// static
diff --git a/third_party/blink/renderer/core/html/parser/html_preload_scanner.h b/third_party/blink/renderer/core/html/parser/html_preload_scanner.h
index 9f2d424..c04eb57a 100644
--- a/third_party/blink/renderer/core/html/parser/html_preload_scanner.h
+++ b/third_party/blink/renderer/core/html/parser/html_preload_scanner.h
@@ -35,6 +35,7 @@
#include "base/memory/weak_ptr.h"
#include "base/task/sequenced_task_runner.h"
#include "services/network/public/cpp/client_hints.h"
+#include "services/network/public/mojom/content_security_policy.mojom-blink.h"
#include "third_party/blink/public/common/features.h"
#include "third_party/blink/renderer/core/core_export.h"
#include "third_party/blink/renderer/core/css/media_values_cached.h"
@@ -103,6 +104,8 @@
static std::optional<features::LcppPreloadLazyLoadImageType>
preload_lazy_load_image_type_for_testing;
HashSet<String> disabled_image_types;
+ Vector<network::mojom::blink::ContentSecurityPolicyPtr>
+ content_security_policy;
};
class TokenPreloadScanner {
diff --git a/third_party/blink/renderer/core/html/parser/html_preload_scanner_test.cc b/third_party/blink/renderer/core/html/parser/html_preload_scanner_test.cc
index 821b996..5dc7b2b 100644
--- a/third_party/blink/renderer/core/html/parser/html_preload_scanner_test.cc
+++ b/third_party/blink/renderer/core/html/parser/html_preload_scanner_test.cc
@@ -14,6 +14,7 @@
#include "third_party/blink/public/common/features.h"
#include "third_party/blink/public/platform/web_runtime_features.h"
#include "third_party/blink/renderer/core/css/media_values_cached.h"
+#include "third_party/blink/renderer/core/frame/csp/content_security_policy.h"
#include "third_party/blink/renderer/core/frame/local_dom_window.h"
#include "third_party/blink/renderer/core/frame/settings.h"
#include "third_party/blink/renderer/core/html/cross_origin_attribute.h"
@@ -28,6 +29,7 @@
#include "third_party/blink/renderer/platform/exported/wrapped_resource_response.h"
#include "third_party/blink/renderer/platform/loader/fetch/client_hints_preferences.h"
#include "third_party/blink/renderer/platform/network/http_names.h"
+#include "third_party/blink/renderer/platform/network/http_parsers.h"
#include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc b/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc
index 789a9fd..40b04c0 100644
--- a/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc
+++ b/third_party/blink/renderer/core/frame/csp/content_security_policy_test.cc
@@ -1886,4 +1886,24 @@
}
}
+TEST_F(ContentSecurityPolicyTest, StaticAllowBaseURI) {
+ KURL allowed_base("https://example.test/");
+ KURL blocked_base("https://not-example.test/");
+
+ // Empty policies should allow everything.
+ Vector<network::mojom::blink::ContentSecurityPolicyPtr> empty_policies;
+ EXPECT_TRUE(
+ ContentSecurityPolicy::AllowBaseURI(allowed_base, empty_policies));
+ EXPECT_TRUE(
+ ContentSecurityPolicy::AllowBaseURI(blocked_base, empty_policies));
+
+ // Policy with base-uri 'self'.
+ Vector<network::mojom::blink::ContentSecurityPolicyPtr> policies =
+ ParseContentSecurityPolicies(
+ "base-uri 'self'", ContentSecurityPolicyType::kEnforce,
+ ContentSecurityPolicySource::kHTTP, *secure_origin);
+ EXPECT_TRUE(ContentSecurityPolicy::AllowBaseURI(allowed_base, policies));
+ EXPECT_FALSE(ContentSecurityPolicy::AllowBaseURI(blocked_base, policies));
+}
+
} // namespace blink
diff --git a/third_party/blink/renderer/core/html/parser/html_preload_scanner_test.cc b/third_party/blink/renderer/core/html/parser/html_preload_scanner_test.cc
index 821b996..5dc7b2b 100644
--- a/third_party/blink/renderer/core/html/parser/html_preload_scanner_test.cc
+++ b/third_party/blink/renderer/core/html/parser/html_preload_scanner_test.cc
@@ -14,6 +14,7 @@
#include "third_party/blink/public/common/features.h"
#include "third_party/blink/public/platform/web_runtime_features.h"
#include "third_party/blink/renderer/core/css/media_values_cached.h"
+#include "third_party/blink/renderer/core/frame/csp/content_security_policy.h"
#include "third_party/blink/renderer/core/frame/local_dom_window.h"
#include "third_party/blink/renderer/core/frame/settings.h"
#include "third_party/blink/renderer/core/html/cross_origin_attribute.h"
@@ -28,6 +29,7 @@
#include "third_party/blink/renderer/platform/exported/wrapped_resource_response.h"
#include "third_party/blink/renderer/platform/loader/fetch/client_hints_preferences.h"
#include "third_party/blink/renderer/platform/network/http_names.h"
+#include "third_party/blink/renderer/platform/network/http_parsers.h"
#include "third_party/blink/renderer/platform/testing/runtime_enabled_features_test_helpers.h"
#include "third_party/blink/renderer/platform/testing/url_loader_mock_factory.h"
#include "third_party/blink/renderer/platform/testing/url_test_helpers.h"
@@ -575,7 +577,7 @@
&GetDocument(), test_case.expected_browsing_topics);
}
- private:
+ protected:
std::unique_ptr<HTMLPreloadScanner> scanner_;
};
@@ -1238,6 +1240,47 @@
}
}
+TEST_F(HTMLPreloadScannerTest, BaseURICSPEnforcement) {
+ // Regression test for crbug.com/502354038.
+ KURL document_url("http://whatever.test/");
+ NavigateTo(document_url);
+
+ // Set up the document with a CSP that restricts base-uri to 'none'.
+ GetDocument().GetExecutionContext()->GetContentSecurityPolicy()->AddPolicies(
+ ParseContentSecurityPolicies(
+ "base-uri 'none'",
+ network::mojom::blink::ContentSecurityPolicyType::kEnforce,
+ network::mojom::blink::ContentSecurityPolicySource::kHTTP,
+ document_url));
+
+ // Create the scanner manually after setting up the CSP.
+ HTMLParserOptions options(&GetDocument());
+ scanner_ = std::make_unique<HTMLPreloadScanner>(
+ std::make_unique<HTMLTokenizer>(options), document_url,
+ std::make_unique<CachedDocumentParameters>(&GetDocument()),
+ CreateMediaValuesData(), TokenPreloadScanner::ScannerType::kMainDocument,
+ /* script_token_scanner=*/nullptr,
+ /* take_preload=*/HTMLPreloadScanner::TakePreloadFn(),
+ Vector<ElementLocator>());
+
+ HTMLMockHTMLResourcePreloader preloader(GetDocument().Url());
+
+ // HTML with a cross-origin <base> tag and a relative image.
+ // The <base> tag should be blocked by CSP, so the image should be resolved
+ // against the document URL, not the blocked base URL.
+ scanner_->AppendToEnd(
+ String("<base href='http://attacker.test/'><img src='test.png'>"));
+
+ std::unique_ptr<PendingPreloadData> preload_data = scanner_->Scan(KURL());
+ preloader.TakePreloadData(std::move(preload_data));
+
+ // EXPECTATION: The base URL used for the preload request should be the
+ // original document base URL, NOT the attacker-controlled one from the <base>
+ // tag, because 'http://attacker.test/' violates 'base-uri 'none''.
+ preloader.PreloadRequestVerification(ResourceType::kImage, "test.png",
+ nullptr, 0, ClientHintsPreferences());
+}
+
TEST_F(HTMLPreloadScannerTest, testNonce) {
NonceTestCase test_cases[] = {
{"http://example.test", "<script src='/script'></script>", ""},
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page