CVE-2026-87589
Overview
Files Changed
content/browser/renderer_host/ipc_utils.cccontent/browser/security_exploit_browsertest.cc
Patch
From 784608b0067f6ea6e7d1b66888c725c09fdd4291 Mon Sep 17 00:00:00 2001
From: Zainab Rizvi <rizvis@google.com>
Date: Mon, 03 Aug 2026 06:49:39 -0700
Subject: [PATCH] Normalize LF to CRLF in VerifyNavigationHeaders
OpenURLParams::extra_headers are LF-separated and are normalized to CRLF
by CreateNavigationRequestFromLoadParams() before they are applied to
the outgoing request. HttpRequestHeaders::AddHeadersFromString() splits
only on CRLF, so apply the same normalization in
VerifyNavigationHeaders() so that the allowlist sees the same set of
headers as the request.
TAG=agy
CONV=0f4b35dd-2710-47f6-9c65-c3ab1af7c0f2
Fixed: 518002426
Change-Id: Id0e6a09fef2d42ad8eb71713c39467c1f49a5c3b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8180965
Commit-Queue: Zainab Rizvi <rizvis@google.com>
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1672649}
---
diff --git a/content/browser/renderer_host/ipc_utils.cc b/content/browser/renderer_host/ipc_utils.cc
index 664430f6..fb99c3c 100644
--- a/content/browser/renderer_host/ipc_utils.cc
+++ b/content/browser/renderer_host/ipc_utils.cc
@@ -9,6 +9,7 @@
#include "base/debug/crash_logging.h"
#include "base/debug/dump_without_crashing.h"
+#include "base/strings/string_util.h"
#include "base/strings/to_string.h"
#include "content/browser/bad_message.h"
#include "content/browser/blob_storage/chrome_blob_storage_context.h"
@@ -457,8 +458,15 @@
bool VerifyNavigationHeaders(RenderProcessHost* process,
const std::string& headers) {
+ // Navigation headers may be LF-separated and are normalized to CRLF
+ // before being applied to the outgoing request.
+ // AddHeadersFromString() splits only on CRLF, so apply the same normalization
+ // here to ensure consistent header verification.
+ std::string headers_crlf;
+ base::ReplaceChars(headers, "\n", "\r\n", &headers_crlf);
+
net::HttpRequestHeaders parsed_headers;
- parsed_headers.AddHeadersFromString(headers);
+ parsed_headers.AddHeadersFromString(headers_crlf);
for (net::HttpRequestHeaders::Iterator header(parsed_headers);
header.GetNext();) {
// Headers should be strictly allowlisted because there can be security
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 9909662..fea640bf 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -4476,6 +4476,26 @@
EXPECT_FALSE(rfhi->IsRenderFrameLive());
}
+// Tests that the renderer is killed if it provides arbitrary headers in a
+// navigation request when the headers are separated by LF rather than CRLF.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ ForbiddenHeaderInBeginNavigation_LFSeparated) {
+ GURL start_url(embedded_test_server()->GetURL("a.test", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+ NavigationHeaderInterceptor interceptor(shell()->web_contents());
+ interceptor.set_headers_to_inject("DNT: 1\nCookie: secret=123");
+ interceptor.Activate();
+
+ RenderFrameHostImpl* rfhi = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(rfhi->GetProcess());
+
+ ExecuteScriptAsync(rfhi, "location = '/title2.html';");
+ EXPECT_EQ(bad_message::RFH_INVALID_NAVIGATION_HEADERS, kill_waiter.Wait());
+ EXPECT_FALSE(rfhi->IsRenderFrameLive());
+}
+
// Tests that the navigation succeeds if a renderer process provides arbitrary
// headers in a navigation request when kKillOnInvalidNavigationHeaders is
// disabled.
@@ -4517,6 +4537,29 @@
EXPECT_FALSE(rfhi->IsRenderFrameLive());
}
+// Tests that the renderer is killed if it provides arbitrary headers in an
+// OpenURL request when the headers are separated by LF rather than CRLF.
+// OpenURLParams::extra_headers are normalized from LF to CRLF before they are
+// applied to the outgoing request, so the allowlist check must apply the same
+// normalization.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ ForbiddenHeaderInOpenURL_LFSeparated) {
+ GURL start_url(embedded_test_server()->GetURL("a.test", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+ RenderFrameHostImpl* rfhi = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+
+ auto params = CreateOpenURLParams(
+ embedded_test_server()->GetURL("a.test", "/title2.html"));
+ params->extra_headers = "DNT: 1\nCookie: secret=123";
+
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(rfhi->GetProcess());
+ static_cast<mojom::FrameHost*>(rfhi)->OpenURL(std::move(params));
+ EXPECT_EQ(bad_message::RFH_INVALID_NAVIGATION_HEADERS, kill_waiter.Wait());
+ EXPECT_FALSE(rfhi->IsRenderFrameLive());
+}
+
// Tests that the navigation succeeds if a renderer process provides arbitrary
// headers in an OpenURL request when kKillOnInvalidNavigationHeaders is
// disabled.
Regression Test / PoC
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 9909662..fea640bf 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -4476,6 +4476,26 @@
EXPECT_FALSE(rfhi->IsRenderFrameLive());
}
+// Tests that the renderer is killed if it provides arbitrary headers in a
+// navigation request when the headers are separated by LF rather than CRLF.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ ForbiddenHeaderInBeginNavigation_LFSeparated) {
+ GURL start_url(embedded_test_server()->GetURL("a.test", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+ NavigationHeaderInterceptor interceptor(shell()->web_contents());
+ interceptor.set_headers_to_inject("DNT: 1\nCookie: secret=123");
+ interceptor.Activate();
+
+ RenderFrameHostImpl* rfhi = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(rfhi->GetProcess());
+
+ ExecuteScriptAsync(rfhi, "location = '/title2.html';");
+ EXPECT_EQ(bad_message::RFH_INVALID_NAVIGATION_HEADERS, kill_waiter.Wait());
+ EXPECT_FALSE(rfhi->IsRenderFrameLive());
+}
+
// Tests that the navigation succeeds if a renderer process provides arbitrary
// headers in a navigation request when kKillOnInvalidNavigationHeaders is
// disabled.
@@ -4517,6 +4537,29 @@
EXPECT_FALSE(rfhi->IsRenderFrameLive());
}
+// Tests that the renderer is killed if it provides arbitrary headers in an
+// OpenURL request when the headers are separated by LF rather than CRLF.
+// OpenURLParams::extra_headers are normalized from LF to CRLF before they are
+// applied to the outgoing request, so the allowlist check must apply the same
+// normalization.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ ForbiddenHeaderInOpenURL_LFSeparated) {
+ GURL start_url(embedded_test_server()->GetURL("a.test", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+ RenderFrameHostImpl* rfhi = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+
+ auto params = CreateOpenURLParams(
+ embedded_test_server()->GetURL("a.test", "/title2.html"));
+ params->extra_headers = "DNT: 1\nCookie: secret=123";
+
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(rfhi->GetProcess());
+ static_cast<mojom::FrameHost*>(rfhi)->OpenURL(std::move(params));
+ EXPECT_EQ(bad_message::RFH_INVALID_NAVIGATION_HEADERS, kill_waiter.Wait());
+ EXPECT_FALSE(rfhi->IsRenderFrameLive());
+}
+
// Tests that the navigation succeeds if a renderer process provides arbitrary
// headers in an OpenURL request when kKillOnInvalidNavigationHeaders is
// disabled.
Original Bug Report
VerifyNavigationHeaders allowlist bypass via bare linefeed injection in OpenURL
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 potential validate-before-normalize parser differential between the browser’s navigation header validation and the actual request construction code can allow a compromised renderer to bypass the navigation header allowlist. By utilizing bare linefeed (\n) separators, the initial validation pass fails to extract and check individual headers. Downstream, the navigation code normalizes these linefeeds to CRLF, allowing arbitrary restricted request headers to be sent over the network.
Affected files:
content/browser/renderer_host/ipc_utils.cccontent/browser/renderer_host/navigation_controller_impl.cc
Estimated timestamp from git blame: 2026-03-18
Description
There is a potential validate-before-normalize parser differential between the browser’s navigation header validation code and the actual request construction code. This allows a compromised renderer to bypass the VerifyNavigationHeaders allowlist validation check and inject arbitrary HTTP request headers on browser-initiated cross-process navigations.
Root Cause
VerifyNavigationHeaders (located in content/browser/renderer_host/ipc_utils.cc) validates the renderer-supplied OpenURLParams::extra_headers string before it undergoes linefeed normalization (from LF to CRLF). However, the actual outbound request headers are built from the normalized CRLF string.
Both parsing passes utilize net::HttpRequestHeaders::AddHeadersFromString, which splits lines using only the literal substring "\r\n" as a delimiter:
void HttpRequestHeaders::AddHeadersFromString(std::string_view headers) {
for (std::string_view header : base::SplitStringPieceUsingSubstr(
headers, "\r\n", base::TRIM_WHITESPACE, base::SPLIT_WANT_NONEMPTY)) {
AddHeaderFromString(header);
}
}
1. Validation Pass
When checking a raw string containing only bare linefeed separators (e.g., "X\nX-CSRF-Token: bypass\nAuthorization: Bearer evil"), there is no "\r\n" substring. Consequently, the entire string is processed as a single header line.
Inside AddHeaderFromString, HttpUtil::IsValidHeaderName determines that the derived key ("X\nX-CSRF-Token") contains an invalid token character (\n) and rejects the entire line, leaving parsed_headers completely empty. Since no headers are extracted, the allowlist loop in VerifyNavigationHeaders executes zero times, validation succeeds, and the renderer is not terminated.
2. Normalization and Usage Pass
After successfully bypassing validation, the string is normalized in NavigationControllerImpl::CreateNavigationRequestFromLoadParams:
std::string extra_headers_crlf;
base::ReplaceChars(params.extra_headers, "\n", "\r\n", &extra_headers_crlf);
This expands the linefeeds to CRLF, resulting in: "X\r\nX-CSRF-Token: bypass\r\nAuthorization: Bearer evil".
When NavigationRequest::Create eventually parses this normalized string via AddHeadersFromString, it successfully splits on "\r\n" and extracts multiple distinct headers. The dummy first header "X" is dropped because it contains no colon, but all subsequent headers (such as X-CSRF-Token and Authorization) are successfully registered and sent on the outbound request.
Potential Trigger Path
- A compromised renderer invokes the Mojo endpoint
RemoteFrameHost::OpenURLon a cross-process target frame, supplyingextra_headersstructured with bare\nlinefeeds containing forbidden headers. RenderFrameProxyHost::OpenURLcallsVerifyOpenURLParams, which validates the raw, non-normalized string viaVerifyNavigationHeaders. Due to the bare linefeeds, no headers are parsed, validation returnstrue, and the renderer survives.NavigateFromFrameProxyforwards the raw parameters toNavigationControllerImpl::CreateNavigationRequestFromLoadParams, which normalizes the linefeeds into CRLF viabase::ReplaceChars.NavigationRequestparses the normalized string, registering the injected headers.- The browser issues the network navigation request carrying the forged headers.
(Note: These are potential steps based on static analysis of the codebase, as we have not executed runtime proof-of-concept tests).
Suggested Fix
To remediate this issue, the browser should perform linefeed normalization to CRLF before running validation in VerifyNavigationHeaders, or explicitly reject any renderer-supplied extra_headers string that contains bare \n (without \r) before any processing is done.
Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040
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.