CVE-2026-87534
Overview
Background
- `AwContentRestrictionURLLoaderThrottle`
- An Android WebView
blink::URLLoaderThrottlethat intercepts network requests and asks a content-restriction manager whether the destination is allowed to load. - `WillRedirectRequest`
- A
blink::URLLoaderThrottlehook invoked when the server answers a request with an HTTP redirect, letting the throttle inspect the newnet::RedirectInfobefore the request is re-issued. - Content restriction (`IsContentRestrictionEnabled`)
- A WebView policy layer that classifies each navigation via
RequestContentClassificationand blocks disallowed destinations withnet::ERR_BLOCKED_BY_CLIENT. - `RequestContentClassification`
- The asynchronous authorization call that hands a URL and method to
AwContentRestrictionManagerClientand returns an allow/block verdict throughOnClassificationResult.
Root Cause Analysis
The throttle enforced content restriction only in WillStartRequest, classifying the original request URL and deferring until RequestContentClassification returned a verdict. It provided no WillRedirectRequest override, so when a server responded with an HTTP 3xx redirect, the throttle did not re-invoke classification for the new target and the redirected request proceeded without any authorization check. The violated invariant is that every navigation destination subject to content restriction must be classified before it loads; a server-controlled redirect broke that invariant by moving the effective destination after the single start-time check.
The fix adds WillRedirectRequest, which sets *defer = true and calls RequestContentClassification on a network::ResourceRequest built from redirect_info->new_url and redirect_info->new_method, so the redirect target is authorized on the same path (OnClassificationResult) as the initial request.
The patch deliberately omits the request body and content-type header because a redirect reuses those from the original request.
WillRedirectRequest to defer and re-classify the redirect’s new URL and method through the same content-restriction path.Attack Path
- Serve a restricted target behind a redirect
An attacker hosts a URL that passes (or is never meant to reach) the initial
WillStartRequestcheck but responds with an HTTP 3xx redirect to content that content restriction should block. - Original request is classified and allowed
WillStartRequestclassifies only the first URL, which the policy permits, so the request is resumed. - Server redirects to the restricted destination
The server returns a redirect; before the fix,
WillRedirectRequestwas absent, so the throttle never calledRequestContentClassificationforredirect_info->new_url. - Redirected load bypasses authorization The WebView loads the redirect target without a classification verdict, defeating the content-restriction policy for that navigation.
Impact Assessment
IsContentRestrictionEnabled) for a navigation with a valid navigation_id_, and that the attacker can cause a server-side HTTP redirect to the restricted destination. The result is a missing-authorization / policy-enforcement bypass rather than memory corruption, consistent with the medium-severity logic-error classification.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fandroid_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle_unittest.cc |
modified |
Files Changed
android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle.ccandroid_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle.handroid_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle_unittest.ccandroid_webview/javatests/src/org/chromium/android_webview/test/AwContentRestrictionTest.java
Audit Directions
- Per-hook policy coverageFor any
blink::URLLoaderThrottle(or similar interceptor) that enforces a security decision inWillStartRequest, verify the same check exists inWillRedirectRequestand other continuation hooks, since a single start-time check misses server-controlled redirects. - Redirect-target re-validationAudit content-classification, allowlist, and authorization logic to confirm it re-evaluates
redirect_info->new_urlandredirect_info->new_methodrather than trusting the initial request, and that any fields deliberately reused (body,Content-Type) are genuinely invariant across the redirect. - Deferral completenessCheck that each authorization path sets
*deferand only resumes on an explicit allow verdict (OnClassificationResult), so no code path lets a request or redirect proceed while classification is still pending or was skipped.
Patch
From 3434442606fb33e6554564f6ed118a2a707e26b3 Mon Sep 17 00:00:00 2001 From: Vignesh Shenvi <[email protected]> Date: Thu, 30 Jul 2026 15:33:00 -0700 Subject: [PATCH] Update content restriction URL loader throttle to classify redirects This change updates the content restriction URL loader throttle to also classify server side redirects. We only capture the method and the URL since the request body would be identical to the original request. Bug: b:513134173 Change-Id: Ie58712bf3c69f4418328cdecde05cb8f8d5769f6 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8159220 Reviewed-by: Luke Cao <[email protected]> Reviewed-by: Nate Fischer <[email protected]> Commit-Queue: Vignesh Shenvi <[email protected]> Cr-Commit-Position: refs/heads/main@{#1671470} --- diff --git a/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle.cc b/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle.cc index 0f1f6a3..039d6a1a 100644 --- a/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle.cc +++ b/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle.cc @@ -33,9 +33,11 @@ #include "mojo/public/cpp/system/data_pipe_drainer.h" #include "mojo/public/cpp/system/simple_watcher.h" #include "net/base/net_errors.h" +#include "net/url_request/redirect_info.h" #include "services/network/public/cpp/data_element.h" #include "services/network/public/mojom/chunked_data_pipe_getter.mojom.h" #include "services/network/public/mojom/data_pipe_getter.mojom.h" +#include "services/network/public/mojom/url_response_head.mojom.h" namespace android_webview { namespace { @@ -601,6 +603,29 @@ } } +void AwContentRestrictionURLLoaderThrottle::WillRedirectRequest( + net::RedirectInfo* redirect_info, + const network::mojom::URLResponseHead& response_head, + bool* defer, + network::HttpRequestHeadersUpdateParams* headers_update_params) { + DCHECK(content_restriction_manager_client_); + if (navigation_id_.has_value() && + content_restriction_manager_client_->IsContentRestrictionEnabled()) { + *defer = true; + + // There is no need to share the request body or the content type header + // value as they will be identical to the original request. + network::ResourceRequest request; + request.url = redirect_info->new_url; + request.method = redirect_info->new_method; + content_restriction_manager_client_->RequestContentClassification( + navigation_id_.value(), request, + base::BindOnce( + &AwContentRestrictionURLLoaderThrottle::OnClassificationResult, + weak_ptr_factory_.GetWeakPtr())); + } +} + void AwContentRestrictionURLLoaderThrottle::OnClassificationResult( bool is_allowed) { DCHECK(delegate_); diff --git a/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle.h b/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle.h index c33a3df..331d643 100644 --- a/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle.h +++ b/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle.h @@ -38,6 +38,11 @@ // blink::URLLoaderThrottle: void WillStartRequest(network::ResourceRequest* request, bool* defer) override; + void WillRedirectRequest( + net::RedirectInfo* redirect_info, + const network::mojom::URLResponseHead& response_head, + bool* defer, + network::HttpRequestHeadersUpdateParams* headers_update_params) override; private: // Asynchronous bridge used to stream chunked and non-chunked data from diff --git a/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle_unittest.cc b/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle_unittest.cc index d938cb70..7094ef2 100644 --- a/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle_unittest.cc +++ b/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle_unittest.cc @@ -24,9 +24,11 @@ #include "mojo/public/cpp/bindings/receiver.h" #include "mojo/public/cpp/system/data_pipe.h" #include "net/base/net_errors.h" +#include "net/url_request/redirect_info.h" #include "services/network/public/cpp/resource_request.h" #include "services/network/public/mojom/chunked_data_pipe_getter.mojom.h" #include "services/network/public/mojom/data_pipe_getter.mojom.h" +#include "services/network/public/mojom/url_response_head.mojom.h" #include "services/network/test/test_data_pipe_getter.h" #include "testing/gmock/include/gmock/gmock.h" #include "testing/gtest/include/gtest/gtest.h" @@ -240,6 +242,19 @@ callback) { std::move(callback).Run(is_allowed); })); } + void MockRedirectRequestContentClassification(bool is_allowed) { + EXPECT_CALL(mock_client_, RequestContentClassification(_, _, _)) + .WillOnce(WithArgs<1, 2>( + [is_allowed]( + const network::ResourceRequest& request, + AwContentRestrictionManagerClient::ContentClassificationCallback + callback) { + EXPECT_EQ(request.method, "GET"); + EXPECT_EQ(request.url, GURL(kTestUrl)); + std::move(callback).Run(is_allowed); + })); + } + std::string ReadPipeContent(int fd) { char buffer[1024]; ssize_t bytes_read = HANDLE_EINTR(read(fd, buffer, sizeof(buffer))); @@ -256,6 +271,13 @@ return request; } + net::RedirectInfo CreateTestRedirectRequest() { + net::RedirectInfo redirect_info; + redirect_info.new_url = GURL(kTestUrl); + redirect_info.new_method = "GET"; + return redirect_info; + } + template <typename... Elements> network::ResourceRequest CreatePostRequestWithElements( Elements... data_elements) { @@ -742,5 +764,55 @@ EXPECT_EQ(ReadPipeContent(read_fd.get()), ""); } +TEST_F(AwContentRestrictionURLLoaderThrottleTest, + AllowRedirectsWhenContentRestrictionDisabled) { + EXPECT_CALL(mock_client_, IsContentRestrictionEnabled()) + .WillOnce(Return(false)); + + net::RedirectInfo redirect_info = CreateTestRedirectRequest(); + bool defer = false; + network::mojom::URLResponseHead url_response_head; + throttle_.WillRedirectRequest(&redirect_info, url_response_head, &defer, + /*headers_update_params=*/nullptr); + + EXPECT_FALSE(defer); + EXPECT_FALSE(delegate_.resume_called()); + EXPECT_FALSE(delegate_.cancel_called()); +} + +TEST_F(AwContentRestrictionURLLoaderThrottleTest, AllowRedirectRequest) { + EXPECT_CALL(mock_client_, IsContentRestrictionEnabled()) + .WillOnce(Return(true)); + + net::RedirectInfo redirect_info = CreateTestRedirectRequest(); + MockRedirectRequestContentClassification(/*is_allowed=*/true); + bool defer = false; + network::mojom::URLResponseHead url_response_head; + throttle_.WillRedirectRequest(&redirect_info, url_response_head, &defer, + /*headers_update_params=*/nullptr); + + EXPECT_TRUE(defer); + EXPECT_TRUE(delegate_.resume_called()); + EXPECT_FALSE(delegate_.cancel_called()); +} + +TEST_F(AwContentRestrictionURLLoaderThrottleTest, BlockRedirectRequest) { + EXPECT_CALL(mock_client_, IsContentRestrictionEnabled()) + .WillOnce(Return(true)); + + net::RedirectInfo redirect_info = CreateTestRedirectRequest(); + MockRedirectRequestContentClassification(/*is_allowed=*/false); + bool defer = false; + network::mojom::URLResponseHead url_response_head; + throttle_.WillRedirectRequest(&redirect_info, url_response_head, &defer, + /*headers_update_params=*/nullptr); + + EXPECT_TRUE(defer); + EXPECT_FALSE(delegate_.resume_called()); + EXPECT_TRUE(delegate_.cancel_called()); + EXPECT_EQ(delegate_.error_code(), net::ERR_BLOCKED_BY_CLIENT); + EXPECT_TRUE(tracker_.IsNavigationBlocked(kTestNavigationId)); +} + } // namespace } // namespace android_webview diff --git a/android_webview/javatests/src/org/chromium/android_webview/test/AwContentRestrictionTest.java b/android_webview/javatests/src/org/chromium/android_webview/test/AwContentRestrictionTest.java index dd81688..b2545e6 100644 --- a/android_webview/javatests/src/org/chromium/android_webview/test/AwContentRestrictionTest.java +++ b/android_webview/javatests/src/org/chromium/android_webview/test/AwContentRestrictionTest.java @@ -60,6 +60,7 @@ private static final String LEARN_MORE_LINK_ID = "learn-more-link"; private static final String ALLOWED_PAYLOAD = "allowed"; private static final String BLOCKED_PAYLOAD = "blocked"; + private static final String REDIRECT_SITE_PATH = "/redirect.html"; private AwContents mAwContents; private TestWebServer mWebServer; @@ -318,4 +319,33 @@ waitForInterstitialPageLoad();
Regression Test / PoC
diff --git a/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle_unittest.cc b/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle_unittest.cc
index d938cb70..7094ef2 100644
--- a/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle_unittest.cc
+++ b/android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle_unittest.cc
@@ -24,9 +24,11 @@
#include "mojo/public/cpp/bindings/receiver.h"
#include "mojo/public/cpp/system/data_pipe.h"
#include "net/base/net_errors.h"
+#include "net/url_request/redirect_info.h"
#include "services/network/public/cpp/resource_request.h"
#include "services/network/public/mojom/chunked_data_pipe_getter.mojom.h"
#include "services/network/public/mojom/data_pipe_getter.mojom.h"
+#include "services/network/public/mojom/url_response_head.mojom.h"
#include "services/network/test/test_data_pipe_getter.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
@@ -240,6 +242,19 @@
callback) { std::move(callback).Run(is_allowed); }));
}
+ void MockRedirectRequestContentClassification(bool is_allowed) {
+ EXPECT_CALL(mock_client_, RequestContentClassification(_, _, _))
+ .WillOnce(WithArgs<1, 2>(
+ [is_allowed](
+ const network::ResourceRequest& request,
+ AwContentRestrictionManagerClient::ContentClassificationCallback
+ callback) {
+ EXPECT_EQ(request.method, "GET");
+ EXPECT_EQ(request.url, GURL(kTestUrl));
+ std::move(callback).Run(is_allowed);
+ }));
+ }
+
std::string ReadPipeContent(int fd) {
char buffer[1024];
ssize_t bytes_read = HANDLE_EINTR(read(fd, buffer, sizeof(buffer)));
@@ -256,6 +271,13 @@
return request;
}
+ net::RedirectInfo CreateTestRedirectRequest() {
+ net::RedirectInfo redirect_info;
+ redirect_info.new_url = GURL(kTestUrl);
+ redirect_info.new_method = "GET";
+ return redirect_info;
+ }
+
template <typename... Elements>
network::ResourceRequest CreatePostRequestWithElements(
Elements... data_elements) {
@@ -742,5 +764,55 @@
EXPECT_EQ(ReadPipeContent(read_fd.get()), "");
}
+TEST_F(AwContentRestrictionURLLoaderThrottleTest,
+ AllowRedirectsWhenContentRestrictionDisabled) {
+ EXPECT_CALL(mock_client_, IsContentRestrictionEnabled())
+ .WillOnce(Return(false));
+
+ net::RedirectInfo redirect_info = CreateTestRedirectRequest();
+ bool defer = false;
+ network::mojom::URLResponseHead url_response_head;
+ throttle_.WillRedirectRequest(&redirect_info, url_response_head, &defer,
+ /*headers_update_params=*/nullptr);
+
+ EXPECT_FALSE(defer);
+ EXPECT_FALSE(delegate_.resume_called());
+ EXPECT_FALSE(delegate_.cancel_called());
+}
+
+TEST_F(AwContentRestrictionURLLoaderThrottleTest, AllowRedirectRequest) {
+ EXPECT_CALL(mock_client_, IsContentRestrictionEnabled())
+ .WillOnce(Return(true));
+
+ net::RedirectInfo redirect_info = CreateTestRedirectRequest();
+ MockRedirectRequestContentClassification(/*is_allowed=*/true);
+ bool defer = false;
+ network::mojom::URLResponseHead url_response_head;
+ throttle_.WillRedirectRequest(&redirect_info, url_response_head, &defer,
+ /*headers_update_params=*/nullptr);
+
+ EXPECT_TRUE(defer);
+ EXPECT_TRUE(delegate_.resume_called());
+ EXPECT_FALSE(delegate_.cancel_called());
+}
+
+TEST_F(AwContentRestrictionURLLoaderThrottleTest, BlockRedirectRequest) {
+ EXPECT_CALL(mock_client_, IsContentRestrictionEnabled())
+ .WillOnce(Return(true));
+
+ net::RedirectInfo redirect_info = CreateTestRedirectRequest();
+ MockRedirectRequestContentClassification(/*is_allowed=*/false);
+ bool defer = false;
+ network::mojom::URLResponseHead url_response_head;
+ throttle_.WillRedirectRequest(&redirect_info, url_response_head, &defer,
+ /*headers_update_params=*/nullptr);
+
+ EXPECT_TRUE(defer);
+ EXPECT_FALSE(delegate_.resume_called());
+ EXPECT_TRUE(delegate_.cancel_called());
+ EXPECT_EQ(delegate_.error_code(), net::ERR_BLOCKED_BY_CLIENT);
+ EXPECT_TRUE(tracker_.IsNavigationBlocked(kTestNavigationId));
+}
+
} // namespace
} // namespace android_webview
diff --git a/android_webview/javatests/src/org/chromium/android_webview/test/AwContentRestrictionTest.java b/android_webview/javatests/src/org/chromium/android_webview/test/AwContentRestrictionTest.java
index dd81688..b2545e6 100644
--- a/android_webview/javatests/src/org/chromium/android_webview/test/AwContentRestrictionTest.java
+++ b/android_webview/javatests/src/org/chromium/android_webview/test/AwContentRestrictionTest.java
@@ -60,6 +60,7 @@
private static final String LEARN_MORE_LINK_ID = "learn-more-link";
private static final String ALLOWED_PAYLOAD = "allowed";
private static final String BLOCKED_PAYLOAD = "blocked";
+ private static final String REDIRECT_SITE_PATH = "/redirect.html";
private AwContents mAwContents;
private TestWebServer mWebServer;
@@ -318,4 +319,33 @@
waitForInterstitialPageLoad();
Assert.assertFalse("Go back link should be hidden", isElementVisible(GO_BACK_LINK_ID));
}
+
+ @Test
+ @MediumTest
+ @Feature({"AndroidWebView"})
+ @EnableFeatures({AwFeatures.WEBVIEW_CONTENT_RESTRICTION_SUPPORT})
+ public void testRedirectToBlockedSite() throws Throwable {
+ String redirectUrl =
+ mWebServer.setRedirect(
+ REDIRECT_SITE_PATH, mWebServer.getResponseUrl(BLOCKED_SITE_PATH));
+ mActivityTestRule.loadUrlAsync(mAwContents, redirectUrl);
+ waitForInterstitialPageLoad();
+ }
+
+ @Test
+ @MediumTest
+ @Feature({"AndroidWebView"})
+ @EnableFeatures({AwFeatures.WEBVIEW_CONTENT_RESTRICTION_SUPPORT})
+ public void testRedirectToAllowedSite() throws Throwable {
+ String redirectUrl =
+ mWebServer.setRedirect(
+ REDIRECT_SITE_PATH, mWebServer.getResponseUrl(ALLOWED_SITE_2_PATH));
+ int initialHistoryCount = getNavigationHistoryEntryCount();
+ mActivityTestRule.loadUrlSync(
+ mAwContents, mContentsClient.getOnPageFinishedHelper(), redirectUrl);
+
+ Assert.assertEquals(initialHistoryCount, getNavigationHistoryEntryCount());
+ Assert.assertEquals(
+ ALLOWED_SITE_2_TITLE, mActivityTestRule.getTitleOnUiThread(mAwContents));
+ }
}
Original Bug Report
Potential ContentRestriction bypass via server-side redirects in Android WebView
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The AwContentRestrictionURLLoaderThrottle fails to override the WillRedirectRequest method, which allows server-side redirects to bypass content classification checks. This enables access to restricted URLs if they are reached via a redirect from an allowed origin.
Affected files:
android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle.ccandroid_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle.h
Estimated timestamp from git blame: 2026-03-30
Description
The AwContentRestrictionURLLoaderThrottle class is responsible for enforcing platform-level content restrictions in Android WebView (e.g., for supervised users). Currently, the implementation only performs URL classification during the initial request phase within WillStartRequest().
However, the class does not override WillRedirectRequest(). In the Chromium network stack, when a server responds with a redirect (e.g., HTTP 302), the ThrottlingURLLoader invokes WillRedirectRequest on all registered throttles. Since AwContentRestrictionURLLoaderThrottle uses the default base class implementation, which is a no-op, the redirected URL is never sent for classification. This allows a navigation to proceed to a restricted URL as long as it originates from an allowed redirector.
This issue affects the kWebViewContentRestrictionSupport feature. While this feature is currently disabled by default, it is intended to provide critical platform-level security and parental control enforcement for WebView-based applications.
Potential Root Cause
In android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle.cc, classification is only initiated in WillStartRequest:
void AwContentRestrictionURLLoaderThrottle::WillStartRequest(
network::ResourceRequest* request, bool* defer) {
if (navigation_id_.has_value() &&
content_restriction_manager_client_->IsContentRestrictionEnabled()) {
*defer = true;
content_restriction_manager_client_->RequestContentClassification(
navigation_id_.value(), *request,
base::BindOnce(&AwContentRestrictionURLLoaderThrottle::OnClassificationResult,
weak_ptr_factory_.GetWeakPtr()));
}
}
Because WillRedirectRequest is not overridden, the throttle does not defer or check subsequent URLs in the redirect chain. Consequently, the redirect target is loaded without being validated against the platform’s content restriction policy.
Suggested Attack Steps
An attacker could potentially bypass content restrictions using the following steps (not verified with a running PoC):
- Identify a URL that is allowed by the content restriction policy (e.g.,
https://allowed.example). - Set up a server-side redirect on that allowed origin (or use a known open redirector) that points to a restricted destination (e.g.,
https://restricted.example/content). - Navigate the WebView to the allowed redirector URL.
- Observe that the WebView follows the redirect and loads the restricted content because the throttle only validated the initial
allowed.exampleURL.
Suggested Fix
The AwContentRestrictionURLLoaderThrottle class should override WillRedirectRequest and implement classification logic similar to WillStartRequest. It should defer the redirect and invoke RequestContentClassification for the new URL provided in the RedirectInfo.
File: android_webview/browser/content_restriction/aw_content_restriction_url_loader_throttle.cc and .h
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.