CVE-2026-91708
Overview
Background
- `NavigationURLLoaderImpl`
- Browser-process class that drives a navigation load and implements the
network::mojom::URLLoaderClientcallbacks (OnReceiveRedirect,OnReceiveResponse) invoked by the Network Service. - `LoaderHolder::HasExclusiveTask()`
- Predicate reporting whether an exclusive async task (such as header parsing for an earlier redirect or an interceptor check) is currently in flight on the loader.
- `DUMP_WILL_BE_CHECK`
- A soft assertion that dumps in some builds but is non-fatal, so a violated condition does not stop execution in production.
- `redirect_info_` / `FollowRedirect()`
- Cached redirect target set on
OnReceiveRedirectand later consumed whenNavigationRequestcallsFollowRedirect()to proceed to the vetted URL.
Root Cause Analysis
NavigationURLLoaderImpl::OnReceiveRedirect and NavigationURLLoaderImpl::OnReceiveResponse guarded the URLLoaderClient protocol invariant “no overlapping IPC while an exclusive task is in flight” with only DUMP_WILL_BE_CHECK(!loader_holder_.HasExclusiveTask()), which is non-fatal in production builds. A compromised or untrusted Network Service could therefore send a second OnReceiveRedirect while asynchronous header parsing of an earlier redirect was still pending; because the check did not abort, the handler ran to completion and overwrote redirect_info_ with the new, unvetted target while NavigationRequest was still asynchronously vetting the prior redirect. When NavigationRequest subsequently called FollowRedirect(), NavigationURLLoaderImpl followed the overwritten target that had never passed IsSafeRedirectTarget or throttle checks.
The fix converts both soft checks into hard termination: when loader_holder_.HasExclusiveTask() is true, OnReceiveResponse cancels immediately (CancelWithError/OnComplete with net::ERR_UNEXPECTED) and OnReceiveRedirect sets error = net::ERR_UNEXPECTED before any state mutation, so the desynchronizing IPC can no longer overwrite loader state. It also widens the post-error state assertion to accept LoaderHolder::State::kNone alongside kLoadingViaReceiver when url_loader() is null, matching the new abort path.
DUMP_WILL_BE_CHECK to enforce a security-critical protocol invariant against a process (the Network Service) that is explicitly part of the threat model; the fix replaces the soft assertion with immediate net::ERR_UNEXPECTED termination in both OnReceiveRedirect and OnReceiveResponse before any state is mutated.Attack Path
- Compromise the Network Service
An attacker gains code execution in the Network Service, which speaks the
URLLoaderClientprotocol to the browser process. - Trigger an async redirect
A navigation receives a first
OnReceiveRedirect, and the browser begins asynchronous header parsing, leavingloader_holder_.HasExclusiveTask()true. - Send an overlapping redirect
Before parsing completes, the compromised service sends a second
OnReceiveRedirectwhoseredirect_infopoints to a sensitive target (e.g. achrome-extension://URL); the non-fatal check does not abort andredirect_info_is overwritten. - Follow the unvetted target
When
NavigationRequestcallsFollowRedirect(),NavigationURLLoaderImplfollows the overwritten target that bypassedIsSafeRedirectTargetand throttle vetting. - Navigate to a restricted destination The navigation lands on a target the checks would have blocked, escalating the compromised renderer/network-service position.
Impact Assessment
URLLoaderFactory) and a navigation that takes the asynchronous header-parsing path; it does not by itself give code execution but undermines a browser-process navigation security boundary.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/loader/navigation_url_loader_impl.cc |
modified | |
TestDoubleRedirectInterceptorcontent/browser/loader/navigation_url_loader_impl_unittest.cc |
modified | |
ifcontent/browser/loader/navigation_url_loader_impl_unittest.cc |
modified | |
TEST_Fcontent/browser/loader/navigation_url_loader_impl_unittest.cc |
modified |
Files Changed
content/browser/loader/navigation_url_loader_impl_unittest.cccontent/browser/loader/navigation_url_loader_impl.cc
Audit Directions
- Non-fatal assertions on protocol invariantsSearch for
DUMP_WILL_BE_CHECK/DCHECKguarding conditions that depend on messages from a less-trusted process, since these do not enforce the invariant in production and must become hard failures. - Overlapping IPC on async stateReview
URLLoaderClientand other Mojo callback handlers that cache state (likeredirect_info_) then consume it later, checking whether a second callback can arrive and overwrite that state while an exclusive/async task is pending. - State machine transitions post-abortAudit
LoaderHolder/loader state assertions (state()comparisons) to confirm every early-termination path leaves a state the later checks and callbacks (e.g.FollowRedirect()) accept without restarting or reusing stale data.
Patch
From 92d214b025565169bf5a2d8a3718ef9e7dbc040a Mon Sep 17 00:00:00 2001
From: Kenichi Ishibashi <bashi@chromium.org>
Date: Tue, 08 Sep 2026 01:11:18 -0700
Subject: [PATCH] Abort navigation on overlapping redirect or response
In NavigationURLLoaderImpl, receiving an OnReceiveRedirect or
OnReceiveResponse IPC while another exclusive task is in flight (such as
asynchronous header parsing for an earlier redirect) violates the
URLLoaderClient protocol.
Previously, these entry points guarded against this using only
DUMP_WILL_BE_CHECK(!loader_holder_.HasExclusiveTask()). In builds where
DUMP_WILL_BE_CHECK is non-fatal, an untrusted or compromised Network
Service could send overlapping OnReceiveRedirect calls. This overwrote
redirect_info_ with the new redirect target while NavigationRequest was
still asynchronously vetting the earlier redirect. When NavigationRequest
subsequently called FollowRedirect(), NavigationURLLoaderImpl followed
the unvetted target.
This CL fixes the issue by:
1. Immediately terminating the navigation with net::ERR_UNEXPECTED if
loader_holder_.HasExclusiveTask() is true in OnReceiveRedirect().
2. Adding an identical guard to OnReceiveResponse() to prevent desync
if a response arrives while an exclusive task is in progress.
3. In OnReceiveRedirect's error path, allowing LoaderHolder::State::kNone
alongside kLoadingViaReceiver when url_loader() is null.
4. Adding unit tests in NavigationURLLoaderImplTest covering overlapping
redirects during header parsing, overlapping redirects while throttle
checks are pending, and responses arriving during redirect header parsing.
Bug: 553115724
Change-Id: I1da1b9b57d6f6f649190f017d264bb92ea6deaea
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8365644
Reviewed-by: Takashi Toyoshima <toyoshim@chromium.org>
Commit-Queue: Kenichi Ishibashi <bashi@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1693615}
---
diff --git a/content/browser/loader/navigation_url_loader_impl.cc b/content/browser/loader/navigation_url_loader_impl.cc
index 17b1557..ac9efb6 100644
--- a/content/browser/loader/navigation_url_loader_impl.cc
+++ b/content/browser/loader/navigation_url_loader_impl.cc
@@ -1426,8 +1426,19 @@
TRACE_EVENT("navigation", "NavigationURLLoaderImpl::OnReceiveResponse",
perfetto::Flow::FromPointer(this));
DCHECK(!cached_metadata);
- // TODO(https://crbug.com/434182226): Remove DUMP_WILL_BE_.
- DUMP_WILL_BE_CHECK(!loader_holder_.HasExclusiveTask());
+ if (loader_holder_.HasExclusiveTask()) {
+ // Receiving a response while an exclusive task (such as header parsing
+ // for a redirect or an interceptor check) is in flight violates the
+ // URLLoaderClient protocol. Cancel immediately.
+ if (loader_holder_.url_loader()) {
+ loader_holder_.url_loader()->CancelWithError(
+ net::ERR_UNEXPECTED,
+ std::string_view(base::NumberToString(net::ERR_UNEXPECTED)));
+ } else {
+ OnComplete(network::URLLoaderCompletionStatus(net::ERR_UNEXPECTED));
+ }
+ return;
+ }
LogQueueTimeHistogram("Navigation.QueueTime.OnReceiveResponse",
resource_request().is_outermost_main_frame);
@@ -1568,44 +1579,50 @@
void NavigationURLLoaderImpl::OnReceiveRedirect(
const net::RedirectInfo& redirect_info,
network::mojom::URLResponseHeadPtr head) {
- // TODO(https://crbug.com/434182226): Remove DUMP_WILL_BE_.
- DUMP_WILL_BE_CHECK(!loader_holder_.HasExclusiveTask());
LogQueueTimeHistogram("Navigation.QueueTime.OnReceiveRedirect",
resource_request().is_outermost_main_frame);
net::Error error = net::OK;
- 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()) {
+ if (loader_holder_.HasExclusiveTask()) {
+ // A redirect shouldn't be received while another exclusive task is already
+ // in flight. Receiving an overlapping redirect from a network service
+ // violates the URLLoaderClient protocol. Fail the navigation immediately.
+ error = net::ERR_UNEXPECTED;
+ } else if (url_.SchemeIsBlob()) {
// Loading a blob URL never produces a redirect.
error = net::ERR_UNSAFE_REDIRECT;
- } else if (!bypass_redirect_checks &&
- !IsSafeRedirectTarget(url_, redirect_info.new_url)) {
- error = net::ERR_UNSAFE_REDIRECT;
- } else if (--redirect_limit_ == 0) {
- error = net::ERR_TOO_MANY_REDIRECTS;
- if (redirect_info.is_signed_exchange_fallback_redirect) {
- UMA_HISTOGRAM_BOOLEAN("SignedExchange.FallbackRedirectLoop", true);
+ } else {
+ 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 (!bypass_redirect_checks &&
+ !IsSafeRedirectTarget(url_, redirect_info.new_url)) {
+ error = net::ERR_UNSAFE_REDIRECT;
+ } else if (--redirect_limit_ == 0) {
+ error = net::ERR_TOO_MANY_REDIRECTS;
+ if (redirect_info.is_signed_exchange_fallback_redirect) {
+ UMA_HISTOGRAM_BOOLEAN("SignedExchange.FallbackRedirectLoop", true);
+ }
}
}
if (error != net::OK) {
@@ -1620,8 +1637,9 @@
error, std::string_view(base::NumberToString(error)));
} else {
// TODO(https://crbug.com/434182226): Turn this to `CHECK()`.
- DUMP_WILL_BE_CHECK_EQ(loader_holder_.state(),
- LoaderHolder::State::kLoadingViaReceiver);
+ DUMP_WILL_BE_CHECK(loader_holder_.state() ==
+ LoaderHolder::State::kLoadingViaReceiver ||
+ loader_holder_.state() == LoaderHolder::State::kNone);
// TODO(crbug.com/40118809): Make sure ResetWithReason() is called
// on the original `url_loader_`.
OnComplete(network::URLLoaderCompletionStatus(error));
diff --git a/content/browser/loader/navigation_url_loader_impl_unittest.cc b/content/browser/loader/navigation_url_loader_impl_unittest.cc
index 059f589..5b34dc6 100644
--- a/content/browser/loader/navigation_url_loader_impl_unittest.cc
+++ b/content/browser/loader/navigation_url_loader_impl_unittest.cc
@@ -776,6 +776,86 @@
mojo::Remote<network::mojom::URLLoaderClient> client_;
};
+std::pair<net::RedirectInfo, network::mojom::URLResponseHeadPtr>
+Create302Redirect(const GURL& from_url,
+ const GURL& to_url,
+ const std::string& method = "GET") {
+ auto head = network::mojom::URLResponseHead::New();
+ head->headers = net::HttpResponseHeaders::TryToCreate(base::StringPrintf(
+ "HTTP/1.1 302 Found\r\nLocation: %s\r\n\r\n", to_url.spec().c_str()));
+ net::RedirectInfo redirect_info = net::RedirectInfo::ComputeRedirectInfo(
+ method, from_url, net::SiteForCookies(),
+ net::RedirectInfo::FirstPartyURLPolicy::NEVER_CHANGE_URL,
+ net::ReferrerPolicy::CLEAR_ON_TRANSITION_FROM_SECURE_TO_INSECURE,
+ std::string(), /*original_initiator=*/std::nullopt, net::HTTP_FOUND,
+ to_url,
+ /*referrer_policy_header=*/std::nullopt,
+ /*insecure_scheme_was_upgraded=*/false);
+ return {redirect_info, std::move(head)};
+}
+
+class TestDoubleRedirectInterceptor final : public NavigationLoaderInterceptor {
+ public:
+ TestDoubleRedirectInterceptor(const GURL& r1_url, const GURL& r2_url)
+ : r1_url_(r1_url), r2_url_(r2_url) {}
+ ~TestDoubleRedirectInterceptor() override = default;
+
+ int request_count() const { return request_count_; }
+ const std::vector<GURL>& requested_urls() const { return requested_urls_; }
+
+ void SendSecondRedirect() {
+ auto [redirect_info, head] = Create302Redirect(r1_url_, r2_url_);
+ client_->OnReceiveRedirect(redirect_info, std::move(head));
+ }
+
+ void SendResponse() {
+ auto head = network::mojom::URLResponseHead::New();
+ mojo::ScopedDataPipeConsumerHandle consumer_handle;
+ mojo::ScopedDataPipeProducerHandle producer_handle;
+ CHECK_EQ(MOJO_RESULT_OK,
+ mojo::CreateDataPipe(nullptr, producer_handle, consumer_handle));
+ client_->OnReceiveResponse(std::move(head), std::move(consumer_handle),
+ /*cached_metadata=*/std::nullopt);
+ }
+
+ private:
+ void MaybeCreateLoader(
+ const network::ResourceRequest& tentative_resource_request,
+ BrowserContext* browser_context,
+ LoaderCallback callback,
+ FallbackCallback fallback_callback) override {
+ ++request_count_;
+ requested_urls_.push_back(tentative_resource_request.url);
+ auto factory = base::MakeRefCounted<network::SingleRequestURLLoaderFactory>(
+ base::BindOnce(&TestDoubleRedirectInterceptor::HandleRequest,
+ base::Unretained(this)));
+ std::move(callback).Run(NavigationLoaderInterceptor::Result(
+ std::move(factory), SubresourceLoaderParams()));
+ }
+
+ void HandleRequest(
+ const network::ResourceRequest& request,
+ mojo::PendingReceiver<network::mojom::URLLoader> loader,
+ mojo::PendingRemote<network::mojom::URLLoaderClient> client) {
+ loader_receiver_ = std::move(loader);
+ client_.reset();
+ client_.Bind(std::move(client));
+
+ if (request_count_ == 1) {
+ auto [redirect_info, head] =
+ Create302Redirect(request.url, r1_url_, request.method);
+ client_->OnReceiveRedirect(redirect_info, std::move(head));
+ }
+ }
+
+ const GURL r1_url_;
+ const GURL r2_url_;
+ int request_count_ = 0;
+ std::vector<GURL> requested_urls_;
+ 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()`.
@@ -1816,4 +1896,129 @@
}
}
+// Regression test for b/553115724: Receiving a second OnReceiveRedirect
+// message from a compromised network service while ParseHeaders() for the first
+// redirect is still in flight.
+TEST_F(NavigationURLLoaderImplTest, DoubleRedirectWhileParseHeadersPending) {
+ const GURL start_url("http://example.com/start");
+ const GURL r1_url("http://example.com/r1");
+ const GURL r2_url("chrome-extension://abcd/manifest.json");
+
+ TestNavigationURLLoaderDelegate delegate;
+ auto interceptor =
+ std::make_unique<TestDoubleRedirectInterceptor>(r1_url, r2_url);
+ auto* interceptor_ptr = interceptor.get();
+ std::vector<std::unique_ptr<NavigationLoaderInterceptor>> interceptors;
+ interceptors.push_back(std::move(interceptor));
+
+ auto loader =
+ CreateTestLoader(start_url, std::string(), "GET", &delegate,
+ blink::NavigationDownloadPolicy(),
+ /*is_main_frame=*/true,
+ /*upgrade_if_insecure=*/false,
+ /*is_ad_tagged=*/false, std::move(interceptors));
+ loader->Start();
+
+ // Force the async ParseHeaders() path for redirects.
+ delegate.set_clear_parsed_headers_on_redirect(true);
+
+ // Wait for the first redirect (R1) to be received by NavigationURLLoaderImpl.
+ delegate.WaitForOnReceiveRedirect();
+ EXPECT_EQ(delegate.on_redirect_handled_counter(), 0);
+
+ // While ParseHeaders(R1) is still in flight, send a second redirect (R2).
+ interceptor_ptr->SendSecondRedirect();
+
+ // The second redirect arrives while the first redirect's ParseHeaders is
+ // in flight. This must immediately fail with ERR_UNEXPECTED instead of
+ // corrupting the loader's redirect state.
+ delegate.WaitForRequestFailed();
+ EXPECT_EQ(delegate.net_error(), net::ERR_UNEXPECTED);
+ EXPECT_EQ(delegate.on_redirect_handled_counter(), 0);
+
+ // No further request (such as R2) should be issued.
+ EXPECT_EQ(interceptor_ptr->request_count(), 1);
+ EXPECT_EQ(interceptor_ptr->requested_urls().size(), 1u);
+}
+
+// Test receiving a second redirect after ParseHeaders() has completed and
+// OnRequestRedirected() has been dispatched to the delegate, but before
+// FollowRedirect() is invoked (e.g. while NavigationThrottles are executing).
+TEST_F(NavigationURLLoaderImplTest, DoubleRedirectWhileThrottleChecksPending) {
+ const GURL start_url("http://example.com/start");
+ const GURL r1_url("http://example.com/r1");
+ const GURL r2_url("chrome-extension://abcd/manifest.json");
+
+ TestNavigationURLLoaderDelegate delegate;
+ auto interceptor =
+ std::make_unique<TestDoubleRedirectInterceptor>(r1_url, r2_url);
+ auto* interceptor_ptr = interceptor.get();
+ std::vector<std::unique_ptr<NavigationLoaderInterceptor>> interceptors;
+ interceptors.push_back(std::move(interceptor));
+
+ auto loader =
+ CreateTestLoader(start_url, std::string(), "GET", &delegate,
+ blink::NavigationDownloadPolicy(),
+ /*is_main_frame=*/true,
+ /*upgrade_if_insecure=*/false,
+ /*is_ad_tagged=*/false, std::move(interceptors));
+ loader->Start();
+
+ // Wait for R1's ParseHeaders to finish and OnRequestRedirected to be called.
+ delegate.WaitForRequestRedirected();
+ EXPECT_EQ(delegate.on_redirect_handled_counter(), 1);
+ EXPECT_EQ(delegate.redirect_info().new_url, r1_url);
+
+ // Send R2 while waiting for FollowRedirect(). HasExclusiveTask() is still
+ // true.
+ interceptor_ptr->SendSecondRedirect();
+
+ delegate.WaitForRequestFailed();
+ EXPECT_EQ(delegate.net_error(), net::ERR_UNEXPECTED);
+
+ // If a racing throttle callback invokes FollowRedirect() afterwards,
+ // ShouldCancelExclusiveTask must cleanly drop the call without restarting.
+ loader->FollowRedirect({});
+
+ EXPECT_EQ(interceptor_ptr->request_count(), 1);
+ EXPECT_EQ(interceptor_ptr->requested_urls().size(), 1u);
+}
+
+// Test receiving OnReceiveResponse while ParseHeaders() for a redirect is in
+// flight.
+TEST_F(NavigationURLLoaderImplTest, ResponseWhileRedirectParseHeadersPending) {
+ const GURL start_url("http://example.com/start");
+ const GURL r1_url("http://example.com/r1");
+
+ TestNavigationURLLoaderDelegate delegate;
+ auto interceptor =
+ std::make_unique<TestDoubleRedirectInterceptor>(r1_url, r1_url);
+ auto* interceptor_ptr = interceptor.get();
+ std::vector<std::unique_ptr<NavigationLoaderInterceptor>> interceptors;
+ interceptors.push_back(std::move(interceptor));
+
+ auto loader =
+ CreateTestLoader(start_url, std::string(), "GET", &delegate,
+ blink::NavigationDownloadPolicy(),
+ /*is_main_frame=*/true,
+ /*upgrade_if_insecure=*/false,
+ /*is_ad_tagged=*/false, std::move(interceptors));
+ loader->Start();
+
+ // Force the async ParseHeaders() path for redirects.
+ delegate.set_clear_parsed_headers_on_redirect(true);
+
+ // Wait for R1 to be received by NavigationURLLoaderImpl.
+ delegate.WaitForOnReceiveRedirect();
+ EXPECT_EQ(delegate.on_redirect_handled_counter(), 0);
+
+ // While ParseHeaders(R1) is still in flight, send OnReceiveResponse.
+ interceptor_ptr->SendResponse();
+
+ delegate.WaitForRequestFailed();
+ EXPECT_EQ(delegate.net_error(), net::ERR_UNEXPECTED);
+ EXPECT_EQ(delegate.on_redirect_handled_counter(), 0);
+ EXPECT_EQ(delegate.on_request_handled_counter(), 1);
+}
+
} // namespace content
Regression Test / PoC
diff --git a/content/browser/loader/navigation_url_loader_impl_unittest.cc b/content/browser/loader/navigation_url_loader_impl_unittest.cc
index 059f589..5b34dc6 100644
--- a/content/browser/loader/navigation_url_loader_impl_unittest.cc
+++ b/content/browser/loader/navigation_url_loader_impl_unittest.cc
@@ -776,6 +776,86 @@
mojo::Remote<network::mojom::URLLoaderClient> client_;
};
+std::pair<net::RedirectInfo, network::mojom::URLResponseHeadPtr>
+Create302Redirect(const GURL& from_url,
+ const GURL& to_url,
+ const std::string& method = "GET") {
+ auto head = network::mojom::URLResponseHead::New();
+ head->headers = net::HttpResponseHeaders::TryToCreate(base::StringPrintf(
+ "HTTP/1.1 302 Found\r\nLocation: %s\r\n\r\n", to_url.spec().c_str()));
+ net::RedirectInfo redirect_info = net::RedirectInfo::ComputeRedirectInfo(
+ method, from_url, net::SiteForCookies(),
+ net::RedirectInfo::FirstPartyURLPolicy::NEVER_CHANGE_URL,
+ net::ReferrerPolicy::CLEAR_ON_TRANSITION_FROM_SECURE_TO_INSECURE,
+ std::string(), /*original_initiator=*/std::nullopt, net::HTTP_FOUND,
+ to_url,
+ /*referrer_policy_header=*/std::nullopt,
+ /*insecure_scheme_was_upgraded=*/false);
+ return {redirect_info, std::move(head)};
+}
+
+class TestDoubleRedirectInterceptor final : public NavigationLoaderInterceptor {
+ public:
+ TestDoubleRedirectInterceptor(const GURL& r1_url, const GURL& r2_url)
+ : r1_url_(r1_url), r2_url_(r2_url) {}
+ ~TestDoubleRedirectInterceptor() override = default;
+
+ int request_count() const { return request_count_; }
+ const std::vector<GURL>& requested_urls() const { return requested_urls_; }
+
+ void SendSecondRedirect() {
+ auto [redirect_info, head] = Create302Redirect(r1_url_, r2_url_);
+ client_->OnReceiveRedirect(redirect_info, std::move(head));
+ }
+
+ void SendResponse() {
+ auto head = network::mojom::URLResponseHead::New();
+ mojo::ScopedDataPipeConsumerHandle consumer_handle;
+ mojo::ScopedDataPipeProducerHandle producer_handle;
+ CHECK_EQ(MOJO_RESULT_OK,
+ mojo::CreateDataPipe(nullptr, producer_handle, consumer_handle));
+ client_->OnReceiveResponse(std::move(head), std::move(consumer_handle),
+ /*cached_metadata=*/std::nullopt);
+ }
+
+ private:
+ void MaybeCreateLoader(
+ const network::ResourceRequest& tentative_resource_request,
+ BrowserContext* browser_context,
+ LoaderCallback callback,
+ FallbackCallback fallback_callback) override {
+ ++request_count_;
+ requested_urls_.push_back(tentative_resource_request.url);
+ auto factory = base::MakeRefCounted<network::SingleRequestURLLoaderFactory>(
+ base::BindOnce(&TestDoubleRedirectInterceptor::HandleRequest,
+ base::Unretained(this)));
+ std::move(callback).Run(NavigationLoaderInterceptor::Result(
+ std::move(factory), SubresourceLoaderParams()));
+ }
+
+ void HandleRequest(
+ const network::ResourceRequest& request,
+ mojo::PendingReceiver<network::mojom::URLLoader> loader,
+ mojo::PendingRemote<network::mojom::URLLoaderClient> client) {
+ loader_receiver_ = std::move(loader);
+ client_.reset();
+ client_.Bind(std::move(client));
+
+ if (request_count_ == 1) {
+ auto [redirect_info, head] =
+ Create302Redirect(request.url, r1_url_, request.method);
+ client_->OnReceiveRedirect(redirect_info, std::move(head));
+ }
+ }
+
+ const GURL r1_url_;
+ const GURL r2_url_;
+ int request_count_ = 0;
+ std::vector<GURL> requested_urls_;
+ 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()`.
@@ -1816,4 +1896,129 @@
}
}
+// Regression test for b/553115724: Receiving a second OnReceiveRedirect
+// message from a compromised network service while ParseHeaders() for the first
+// redirect is still in flight.
+TEST_F(NavigationURLLoaderImplTest, DoubleRedirectWhileParseHeadersPending) {
+ const GURL start_url("http://example.com/start");
+ const GURL r1_url("http://example.com/r1");
+ const GURL r2_url("chrome-extension://abcd/manifest.json");
+
+ TestNavigationURLLoaderDelegate delegate;
+ auto interceptor =
+ std::make_unique<TestDoubleRedirectInterceptor>(r1_url, r2_url);
+ auto* interceptor_ptr = interceptor.get();
+ std::vector<std::unique_ptr<NavigationLoaderInterceptor>> interceptors;
+ interceptors.push_back(std::move(interceptor));
+
+ auto loader =
+ CreateTestLoader(start_url, std::string(), "GET", &delegate,
+ blink::NavigationDownloadPolicy(),
+ /*is_main_frame=*/true,
+ /*upgrade_if_insecure=*/false,
+ /*is_ad_tagged=*/false, std::move(interceptors));
+ loader->Start();
+
+ // Force the async ParseHeaders() path for redirects.
+ delegate.set_clear_parsed_headers_on_redirect(true);
+
+ // Wait for the first redirect (R1) to be received by NavigationURLLoaderImpl.
+ delegate.WaitForOnReceiveRedirect();
+ EXPECT_EQ(delegate.on_redirect_handled_counter(), 0);
+
+ // While ParseHeaders(R1) is still in flight, send a second redirect (R2).
+ interceptor_ptr->SendSecondRedirect();
+
+ // The second redirect arrives while the first redirect's ParseHeaders is
+ // in flight. This must immediately fail with ERR_UNEXPECTED instead of
+ // corrupting the loader's redirect state.
+ delegate.WaitForRequestFailed();
+ EXPECT_EQ(delegate.net_error(), net::ERR_UNEXPECTED);
+ EXPECT_EQ(delegate.on_redirect_handled_counter(), 0);
+
+ // No further request (such as R2) should be issued.
+ EXPECT_EQ(interceptor_ptr->request_count(), 1);
+ EXPECT_EQ(interceptor_ptr->requested_urls().size(), 1u);
+}
+
+// Test receiving a second redirect after ParseHeaders() has completed and
+// OnRequestRedirected() has been dispatched to the delegate, but before
+// FollowRedirect() is invoked (e.g. while NavigationThrottles are executing).
+TEST_F(NavigationURLLoaderImplTest, DoubleRedirectWhileThrottleChecksPending) {
+ const GURL start_url("http://example.com/start");
+ const GURL r1_url("http://example.com/r1");
+ const GURL r2_url("chrome-extension://abcd/manifest.json");
+
+ TestNavigationURLLoaderDelegate delegate;
+ auto interceptor =
+ std::make_unique<TestDoubleRedirectInterceptor>(r1_url, r2_url);
+ auto* interceptor_ptr = interceptor.get();
+ std::vector<std::unique_ptr<NavigationLoaderInterceptor>> interceptors;
+ interceptors.push_back(std::move(interceptor));
+
+ auto loader =
+ CreateTestLoader(start_url, std::string(), "GET", &delegate,
+ blink::NavigationDownloadPolicy(),
+ /*is_main_frame=*/true,
+ /*upgrade_if_insecure=*/false,
+ /*is_ad_tagged=*/false, std::move(interceptors));
+ loader->Start();
+
+ // Wait for R1's ParseHeaders to finish and OnRequestRedirected to be called.
+ delegate.WaitForRequestRedirected();
+ EXPECT_EQ(delegate.on_redirect_handled_counter(), 1);
+ EXPECT_EQ(delegate.redirect_info().new_url, r1_url);
+
+ // Send R2 while waiting for FollowRedirect(). HasExclusiveTask() is still
+ // true.
+ interceptor_ptr->SendSecondRedirect();
+
+ delegate.WaitForRequestFailed();
+ EXPECT_EQ(delegate.net_error(), net::ERR_UNEXPECTED);
+
+ // If a racing throttle callback invokes FollowRedirect() afterwards,
+ // ShouldCancelExclusiveTask must cleanly drop the call without restarting.
+ loader->FollowRedirect({});
+
+ EXPECT_EQ(interceptor_ptr->request_count(), 1);
+ EXPECT_EQ(interceptor_ptr->requested_urls().size(), 1u);
+}
+
+// Test receiving OnReceiveResponse while ParseHeaders() for a redirect is in
+// flight.
+TEST_F(NavigationURLLoaderImplTest, ResponseWhileRedirectParseHeadersPending) {
+ const GURL start_url("http://example.com/start");
+ const GURL r1_url("http://example.com/r1");
+
+ TestNavigationURLLoaderDelegate delegate;
+ auto interceptor =
+ std::make_unique<TestDoubleRedirectInterceptor>(r1_url, r1_url);
+ auto* interceptor_ptr = interceptor.get();
+ std::vector<std::unique_ptr<NavigationLoaderInterceptor>> interceptors;
+ interceptors.push_back(std::move(interceptor));
+
+ auto loader =
+ CreateTestLoader(start_url, std::string(), "GET", &delegate,
+ blink::NavigationDownloadPolicy(),
+ /*is_main_frame=*/true,
+ /*upgrade_if_insecure=*/false,
+ /*is_ad_tagged=*/false, std::move(interceptors));
+ loader->Start();
+
+ // Force the async ParseHeaders() path for redirects.
+ delegate.set_clear_parsed_headers_on_redirect(true);
+
+ // Wait for R1 to be received by NavigationURLLoaderImpl.
+ delegate.WaitForOnReceiveRedirect();
+ EXPECT_EQ(delegate.on_redirect_handled_counter(), 0);
+
+ // While ParseHeaders(R1) is still in flight, send OnReceiveResponse.
+ interceptor_ptr->SendResponse();
+
+ delegate.WaitForRequestFailed();
+ EXPECT_EQ(delegate.net_error(), net::ERR_UNEXPECTED);
+ EXPECT_EQ(delegate.on_redirect_handled_counter(), 0);
+ EXPECT_EQ(delegate.on_request_handled_counter(), 1);
+}
+
} // namespace content