Skip to content

Commit 34795f0

Browse files
committed
Strip Sec-CH-* headers on trustworthiness downgrade redirect (fixes #4207)
Fixes a Client Hints metadata leak on HTTP redirects of renderer-initiated subresource requests. On redirect, CEF restarts the request through the URLLoaderFactory (InterceptedRequest::FollowRedirect -> Restart -> ContinueToBeforeRedirect) instead of following it on the existing loader. Stock Chromium's in-place path calls network::MaybeRemoveSecHeaders in URLLoader::FollowRedirect, which strips sec-ch-* (and sec-fetch-*) prefixed headers when the redirect goes from a potentially trustworthy URL to an untrustworthy one, so that Client Hints attached on the prior hop are not disclosed to a destination that would never have received them directly. CEF's restart path skipped that step and re-submitted the carried-over Sec-CH-* headers. The fix strips the Sec-CH- header family by prefix before the restart, conditional on the same trustworthiness downgrade check (network::IsUrlPotentiallyTrustworthy). Unlike Sec-Fetch-* (see #4190), Sec-CH-* headers are not re-derived downstream, so they must be preserved on all other redirects. Two redirect shapes bypass CEF's restart path entirely and behave identically in stock Chromium (out of scope here): redirects out of the network service's URL space (e.g. to a custom scheme), which the renderer re-issues itself via ThrottlingURLLoader::FollowRedirectForcingRestart with blink-level headers; and mode "cors" requests whose CORS flag flips on redirect, which CorsURLLoader::StartRequest re-issues from its own header copy. Adds CorsTest.ScriptRedirectSubresourceSecHeaders* regression tests covering the same-trust, cross-origin-trustworthy and downgrade cases, asserting Sec-CH-*/Sec-Fetch-* presence on both the redirecting hop and the target.
1 parent 55db913 commit 34795f0

2 files changed

Lines changed: 199 additions & 1 deletion

File tree

libcef/browser/net_service/proxy_url_loader_factory.cc

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
#include "net/url_request/url_request.h"
3434
#include "services/network/public/cpp/cors/cors.h"
3535
#include "services/network/public/cpp/features.h"
36+
#include "services/network/public/cpp/is_potentially_trustworthy.h"
3637
#include "services/network/public/mojom/early_hints.mojom.h"
3738
#include "third_party/blink/public/common/loader/referrer_utils.h"
3839

@@ -1076,9 +1077,22 @@ void InterceptedRequest::ContinueToBeforeRedirect(
10761077
// network-managed Sec-Fetch-* headers carried over from the previous hop and
10771078
// kills the process. Strip the whole family by prefix (like
10781079
// network::MaybeRemoveSecHeaders); URLLoader re-derives them downstream.
1080+
//
1081+
// Sec-CH-* Client Hints headers from the previous hop must also be stripped
1082+
// when the redirect downgrades from a potentially trustworthy URL to an
1083+
// untrustworthy one (again like network::MaybeRemoveSecHeaders, which runs
1084+
// on the non-restart redirect path); the destination would never have
1085+
// received them directly. Unlike Sec-Fetch-* they are not re-derived
1086+
// downstream, so they must be preserved on all other redirects.
1087+
const bool strip_client_hints =
1088+
network::IsUrlPotentiallyTrustworthy(original_url) &&
1089+
!network::IsUrlPotentiallyTrustworthy(request_.url);
10791090
for (const auto& header : request_.headers.GetHeaderVector()) {
10801091
if (base::StartsWith(header.key, "Sec-Fetch-",
1081-
base::CompareCase::INSENSITIVE_ASCII)) {
1092+
base::CompareCase::INSENSITIVE_ASCII) ||
1093+
(strip_client_hints &&
1094+
base::StartsWith(header.key, "Sec-CH-",
1095+
base::CompareCase::INSENSITIVE_ASCII))) {
10821096
remove_headers.push_back(header.key);
10831097
}
10841098
}

tests/ceftests/cors_unittest.cc

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// can be found in the LICENSE file.
44

55
#include <algorithm>
6+
#include <cctype>
67
#include <set>
78
#include <sstream>
89
#include <vector>
@@ -58,6 +59,7 @@ const char kFailureMsg[] = "CorsTestHandler.Failure";
5859
enum class HandlerType {
5960
SERVER,
6061
HTTP_SCHEME,
62+
HTTP_INSECURE_SCHEME,
6163
CUSTOM_STANDARD_SCHEME,
6264
CUSTOM_NONSTANDARD_SCHEME,
6365
CUSTOM_UNREGISTERED_SCHEME,
@@ -79,6 +81,12 @@ std::string GetOrigin(HandlerType handler) {
7981
// Use HTTPS because requests from HTTP to the loopback address will be
8082
// blocked by https://chromestatus.com/feature/5436853517811712.
8183
return "https://corstest.com";
84+
case HandlerType::HTTP_INSECURE_SCHEME:
85+
// Like HTTP_SCHEME, but HTTP so the origin is not potentially
86+
// trustworthy. Only usable as a redirect target (served via
87+
// GetResourceHandler); as an initiator it would be subject to the
88+
// loopback address blocking described above.
89+
return "http://corstest.com";
8290
case HandlerType::CUSTOM_STANDARD_SCHEME:
8391
// Standard scheme that's registered as CORS and fetch enabled.
8492
// Registered in scheme_handler_unittest.cc.
@@ -102,6 +110,8 @@ std::string GetScheme(HandlerType handler) {
102110
return test_server::GetScheme(kUseHttpsServerScheme);
103111
case HandlerType::HTTP_SCHEME:
104112
return "https";
113+
case HandlerType::HTTP_INSECURE_SCHEME:
114+
return "http";
105115
case HandlerType::CUSTOM_STANDARD_SCHEME:
106116
return "customstdfetch";
107117
case HandlerType::CUSTOM_NONSTANDARD_SCHEME:
@@ -1725,6 +1735,180 @@ void SetupExecRedirectRequest(ExecMode mode,
17251735
CORS_TEST_EXEC_REDIRECT(XhrRedirectSubresource, XHR)
17261736
CORS_TEST_EXEC_REDIRECT(FetchRedirectSubresource, FETCH)
17271737

1738+
// Test that Sec-CH-* (Client Hints) and Sec-Fetch-* headers are correctly
1739+
// sanitized when a renderer-initiated subresource request is redirected.
1740+
namespace {
1741+
1742+
// Returns true if |header_map| contains a header whose name starts with
1743+
// |prefix| (case-insensitive).
1744+
bool HasHeaderWithPrefix(const CefRequest::HeaderMap& header_map,
1745+
const std::string& prefix) {
1746+
for (const auto& [name, value] : header_map) {
1747+
const std::string& name_str = name.ToString();
1748+
if (name_str.size() < prefix.size()) {
1749+
continue;
1750+
}
1751+
bool match = true;
1752+
for (size_t i = 0; i < prefix.size(); ++i) {
1753+
if (std::tolower(static_cast<unsigned char>(name_str[i])) !=
1754+
std::tolower(static_cast<unsigned char>(prefix[i]))) {
1755+
match = false;
1756+
break;
1757+
}
1758+
}
1759+
if (match) {
1760+
return true;
1761+
}
1762+
}
1763+
return false;
1764+
}
1765+
1766+
// Verifies the presence or absence of Sec-CH-* and Sec-Fetch-* headers on the
1767+
// received request.
1768+
struct SecHeaderResource : Resource {
1769+
bool expect_sec_ch = false;
1770+
bool expect_sec_fetch = false;
1771+
1772+
bool VerifyRequest(CefRefPtr<CefRequest> request) const override {
1773+
CefRequest::HeaderMap header_map;
1774+
request->GetHeaderMap(header_map);
1775+
const bool has_sec_ch = HasHeaderWithPrefix(header_map, "sec-ch-");
1776+
const bool has_sec_fetch = HasHeaderWithPrefix(header_map, "sec-fetch-");
1777+
EXPECT_EQ(expect_sec_ch, has_sec_ch) << GetPathURL();
1778+
EXPECT_EQ(expect_sec_fetch, has_sec_fetch) << GetPathURL();
1779+
return expect_sec_ch == has_sec_ch && expect_sec_fetch == has_sec_fetch;
1780+
}
1781+
};
1782+
1783+
const char kMimeTypeJs[] = "text/javascript";
1784+
1785+
// Returns HTML for a main page that loads |sub_url| as a classic <script>.
1786+
// The script request uses mode "no-cors", which matters for redirect path
1787+
// selection: a cross-origin redirect of a mode "cors" request whose CORS flag
1788+
// or method changes is re-issued by CorsURLLoader::StartRequest from
1789+
// CorsURLLoader's own copy of the request headers, while a "no-cors" request
1790+
// follows the redirect on the existing loader (CorsURLLoader::FollowRedirect
1791+
// -> URLLoader::FollowRedirect), which is the path that CEF replaces with
1792+
// InterceptedRequest::FollowRedirect -> Restart. Only the latter path is
1793+
// under test here. For the same reason the redirect target must be an
1794+
// http(s) URL: a redirect out of the network service's URL space (e.g. to a
1795+
// custom scheme) is re-issued by the renderer with its own copy of the
1796+
// request headers (ThrottlingURLLoader::FollowRedirectForcingRestart) and
1797+
// never takes the redirect path at all.
1798+
std::string GetScriptExecMainHtml(const std::string& sub_url) {
1799+
return "<html><head>\n"
1800+
"<script language=\"JavaScript\">\n"
1801+
"function onResult(val) {\n"
1802+
" if (val === '" +
1803+
std::string(kDefaultText) + "') {" + GetSuccessMsgJS() + "} else {" +
1804+
GetFailureMsgJS() +
1805+
"}\n}\n"
1806+
"function execRequest() {\n"
1807+
" var s = document.createElement('script');\n"
1808+
" s.src = '" +
1809+
sub_url +
1810+
"';\n"
1811+
" s.onerror = function() { onResult('FAILURE'); };\n"
1812+
" document.head.appendChild(s);\n"
1813+
"}\n</script>\n"
1814+
"</head><body onload=\"execRequest();\">"
1815+
"Running execRequest..."
1816+
"</body></html>";
1817+
}
1818+
1819+
// On redirect CEF restarts the request through the URLLoaderFactory (see
1820+
// InterceptedRequest::FollowRedirect -> Restart) with the header set from the
1821+
// previous hop. Sec-Fetch-* headers are always removed before the restart
1822+
// (see XhrRedirectSubresource above) and re-derived by the network service
1823+
// for requests that reach it. Sec-CH-* headers must be removed only when the
1824+
// redirect goes from a potentially trustworthy URL (here, the loopback
1825+
// server) to an untrustworthy URL (here, an http URL on a non-loopback
1826+
// host), matching network::MaybeRemoveSecHeaders behavior on Chromium's
1827+
// in-place redirect path; on all other redirects they must be preserved
1828+
// because nothing re-derives them.
1829+
//
1830+
// The initial subresource request is same-origin to the (potentially
1831+
// trustworthy) server, so it carries both the renderer-set Sec-CH-* headers
1832+
// and the network-managed Sec-Fetch-* headers; that's asserted on the
1833+
// redirecting resource so the target expectations can't pass vacuously.
1834+
void SetupScriptRedirectSecHeadersRequest(HandlerType target_handler,
1835+
bool expect_target_sec_ch,
1836+
bool expect_target_sec_fetch,
1837+
TestSetup* setup,
1838+
const std::string& test_name,
1839+
Resource* main_resource,
1840+
SecHeaderResource* redirect_resource,
1841+
SecHeaderResource* target_resource) {
1842+
const std::string& base_path = "/" + test_name;
1843+
1844+
// Final resource the script request lands on after the redirect. Executing
1845+
// it reports success back to the main page.
1846+
target_resource->Init(target_handler, base_path + ".target.js", kMimeTypeJs,
1847+
"onResult('" + std::string(kDefaultText) + "');");
1848+
target_resource->expect_sec_ch = expect_target_sec_ch;
1849+
target_resource->expect_sec_fetch = expect_target_sec_fetch;
1850+
1851+
// Script request returns a redirect to the final resource.
1852+
redirect_resource->Init(HandlerType::SERVER, base_path + ".sub.js",
1853+
kMimeTypeJs, std::string());
1854+
redirect_resource->expect_sec_ch = true;
1855+
redirect_resource->expect_sec_fetch = true;
1856+
SetupRedirectResponse(RedirectMode::MODE_302, target_resource->GetPathURL(),
1857+
redirect_resource->response);
1858+
1859+
// Main page issues the script request to the redirecting URL.
1860+
main_resource->Init(HandlerType::SERVER, base_path, kMimeTypeHtml,
1861+
GetScriptExecMainHtml(redirect_resource->GetPathURL()));
1862+
main_resource->expected_success_query_ct = 1;
1863+
1864+
setup->AddResource(main_resource);
1865+
setup->AddResource(redirect_resource);
1866+
setup->AddResource(target_resource);
1867+
}
1868+
1869+
} // namespace
1870+
1871+
#define CORS_TEST_SCRIPT_REDIRECT_SEC_HEADERS(test_name, target_handler, \
1872+
expect_sec_ch, expect_sec_fetch) \
1873+
TEST(CorsTest, test_name) { \
1874+
TestSetup setup; \
1875+
Resource resource_main; \
1876+
SecHeaderResource resource_redirect; \
1877+
SecHeaderResource resource_target; \
1878+
SetupScriptRedirectSecHeadersRequest( \
1879+
HandlerType::target_handler, expect_sec_ch, expect_sec_fetch, &setup, \
1880+
"CorsTest." #test_name, &resource_main, &resource_redirect, \
1881+
&resource_target); \
1882+
CefRefPtr<CorsTestHandler> handler = new CorsTestHandler(&setup); \
1883+
handler->ExecuteTest(); \
1884+
ReleaseAndWaitForDestructor(handler); \
1885+
}
1886+
1887+
// Same-trustworthiness redirect (server to server): Sec-CH-* headers are
1888+
// preserved and Sec-Fetch-* headers are re-derived by the network service.
1889+
CORS_TEST_SCRIPT_REDIRECT_SEC_HEADERS(ScriptRedirectSubresourceSecHeadersServer,
1890+
SERVER,
1891+
/*expect_target_sec_ch=*/true,
1892+
/*expect_target_sec_fetch=*/true)
1893+
1894+
// Cross-origin redirect to a potentially trustworthy https URL (served via
1895+
// GetResourceHandler): Sec-CH-* headers are preserved. Sec-Fetch-* headers
1896+
// are removed before the restart and nothing re-derives them for requests
1897+
// that don't reach the network service.
1898+
CORS_TEST_SCRIPT_REDIRECT_SEC_HEADERS(
1899+
ScriptRedirectSubresourceSecHeadersHttpScheme,
1900+
HTTP_SCHEME,
1901+
/*expect_target_sec_ch=*/true,
1902+
/*expect_target_sec_fetch=*/false)
1903+
1904+
// Cross-origin redirect that downgrades to an untrustworthy http URL (served
1905+
// via GetResourceHandler): both header families are removed.
1906+
CORS_TEST_SCRIPT_REDIRECT_SEC_HEADERS(
1907+
ScriptRedirectSubresourceSecHeadersInsecureHttpScheme,
1908+
HTTP_INSECURE_SCHEME,
1909+
/*expect_target_sec_ch=*/false,
1910+
/*expect_target_sec_fetch=*/false)
1911+
17281912
namespace {
17291913

17301914
struct PostResource : CookieResource {

0 commit comments

Comments
 (0)