CVE-2026-87537
Overview
Background
- `bypass_redirect_checks`
- a flag that tells the navigation loader to skip redirect safety validation (the check the shipped test names as
IsSafeRedirectTarget()) for a redirect, so a redirect to an otherwise-blocked target is permitted. - `URLResponseHead`
- the
network::mojomresponse-header struct delivered over theURLLoaderClientmojo pipe from aURLLoaderFactoryto the client; before the patch it carried abypass_redirect_checksbool field. - `WebRequestProxyingURLLoaderFactory`
- a browser-process (UI-thread) proxying
URLLoaderFactoryused by the Extensions WebRequest API that legitimately authorizes extension-generated redirects to bypass those checks. - `NavigationRequest` / `NavigationHandle`
- the browser-process, UI-thread objects that own a navigation’s state, identified by a
navigation_id; the fix stores the bypass authorization here instead of on the wire.
Root Cause Analysis
Before the patch, redirect-bypass authorization was communicated as a data field, bypass_redirect_checks, inside network::mojom::URLResponseHead, which crosses process boundaries over the URLLoaderClient pipe. In the redirect-handling code in content/browser/loader/navigation_url_loader_impl.cc, when features::kBypassRedirectChecksPerRequest was enabled the browser read this authorization directly from head->bypass_redirect_checks — data supplied by whatever process produced the response head — and used it to skip redirect safety checks. The comment removed from url_response_head.mojom (“True when a proxy will handle the redirect checks”) and the CL description both state the invariant that was violated: the flag “is only ever intended to be set by browser-process proxying URLLoaderFactories,” yet nothing enforced that only such a browser-process proxy could set it.
The fix removes the field from URLResponseHead entirely and instead records the authorization on the UI-thread NavigationRequest via SetBypassRedirectChecksForNextRedirect(), keyed and re-checked against the navigation_id; NavigationURLLoaderImpl then consumes that in-memory browser-side flag with ConsumeBypassRedirectChecksForNextRedirect() rather than trusting the wire value. The shipped test PerRequestBypassRedirectChecksRequiresFactoryFlag confirms the intended behavior: a bypass bit delivered over the pipe must by itself yield net::ERR_UNSAFE_REDIRECT for a file:/// redirect target.
URLResponseHead — not just the trusted browser-process proxy — could grant itself the bypass; the fix moves that authorization to authoritative browser-side NavigationRequest state that only in-process proxying code can set and that is matched against the navigation_id.Attack Path
- Enable the vulnerable path
With
features::kBypassRedirectChecksPerRequestactive, the browser’s redirect handler innavigation_url_loader_impl.ccderivesbypass_redirect_checksfromhead->bypass_redirect_checks, i.e. from a field carried over theURLLoaderClientpipe. - Supply a forged authorization
A process that produces the
URLResponseHeadfor a navigation response setsbypass_redirect_checks = trueon the head, without being the browser-process proxy the flag was reserved for (missing authorization). - Skip the redirect safety check
The browser trusts the flag and bypasses redirect validation, so a redirect whose target would fail
IsSafeRedirectTarget()(the shipped test usesfile:///) is followed instead of being rejected withnet::ERR_UNSAFE_REDIRECT. - Reach a restricted redirect target (inferred) — Bypassing the check would allow a navigation to be redirected to a target normally blocked by that safety check, such as a local-file or otherwise restricted scheme; the diff proves only that the check is skipped, not the concrete end effect.
Impact Assessment
bypass_redirect_checks field on a navigation’s URLResponseHead could cause the browser process to skip redirect safety validation and follow a redirect to a target that would otherwise be rejected as unsafe. The precondition is that features::kBypassRedirectChecksPerRequest is enabled and that the attacker can produce or influence the response head for a navigation; the advisory classifies this as “Missing authorization in Extensions” and reports no exploited-in-the-wild status. Any further escalation from an unsafe redirect — for example navigation to a privileged or local scheme leading to information disclosure or same-origin abuse — is (inferred) and not demonstrated by the diff.Changed Functions
| Function | Change | Notes |
|---|---|---|
AuthorizeBypassRedirectChecksextensions/browser/api/web_request/web_request_proxying_url_loader_factory.cc |
modified | |
UpdateRequestInfoextensions/browser/api/web_request/web_request_proxying_url_loader_factory.cc |
modified | |
ifextensions/browser/api/web_request/web_request_proxying_url_loader_factory.cc |
modified | |
parent_routing_id_extensions/browser/extension_navigation_ui_data.cc |
modified | |
frame_tree_node_id_extensions/browser/extension_navigation_ui_data.cc |
modified | |
TestRedirectInterceptorcontent/browser/loader/navigation_url_loader_impl_unittest.cc |
modified |
Files Changed
content/browser/blob_storage/blob_url_browsertest.ccextensions/browser/extension_navigation_ui_data.cccontent/browser/renderer_host/navigation_request.hcontent/browser/loader/navigation_url_loader_impl_unittest.ccextensions/browser/api/web_request/web_request_proxying_url_loader_factory.ccextensions/browser/api/web_request/web_request_proxying_url_loader_factory.hcontent/public/test/mock_navigation_handle.hcontent/browser/loader/navigation_url_loader_impl.ccservices/network/public/mojom/url_response_head.mojomcontent/browser/renderer_host/navigation_request.cccontent/public/browser/navigation_handle.hextensions/browser/extension_navigation_ui_data.h
Audit Directions
- Authorization carried as mojo data fieldsAudit other
network::mojom(and IPC) struct fields that encode a trust or authorization decision — especially booleans set by trusted browser-process code but readable/writable by less-privileged producers — and confirm the decision is enforced from authoritative in-process state, not the wire value. - Feature-gated trust divergenceReview code where a feature flag (like
kBypassRedirectChecksPerRequest) switches the source of a security-relevant value between a trusted member and an externally supplied field, since the enabled branch may silently relax an existing enforcement. - Navigation-scoped authorizations without identity checksLook for authorizations applied to “the current navigation/frame” that are not re-validated against a
navigation_id(or equivalent), as they may be applied to a different, unintended navigation in the sameFrameTreeNode.
Patch
From 40aa5a35e86fe4c46f19655c29984e93a1b20b5f Mon Sep 17 00:00:00 2001
From: Nidhi Jaju <nidhijaju@chromium.org>
Date: Thu, 13 Aug 2026 20:48:55 -0700
Subject: [PATCH] [Navigation] Move redirect check bypass state to NavigationHandle
Currently, bypass_redirect_checks is passed across process boundaries
in network::mojom::URLResponseHead. However, this flag is only ever
intended to be set by browser-process proxying URLLoaderFactories
(such as the WebRequest API proxying loader factory on the UI thread)
when authorizing a redirect.
This CL removes bypass_redirect_checks from URLResponseHead and instead
records the authorization state directly on the UI-thread
NavigationHandle/NavigationRequest identified by the navigation_id.
When NavigationURLLoaderImpl receives a redirect, it consumes the
in-memory flag from NavigationHandle rather than reading from
URLResponseHead.
Bug: 498732709
Change-Id: I62d17b56bac8a94f7be03772620149b9ab9ba9af
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8254718
Commit-Queue: Nidhi Jaju <nidhijaju@chromium.org>
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
Reviewed-by: Sam McNally <sammc@chromium.org>
Reviewed-by: Andrea Orru <andreaorru@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1679332}
---
diff --git a/content/browser/loader/navigation_url_loader_impl.cc b/content/browser/loader/navigation_url_loader_impl.cc
index 6c52418..bf56800 100644
--- a/content/browser/loader/navigation_url_loader_impl.cc
+++ b/content/browser/loader/navigation_url_loader_impl.cc
@@ -66,6 +66,7 @@
#include "content/public/browser/content_browser_client.h"
#include "content/public/browser/download_utils.h"
#include "content/public/browser/frame_accept_header.h"
+#include "content/public/browser/navigation_handle.h"
#include "content/public/browser/navigation_ui_data.h"
#include "content/public/browser/network_service_instance.h"
#include "content/public/browser/network_service_util.h"
@@ -1567,10 +1568,27 @@
resource_request().is_outermost_main_frame);
net::Error error = net::OK;
- bool bypass_redirect_checks =
- base::FeatureList::IsEnabled(features::kBypassRedirectChecksPerRequest)
- ? head->bypass_redirect_checks
- : bypass_redirect_checks_;
+ bool bypass_redirect_checks = false;
+ if (base::FeatureList::IsEnabled(features::kBypassRedirectChecksPerRequest)) {
+ // A proxying URLLoaderFactory may authorize a redirect to bypass safety
+ // checks. This authorization is set directly on the NavigationRequest
+ // in the browser process. NavigationURLLoaderImpl doesn't have a direct
+ // pointer to the NavigationRequest, so we look it up via the
+ // FrameTreeNode. We check the navigation ID to ensure we don't apply the
+ // bypass to a different navigation in the same frame.
+ if (FrameTreeNode* frame_tree_node =
+ FrameTreeNode::GloballyFindByID(frame_tree_node_id_)) {
+ if (NavigationRequest* nav_request =
+ frame_tree_node->navigation_request()) {
+ if (nav_request->GetNavigationId() == request_info_->navigation_id) {
+ bypass_redirect_checks =
+ nav_request->ConsumeBypassRedirectChecksForNextRedirect();
+ }
+ }
+ }
+ } else {
+ bypass_redirect_checks = bypass_redirect_checks_;
+ }
if (url_.SchemeIsBlob()) {
// Loading a blob URL never produces a redirect.
diff --git a/content/browser/renderer_host/navigation_request.cc b/content/browser/renderer_host/navigation_request.cc
index eb00b221..b1d10c220 100644
--- a/content/browser/renderer_host/navigation_request.cc
+++ b/content/browser/renderer_host/navigation_request.cc
@@ -10364,6 +10364,29 @@
return navigation_id_;
}
+void NavigationRequest::SetBypassRedirectChecksForNextRedirect(bool bypass) {
+ bypass_redirect_checks_for_next_redirect_ = bypass;
+}
+
+bool NavigationRequest::ConsumeBypassRedirectChecksForNextRedirect() {
+ bool bypass = bypass_redirect_checks_for_next_redirect_;
+ bypass_redirect_checks_for_next_redirect_ = false;
+ return bypass;
+}
+
+void NavigationHandle::SetBypassRedirectChecksForNextRedirect(
+ FrameTreeNodeId frame_tree_node_id,
+ int64_t navigation_id) {
+ FrameTreeNode* frame_tree_node =
+ FrameTreeNode::GloballyFindByID(frame_tree_node_id);
+ if (frame_tree_node && frame_tree_node->navigation_request() &&
+ frame_tree_node->navigation_request()->GetNavigationId() ==
+ navigation_id) {
+ frame_tree_node->navigation_request()
+ ->SetBypassRedirectChecksForNextRedirect(true);
+ }
+}
+
ukm::SourceId NavigationRequest::GetNextPageUkmSourceId() {
// If the navigation is restoring from back-forward cache, the UKM id
// will get restored, too.
diff --git a/content/browser/renderer_host/navigation_request.h b/content/browser/renderer_host/navigation_request.h
index 3130273..d379345 100644
--- a/content/browser/renderer_host/navigation_request.h
+++ b/content/browser/renderer_host/navigation_request.h
@@ -527,6 +527,8 @@
std::optional<url::Origin> GetOriginToCommit() override;
bool NeedsUrlLoader() override;
bool IsInitialWebUINavigation() override;
+ void SetBypassRedirectChecksForNextRedirect(bool bypass) override;
+ bool ConsumeBypassRedirectChecksForNextRedirect() override;
bool IsPageActivation() const override;
bool IsNavigatingFromInitialEmptyDocument() const override;
bool IsBlockedByConnectionAllowlist() const override;
@@ -3536,6 +3538,10 @@
// the network service.
bool allow_cookies_from_browser_ = false;
+ // Whether the next redirect should bypass redirect checks (authorized by a
+ // proxying URLLoaderFactory).
+ bool bypass_redirect_checks_for_next_redirect_ = false;
+
// If the browser has asked the renderer to commit the navigation in a
// speculative RenderFrameHost, but the renderer has not yet responded, a
// subsequent navigation request will be queued when it is about to pick its
diff --git a/content/public/browser/navigation_handle.h b/content/public/browser/navigation_handle.h
index beccc4a0..1dd3fde 100644
--- a/content/public/browser/navigation_handle.h
+++ b/content/public/browser/navigation_handle.h
@@ -938,6 +938,21 @@
// both NavigationRequest and MockNavigationHandle. It's not actually needed
// outside of //content.
virtual bool IsInitialWebUINavigation() = 0;
+
+ // Marks that for the navigation identified by `navigation_id` (if it exists
+ // and is in progress) in the given `frame_tree_node_id`, redirect checks
+ // should be bypassed for the next redirect. Called by browser-process
+ // proxying loaders when the embedder authorizes a redirect.
+ static void SetBypassRedirectChecksForNextRedirect(
+ FrameTreeNodeId frame_tree_node_id,
+ int64_t navigation_id);
+
+ // Sets whether the next redirect should bypass redirect checks.
+ virtual void SetBypassRedirectChecksForNextRedirect(bool bypass) = 0;
+
+ // Returns true if the next redirect should bypass redirect checks, and resets
+ // the flag to false.
+ virtual bool ConsumeBypassRedirectChecksForNextRedirect() = 0;
};
} // namespace content
diff --git a/extensions/browser/api/web_request/web_request_proxying_url_loader_factory.cc b/extensions/browser/api/web_request/web_request_proxying_url_loader_factory.cc
index ac1f5c0f..b3a850e 100644
--- a/extensions/browser/api/web_request/web_request_proxying_url_loader_factory.cc
+++ b/extensions/browser/api/web_request/web_request_proxying_url_loader_factory.cc
@@ -31,6 +31,7 @@
#include "content/public/browser/browser_thread.h"
#include "content/public/browser/content_browser_client.h"
#include "content/public/browser/global_request_id.h"
+#include "content/public/browser/navigation_handle.h"
#include "content/public/browser/network_service_instance.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/browser/storage_partition.h"
@@ -264,6 +265,16 @@
}
void WebRequestProxyingURLLoaderFactory::InProgressRequest::
+ AuthorizeBypassRedirectChecks() {
+ if (info_->is_navigation_request && info_->navigation_id.has_value() &&
+ factory_->navigation_ui_data_) {
+ content::NavigationHandle::SetBypassRedirectChecksForNextRedirect(
+ factory_->navigation_ui_data_->frame_tree_node_id(),
+ info_->navigation_id.value());
+ }
+}
+
+void WebRequestProxyingURLLoaderFactory::InProgressRequest::
UpdateRequestInfo() {
// Derive a new WebRequestInfo value any time |Restart()| is called, because
// the details in |request_| may have changed e.g. if we've been redirected.
@@ -470,7 +481,7 @@
bool redirect_url_comes_from_extension =
redirect_url_ == redirect_info.new_url;
if (redirect_url_comes_from_extension) {
- head->bypass_redirect_checks = true;
+ AuthorizeBypassRedirectChecks();
}
if (!redirect_url_comes_from_extension &&
@@ -739,7 +750,7 @@
head->headers = base::MakeRefCounted<net::HttpResponseHeaders>(
net::HttpUtil::AssembleRawHeaders(headers));
head->encoded_data_length = 0;
- head->bypass_redirect_checks = true;
+ AuthorizeBypassRedirectChecks();
current_response_ = std::move(head);
ContinueToBeforeRedirect(redirect_info, net::OK);
@@ -1238,7 +1249,7 @@
// Since this is an extension-generated redirect, we need to tell
// the client to bypass redirect checks.
- current_response_->bypass_redirect_checks = true;
+ AuthorizeBypassRedirectChecks();
// These will get re-bound if a new request is initiated by
// |FollowRedirect()|.
diff --git a/extensions/browser/api/web_request/web_request_proxying_url_loader_factory.h b/extensions/browser/api/web_request/web_request_proxying_url_loader_factory.h
index f733dbc..91b67a6 100644
--- a/extensions/browser/api/web_request/web_request_proxying_url_loader_factory.h
+++ b/extensions/browser/api/web_request/web_request_proxying_url_loader_factory.h
@@ -104,6 +104,7 @@
~InProgressRequest() override;
void Restart();
+ void AuthorizeBypassRedirectChecks();
// network::mojom::URLLoader:
void FollowRedirect(
diff --git a/extensions/browser/extension_navigation_ui_data.cc b/extensions/browser/extension_navigation_ui_data.cc
index f553cca..d06548a1 100644
--- a/extensions/browser/extension_navigation_ui_data.cc
+++ b/extensions/browser/extension_navigation_ui_data.cc
@@ -85,8 +85,9 @@
navigation_handle->GetParentFrameOrOuterDocument()),
ExtensionApiFrameIdMap::GetFrameType(navigation_handle),
ExtensionApiFrameIdMap::GetDocumentLifecycle(navigation_handle),
- GetWebViewData(navigation_handle)) {
- // TODO(clamy): See if it would be possible to have just one source for the
+ GetWebViewData(navigation_handle),
+ navigation_handle->GetFrameTreeNodeId()) {
+ // TODO(clamy): See if it would be possible to have just one source for the
// FrameData that works both for navigations and subresources loads.
}
@@ -109,7 +110,8 @@
frame_host->GetParentOrOuterDocument()),
ExtensionApiFrameIdMap::GetFrameType(frame_host),
ExtensionApiFrameIdMap::GetDocumentLifecycle(frame_host),
- GetWebViewData(frame_host)) {}
+ GetWebViewData(frame_host),
+ frame_host->GetFrameTreeNodeId()) {}
// static
std::unique_ptr<ExtensionNavigationUIData>
@@ -131,7 +133,8 @@
/*parent_document_id=*/ExtensionApiFrameIdMap::DocumentId(),
api::extension_types::FrameType::kOutermostFrame,
api::extension_types::DocumentLifecycle::kActive,
- /*web_view_data=*/std::nullopt));
+ /*web_view_data=*/std::nullopt,
+ web_contents->GetPrimaryMainFrame()->GetFrameTreeNodeId()));
}
std::unique_ptr<ExtensionNavigationUIData> ExtensionNavigationUIData::DeepCopy()
@@ -141,6 +144,7 @@
copy->web_view_data_ = web_view_data_;
copy->parent_routing_id_ = parent_routing_id_;
copy->is_privileged_ = is_privileged_;
+ copy->frame_tree_node_id_ = frame_tree_node_id_;
return copy;
}
@@ -155,7 +159,8 @@
const ExtensionApiFrameIdMap::DocumentId& parent_document_id,
api::extension_types::FrameType frame_type,
api::extension_types::DocumentLifecycle document_lifecycle,
- std::optional<WebViewData> web_view_data)
+ std::optional<WebViewData> web_view_data,
+ content::FrameTreeNodeId frame_tree_node_id)
: frame_data_(frame_id,
parent_frame_id,
tab_id,
@@ -166,6 +171,7 @@
document_lifecycle),
web_view_data_(web_view_data),
is_privileged_(web_contents && web_contents->IsPrivileged()),
- parent_routing_id_(parent_routing_id) {}
+ parent_routing_id_(parent_routing_id),
+ frame_tree_node_id_(frame_tree_node_id) {}
} // namespace extensions
diff --git a/extensions/browser/extension_navigation_ui_data.h b/extensions/browser/extension_navigation_ui_data.h
index 054be653..0f78276 100644
--- a/extensions/browser/extension_navigation_ui_data.h
+++ b/extensions/browser/extension_navigation_ui_data.h
@@ -8,8 +8,8 @@
#include <memory>
#include <optional>
+#include "content/public/browser/frame_tree_node_id.h"
#include "content/public/browser/global_routing_id.h"
-#include "content/public/common/child_process_id.h"
#include "extensions/browser/extension_api_frame_id_map.h"
#include "extensions/buildflags/buildflags.h"
@@ -74,6 +74,10 @@
return parent_routing_id_;
}
+ content::FrameTreeNodeId frame_tree_node_id() const {
+ return frame_tree_node_id_;
+ }
+
private:
ExtensionNavigationUIData(
content::WebContents* web_contents,
@@ -86,7 +90,8 @@
const ExtensionApiFrameIdMap::DocumentId& parent_document_id,
api::extension_types::FrameType frame_type,
api::extension_types::DocumentLifecycle document_lifecycle,
- std::optional<WebViewData> web_view_data);
+ std::optional<WebViewData> web_view_data,
+ content::FrameTreeNodeId frame_tree_node_id);
ExtensionApiFrameIdMap::FrameData frame_data_;
std::optional<WebViewData> web_view_data_;
@@ -98,6 +103,8 @@
// ID for the parent RenderFrameHost of this navigation. Will only have a
// valid value for sub-frame navigations.
content::GlobalRenderFrameHostId parent_routing_id_;
+
+ content::FrameTreeNodeId frame_tree_node_id_;
};
} // namespace extensions
diff --git a/services/network/public/mojom/url_response_head.mojom b/services/network/public/mojom/url_response_head.mojom
index a0f6bd43..d810e8a4 100644
--- a/services/network/public/mojom/url_response_head.mojom
+++ b/services/network/public/mojom/url_response_head.mojom
@@ -319,9 +319,6 @@
// are returned with the locally storead headers. See crbug.com/352578800.
bool from_synthetic_response = false;
- // True when a proxy will handle the redirect checks.
- bool bypass_redirect_checks = false;
-
// The parsed value of the Unencoded-Digest header, if present.
UnencodedDigests? unencoded_digests;
diff --git a/content/browser/blob_storage/blob_url_browsertest.cc b/content/browser/blob_storage/blob_url_browsertest.cc
index 08c747b..5010588b 100644
--- a/content/browser/blob_storage/blob_url_browsertest.cc
+++ b/content/browser/blob_storage/blob_url_browsertest.cc
@@ -293,7 +293,6 @@
head->headers = net::HttpResponseHeaders::TryToCreate(
"HTTP/1.1 302 Found\r\nLocation: " + redirect_target_.spec() + "\r\n");
head->encoded_data_length = 0;
- head->bypass_redirect_checks = true;
client_->OnReceiveRedirect(redirect_info, std::move(head));
}
void ReadSideData(ReadSideDataCallback) override { NOTREACHED(); }
diff --git a/content/browser/loader/navigation_url_loader_impl_unittest.cc b/content/browser/loader/navigation_url_loader_impl_unittest.cc
index d756567..df925305 100644
--- a/content/browser/loader/navigation_url_loader_impl_unittest.cc
+++ b/content/browser/loader/navigation_url_loader_impl_unittest.cc
@@ -30,6 +30,7 @@
#include "content/public/browser/navigation_ui_data.h"
#include "content/public/browser/storage_partition.h"
#include "content/public/common/buildflags.h"
+#include "content/public/common/content_features.h"
#include "content/public/common/content_switches.h"
#include "content/public/test/browser_task_environment.h"
#include "content/public/test/mock_client_hints_controller_delegate.h"
@@ -38,6 +39,7 @@
#include "content/public/test/test_renderer_host.h"
#include "content/test/test_navigation_url_loader_delegate.h"
#include "content/test/test_web_contents.h"
+#include "mojo/public/cpp/bindings/remote.h"
#include "net/base/load_flags.h"
#include "net/base/mock_network_change_notifier.h"
#include "net/proxy_resolution/configured_proxy_resolution_service.h"
@@ -200,10 +202,12 @@
nullptr /* blob_url_loader_factory */,
base::UnguessableToken::Create() /* devtools_navigation_token */,
base::UnguessableToken::Create() /* devtools_frame_token */,
- nullptr /* client_security_state */,
- false /* is_pdf */, ChildProcessId() /* initiator_process_id */,
+ nullptr /* client_security_state */, false /* is_pdf */,
+ ChildProcessId() /* initiator_process_id */,
std::nullopt /* initiator_document_token */,
- false /* allow_cookies_from_browser */, 0 /* navigation_id */,
+ false /* allow_cookies_from_browser */,
+ pending_navigation_->GetNavigationHandle()
+ ->GetNavigationId() /* navigation_id */,
is_ad_tagged /* is_ad_tagged */,
false /* force_no_https_upgrade */));
@@ -698,6 +702,78 @@
bool should_redirect_ = true;
};
+// A `NavigationLoaderInterceptor` that intercepts the request via
+// `MaybeCreateLoader()` and immediately issues a redirect to `redirect_url`
+// with `URLResponseHead::bypass_redirect_checks` set to the supplied value.
+class TestRedirectInterceptor final : public NavigationLoaderInterceptor {
... (file diff truncated)
Regression Test / PoC
diff --git a/content/browser/blob_storage/blob_url_browsertest.cc b/content/browser/blob_storage/blob_url_browsertest.cc
index 08c747b..5010588b 100644
--- a/content/browser/blob_storage/blob_url_browsertest.cc
+++ b/content/browser/blob_storage/blob_url_browsertest.cc
@@ -293,7 +293,6 @@
head->headers = net::HttpResponseHeaders::TryToCreate(
"HTTP/1.1 302 Found\r\nLocation: " + redirect_target_.spec() + "\r\n");
head->encoded_data_length = 0;
- head->bypass_redirect_checks = true;
client_->OnReceiveRedirect(redirect_info, std::move(head));
}
void ReadSideData(ReadSideDataCallback) override { NOTREACHED(); }
diff --git a/content/browser/loader/navigation_url_loader_impl_unittest.cc b/content/browser/loader/navigation_url_loader_impl_unittest.cc
index d756567..df925305 100644
--- a/content/browser/loader/navigation_url_loader_impl_unittest.cc
+++ b/content/browser/loader/navigation_url_loader_impl_unittest.cc
@@ -30,6 +30,7 @@
#include "content/public/browser/navigation_ui_data.h"
#include "content/public/browser/storage_partition.h"
#include "content/public/common/buildflags.h"
+#include "content/public/common/content_features.h"
#include "content/public/common/content_switches.h"
#include "content/public/test/browser_task_environment.h"
#include "content/public/test/mock_client_hints_controller_delegate.h"
@@ -38,6 +39,7 @@
#include "content/public/test/test_renderer_host.h"
#include "content/test/test_navigation_url_loader_delegate.h"
#include "content/test/test_web_contents.h"
+#include "mojo/public/cpp/bindings/remote.h"
#include "net/base/load_flags.h"
#include "net/base/mock_network_change_notifier.h"
#include "net/proxy_resolution/configured_proxy_resolution_service.h"
@@ -200,10 +202,12 @@
nullptr /* blob_url_loader_factory */,
base::UnguessableToken::Create() /* devtools_navigation_token */,
base::UnguessableToken::Create() /* devtools_frame_token */,
- nullptr /* client_security_state */,
- false /* is_pdf */, ChildProcessId() /* initiator_process_id */,
+ nullptr /* client_security_state */, false /* is_pdf */,
+ ChildProcessId() /* initiator_process_id */,
std::nullopt /* initiator_document_token */,
- false /* allow_cookies_from_browser */, 0 /* navigation_id */,
+ false /* allow_cookies_from_browser */,
+ pending_navigation_->GetNavigationHandle()
+ ->GetNavigationId() /* navigation_id */,
is_ad_tagged /* is_ad_tagged */,
false /* force_no_https_upgrade */));
@@ -698,6 +702,78 @@
bool should_redirect_ = true;
};
+// A `NavigationLoaderInterceptor` that intercepts the request via
+// `MaybeCreateLoader()` and immediately issues a redirect to `redirect_url`
+// with `URLResponseHead::bypass_redirect_checks` set to the supplied value.
+class TestRedirectInterceptor final : public NavigationLoaderInterceptor {
+ public:
+ TestRedirectInterceptor(const GURL& redirect_url,
+ bool bypass_redirect_checks,
+ int64_t* navigation_id_ptr = nullptr,
+ FrameTreeNodeId* frame_tree_node_id = nullptr)
+ : redirect_url_(redirect_url),
+ bypass_redirect_checks_(bypass_redirect_checks),
+ navigation_id_ptr_(navigation_id_ptr),
+ frame_tree_node_id_ptr_(frame_tree_node_id) {}
+ ~TestRedirectInterceptor() override = default;
+
+ private:
+ void MaybeCreateLoader(
+ const network::ResourceRequest& tentative_resource_request,
+ BrowserContext* browser_context,
+ LoaderCallback callback,
+ FallbackCallback fallback_callback) override {
+ auto factory = base::MakeRefCounted<network::SingleRequestURLLoaderFactory>(
+ base::BindOnce(&TestRedirectInterceptor::HandleRequest,
+ base::Unretained(this)));
+ std::move(callback).Run(NavigationLoaderInterceptor::Result(
+ std::move(factory), SubresourceLoaderParams()));
+ }
+
+ bool MaybeCreateLoaderForResponse(
+ const network::URLLoaderCompletionStatus& status,
+ const network::ResourceRequest& request,
+ network::mojom::URLResponseHeadPtr* response_head,
+ mojo::ScopedDataPipeConsumerHandle* response_body,
+ mojo::PendingReceiver<network::mojom::URLLoaderClient>* client_receiver,
+ blink::ThrottlingURLLoader* url_loader,
+ bool* skip_other_interceptors) override {
+ return false;
+ }
+
+ void HandleRequest(
+ const network::ResourceRequest& request,
+ mojo::PendingReceiver<network::mojom::URLLoader> loader,
+ mojo::PendingRemote<network::mojom::URLLoaderClient> client) {
+ loader_receiver_ = std::move(loader);
+ client_.Bind(std::move(client));
+
+ auto head = network::mojom::URLResponseHead::New();
+ if (bypass_redirect_checks_ && navigation_id_ptr_ &&
+ *navigation_id_ptr_ != 0 && frame_tree_node_id_ptr_) {
+ NavigationHandle::SetBypassRedirectChecksForNextRedirect(
+ *frame_tree_node_id_ptr_, *navigation_id_ptr_);
+ }
+ net::RedirectInfo redirect_info = net::RedirectInfo::ComputeRedirectInfo(
+ request.method, request.url, request.site_for_cookies,
+ request.update_first_party_url_on_redirect
+ ? net::RedirectInfo::FirstPartyURLPolicy::UPDATE_URL_ON_REDIRECT
+ : net::RedirectInfo::FirstPartyURLPolicy::NEVER_CHANGE_URL,
+ request.referrer_policy, request.referrer.spec(),
+ request.request_initiator, net::HTTP_TEMPORARY_REDIRECT, redirect_url_,
+ /*referrer_policy_header=*/std::nullopt,
+ /*insecure_scheme_was_upgraded=*/false);
+ client_->OnReceiveRedirect(redirect_info, std::move(head));
+ }
+
+ const GURL redirect_url_;
+ const bool bypass_redirect_checks_;
+ const raw_ptr<int64_t> navigation_id_ptr_;
+ const raw_ptr<FrameTreeNodeId> frame_tree_node_id_ptr_;
+ mojo::PendingReceiver<network::mojom::URLLoader> loader_receiver_;
+ mojo::Remote<network::mojom::URLLoaderClient> client_;
+};
+
// This sets the timeout timer but doesn't expect the timer is fired
// automatically. If needed, the timer should be fired explicitly e.g. via
// `TriggerTimeoutForTesting()`.
@@ -1370,6 +1446,68 @@
Optional(std::string("Value3")));
}
+// `URLResponseHead::bypass_redirect_checks` is delivered over the
+// `URLLoaderClient` pipe and must not by itself allow a redirect to a target
+// that fails `IsSafeRedirectTarget()`. The per-request bit is only honored when
+// the loader factory in use was created with `bypass_redirect_checks` set
+// (i.e., a browser-process proxy is responsible for the redirect).
+TEST_F(NavigationURLLoaderImplTest,
+ PerRequestBypassRedirectChecksRequiresFactoryFlag) {
+ base::test::ScopedFeatureList feature_list{
+ features::kBypassRedirectChecksPerRequest};
+ ASSERT_TRUE(http_test_server_.Start());
+
+ for (bool bypass : {true, false}) {
+ SCOPED_TRACE(testing::Message() << "bypass_redirect_checks=" << bypass);
+ TestNavigationURLLoaderDelegate delegate;
+ std::vector<std::unique_ptr<NavigationLoaderInterceptor>> interceptors;
+ interceptors.push_back(std::make_unique<TestRedirectInterceptor>(
+ GURL("file:///"), /*bypass_redirect_checks=*/bypass));
+ auto loader =
+ CreateTestLoader(http_test_server_.GetURL("/echo"), std::string(),
+ "GET", &delegate, blink::NavigationDownloadPolicy(),
+ /*is_main_frame=*/true,
+ /*upgrade_if_insecure=*/false,
+ /*is_ad_tagged=*/false, std::move(interceptors));
+ loader->Start();
+ delegate.WaitForRequestFailed();
+ EXPECT_EQ(delegate.on_redirect_handled_counter(), 0);
+ EXPECT_EQ(delegate.on_request_handled_counter(), 1);
+ EXPECT_EQ(net::ERR_UNSAFE_REDIRECT, delegate.net_error());
+ }
+}
+
+// When a browser-process proxy authorizes bypassing redirect checks via
+// authorizes bypassing redirect checks via
+// `NavigationHandle::SetBypassRedirectChecksForNextRedirect()`, the redirect is
+// allowed even if `IsSafeRedirectTarget()` would normally fail.
+TEST_F(NavigationURLLoaderImplTest,
+ PerRequestBypassRedirectChecksSucceedsWhenAuthorizedByProxy) {
+ base::test::ScopedFeatureList feature_list{
+ features::kBypassRedirectChecksPerRequest};
+ ASSERT_TRUE(http_test_server_.Start());
+
+ int64_t navigation_id = 0;
+ FrameTreeNodeId frame_tree_node_id;
+ TestNavigationURLLoaderDelegate delegate;
+ std::vector<std::unique_ptr<NavigationLoaderInterceptor>> interceptors;
+ interceptors.push_back(std::make_unique<TestRedirectInterceptor>(
+ GURL("file:///"), /*bypass_redirect_checks=*/true, &navigation_id,
+ &frame_tree_node_id));
+ auto loader =
+ CreateTestLoader(http_test_server_.GetURL("/echo"), std::string(), "GET",
+ &delegate, blink::NavigationDownloadPolicy(),
+ /*is_main_frame=*/true,
+ /*upgrade_if_insecure=*/false,
+ /*is_ad_tagged=*/false, std::move(interceptors));
+ navigation_id = pending_navigation_->GetNavigationHandle()->GetNavigationId();
+ frame_tree_node_id =
+ pending_navigation_->GetNavigationHandle()->GetFrameTreeNodeId();
+ loader->Start();
+ delegate.WaitForRequestRedirected();
+ EXPECT_EQ(delegate.on_redirect_handled_counter(), 1);
+}
+
// Tests that the Upgrade If Insecure flag is obeyed.
TEST_F(NavigationURLLoaderImplTest, UpgradeIfInsecureTest) {
ASSERT_TRUE(http_test_server_.Start());
diff --git a/content/public/test/mock_navigation_handle.h b/content/public/test/mock_navigation_handle.h
index 155acda..3389103 100644
--- a/content/public/test/mock_navigation_handle.h
+++ b/content/public/test/mock_navigation_handle.h
@@ -280,6 +280,8 @@
());
MOCK_METHOD(bool, NeedsUrlLoader, ());
MOCK_METHOD(bool, IsInitialWebUINavigation, ());
+ MOCK_METHOD(void, SetBypassRedirectChecksForNextRedirect, (bool));
+ MOCK_METHOD(bool, ConsumeBypassRedirectChecksForNextRedirect, ());
#if BUILDFLAG(IS_ANDROID)
MOCK_METHOD(const base::android::JavaRef<jobject>&,
Original Bug Report
Sandbox Escape: Network Service can bypass redirect checks to load chrome:// or file://
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 security team.
Overview: A compromised Network Service can set the bypass_redirect_checks flag in URLResponseHead to bypass IsSafeRedirectTarget in the browser process. When combined with a browser-initiated navigation, this allows the Network Service to redirect the user to privileged schemes like chrome:// or file://, leading to a sandbox escape.
Affected files:
content/browser/loader/navigation_url_loader_impl.cccontent/browser/child_process_security_policy_impl.cccontent/public/common/url_utils.ccservices/network/url_loader.cccontent/public/common/content_features.ccthird_party/blink/common/loader/throttling_url_loader.cc
Estimated timestamp from git blame: 2025-07-10
Summary
The browser process incorrectly trusts the bypass_redirect_checks boolean field within the network::mojom::URLResponseHead structure received via Mojo from the Network Service. A compromised Network Service can set this flag to true during a redirect, causing the browser to skip the IsSafeRedirectTarget security check in NavigationURLLoaderImpl::OnReceiveRedirect. If the navigation is browser-initiated, this enables a malicious redirect to privileged URL schemes such as chrome:// and file://, resulting in a sandbox escape.
Technical Details
When a navigation occurs, the browser creates a NavigationURLLoaderImpl to fetch the URL. If the response is a redirect, NavigationURLLoaderImpl::OnReceiveRedirect evaluates whether to bypass redirect safety checks:
bool bypass_redirect_checks =
base::FeatureList::IsEnabled(features::kBypassRedirectChecksPerRequest)
? head->bypass_redirect_checks
: bypass_redirect_checks_;
if (!bypass_redirect_checks &&
!IsSafeRedirectTarget(url_, redirect_info.new_url)) {
error = net::ERR_UNSAFE_REDIRECT;
}
Because the kBypassRedirectChecksPerRequest feature is enabled by default, the browser trusts head->bypass_redirect_checks. URLResponseHead is populated and sent by the untrusted Network Service. By setting this field to true, the Network Service completely bypasses IsSafeRedirectTarget, which normally blocks redirects to chrome://, file://, and other dangerous schemes.
Later in the navigation flow, NavigationRequest::OnRequestRedirected performs secondary checks. It calls ChildProcessSecurityPolicyImpl::CanRedirectToURL, which only blocks pseudo-schemes (like javascript:) and allows chrome:// and file://. It also checks CanRequestURL, but crucially, this check is skipped for browser-initiated navigations:
if (!commit_params_->is_browser_initiated && GetSourceSiteInstance() &&
!ChildProcessSecurityPolicyImpl::GetInstance()->CanRequestURL(...)) {
Consequently, if an attacker waits for the user to initiate a navigation (e.g., by typing in the Omnibox or clicking a bookmark), they can intercept the request in the Network Service and redirect it to a highly privileged URL. The browser will then allocate an appropriately privileged renderer process (e.g., a WebUI renderer) and grant it commit privileges for the malicious URL, completing a sandbox escape.
Potential Attack Steps
- An attacker compromises the sandboxed Network Service process (e.g., via a memory corruption vulnerability in network data parsing).
- The attacker waits for the user to initiate a top-level navigation (e.g., typing a URL into the Omnibox), ensuring
commit_params_->is_browser_initiatedistrue. - The compromised Network Service intercepts the request and responds with an
OnReceiveRedirectIPC message. - The attacker crafts the
redirect_info.new_urlto point to a privileged destination, such aschrome://settings/or a local attacker-controlled HTML file (file:///...). - The attacker sets
head->bypass_redirect_checks = truein theURLResponseHead. - The browser receives the redirect, bypasses
IsSafeRedirectTarget, and follows the redirect. - A new privileged renderer process is selected and granted permissions, executing the privileged page and giving the attacker a sandbox escape.
Suggested Fix
bypass_redirect_checks should not be a field in network::mojom::URLResponseHead, as this structure represents untrusted data from the Network Service. The bypass flag is a browser-internal concept used primarily by the Web Request API proxy (which runs in the browser process) to signal that an extension authorized the redirect.
Consider removing bypass_redirect_checks from URLResponseHead and communicating this state out-of-band. Alternatively, ensure that NavigationURLLoaderImpl only respects head->bypass_redirect_checks if the IPC was received from a trusted, browser-process-hosted URLLoader (like the Web Request proxy), rather than the raw Network Service.
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.