Chrome · FedCM
CVE-2026-17764
Logic Error in FedCM
Overview
Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
WillRedirectRequestcontent/browser/webid/navigation_interceptor.cc |
modified | |
WillProcessResponsecontent/browser/webid/navigation_interceptor.cc |
modified | |
ProcessRequestcontent/browser/webid/navigation_interceptor.cc |
modified | |
ifcontent/browser/webid/navigation_interceptor.cc |
modified |
Files Changed
content/browser/webid/navigation_interceptor.cccontent/browser/webid/navigation_interceptor.hcontent/browser/webid/navigation_interceptor_unittest.cc
Patch
From 2af692fec79c2041fcd939e82a8718fa00faabe2 Mon Sep 17 00:00:00 2001 From: Jochen Eisinger <[email protected]> Date: Wed, 24 Jun 2026 06:08:26 -0700 Subject: [PATCH] Fix same-origin bypass in FedCM NavigationInterceptor during redirects. When a navigation encounters a redirect, NavigationRequest updates its internal URL to the redirect target before notifying navigation throttles. As a result, NavigationInterceptor::ProcessRequest (running during WillRedirectRequest) would validate response headers against the target URL instead of the redirector URL. This allowed same-origin bypass checks. A dangling pointer crash in the new tests was fixed by ensuring NavigateAndCommit is called before MockFederatedAuthRequest is created, as NavigateAndCommit destroys/replaces the document associated data which Request holds a raw_ref to. TAG=agy Fixed: 511754400 Change-Id: Ic2488047915a0779e4b8b5961d8ed81aa59b6e63 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7965919 Commit-Queue: Jochen Eisinger <[email protected]> Reviewed-by: Christian Biesinger <[email protected]> Cr-Commit-Position: refs/heads/main@{#1651664} --- diff --git a/content/browser/webid/navigation_interceptor.cc b/content/browser/webid/navigation_interceptor.cc index 281b332..5458d12 100644 --- a/content/browser/webid/navigation_interceptor.cc +++ b/content/browser/webid/navigation_interceptor.cc @@ -75,16 +75,22 @@ NavigationThrottle::ThrottleCheckResult NavigationInterceptor::WillRedirectRequest() { - return ProcessRequest(); + // Despite the name of this method, the request was already redirected when + // this invoked. This implies that the headers we're about to process came + // from the 2nd to last URL on the redirect chain. + const std::vector<GURL>& redirect_chain = + navigation_handle()->GetRedirectChain(); + CHECK_GE(redirect_chain.size(), 2u); + return ProcessRequest(redirect_chain[redirect_chain.size() - 2]); } NavigationThrottle::ThrottleCheckResult NavigationInterceptor::WillProcessResponse() { - return ProcessRequest(); + return ProcessRequest(navigation_handle()->GetURL()); } -NavigationThrottle::ThrottleCheckResult -NavigationInterceptor::ProcessRequest() { +NavigationThrottle::ThrottleCheckResult NavigationInterceptor::ProcessRequest( + const GURL& intercepted_url) { if (!document_.AsRenderFrameHostIfValid()) { // Some other navigation has happened in the meantime. return PROCEED; @@ -165,7 +171,7 @@ data_decoder::DataDecoder::ParseStructuredHeaderDictionaryIsolated( *connection_status_header, base::BindOnce(&NavigationInterceptor::OnConnectionStatusHeaderParsed, - weak_ptr_factory_.GetWeakPtr())); + weak_ptr_factory_.GetWeakPtr(), intercepted_url)); } else { return PROCEED; } @@ -173,7 +179,7 @@ data_decoder::DataDecoder::ParseStructuredHeaderDictionaryIsolated( *intercept_header, base::BindOnce(&NavigationInterceptor::OnHeaderParsed, - weak_ptr_factory_.GetWeakPtr())); + weak_ptr_factory_.GetWeakPtr(), intercepted_url)); } else { return PROCEED; } @@ -187,6 +193,7 @@ } void NavigationInterceptor::OnConnectionStatusHeaderParsed( + const GURL& intercepted_url, base::expected<net::structured_headers::Dictionary, std::string> result) { content::RenderFrameHost* rfh = document_.AsRenderFrameHostIfValid(); if (!rfh) { @@ -224,9 +231,8 @@ } // The server can send this header without embedder login request. - if (net::SchemefulSite::IsSameSite( - embedder_login_request->idp_origin(), - url::Origin::Create(navigation_handle()->GetURL()))) { + if (net::SchemefulSite::IsSameSite(embedder_login_request->idp_origin(), + url::Origin::Create(intercepted_url))) { if (account_id == embedder_login_request->account_id()) { embedder_login_request->OnFederatedResultReceived( FederatedLoginResult::kSuccess); @@ -242,6 +248,7 @@ } void NavigationInterceptor::OnHeaderParsed( + const GURL& intercepted_url, base::expected<net::structured_headers::Dictionary, std::string> result) { content::RenderFrameHost* rfh = document_.AsRenderFrameHostIfValid(); if (!rfh) { @@ -260,8 +267,7 @@ } RequestBuilder request_builder; - auto idp_get_params_vector = - request_builder.Build(navigation_handle()->GetURL(), *result); + auto idp_get_params_vector = request_builder.Build(intercepted_url, *result); if (!idp_get_params_vector) { // The header was available, parsed, but contained an invalid set of @@ -274,7 +280,7 @@ request_factory_.Run(rfh)->RequestToken( std::move(*idp_get_params_vector), password_manager::CredentialMediationRequirement::kOptional, - navigation_handle(), + navigation_handle(), intercepted_url, base::BindOnce(&NavigationInterceptor::OnTokenResponse, weak_ptr_factory_.GetWeakPtr())); } diff --git a/content/browser/webid/navigation_interceptor.h b/content/browser/webid/navigation_interceptor.h index ae024a5e..34cd03e3 100644 --- a/content/browser/webid/navigation_interceptor.h +++ b/content/browser/webid/navigation_interceptor.h @@ -66,11 +66,13 @@ static void MaybeCreateAndAdd(NavigationThrottleRegistry& registry); private: - ThrottleCheckResult ProcessRequest(); + ThrottleCheckResult ProcessRequest(const GURL& intercepted_url); void OnHeaderParsed( + const GURL& intercepted_url, base::expected<net::structured_headers::Dictionary, std::string> result); void OnConnectionStatusHeaderParsed( + const GURL& intercepted_url, base::expected<net::structured_headers::Dictionary, std::string> result); void OnTokenResponse( blink::mojom::RequestTokenStatus status, diff --git a/content/browser/webid/navigation_interceptor_unittest.cc b/content/browser/webid/navigation_interceptor_unittest.cc index 838d463..44fdedf1 100644 --- a/content/browser/webid/navigation_interceptor_unittest.cc +++ b/content/browser/webid/navigation_interceptor_unittest.cc @@ -71,6 +71,7 @@ idp_get_params, password_manager::CredentialMediationRequirement mediation_requirement, NavigationHandle* navigation_handle, + const GURL& intercepted_url, RequestTokenCallback callback), (override)); MOCK_METHOD(void, CancelTokenRequest, (), (override)); @@ -269,11 +270,10 @@ })); base::RunLoop run_loop; - EXPECT_CALL(*federated_auth_request.get(), RequestToken) - .WillOnce([&](auto, auto, auto, auto) { - // When RequestToken is finally called, quit the RunLoop. - run_loop.Quit(); - }); + EXPECT_CALL(*federated_auth_request.get(), RequestToken).WillOnce([&]() { + // When RequestToken is finally called, quit the RunLoop. + run_loop.Quit(); + }); interceptor.WillStartRequest(); auto result = interceptor.WillProcessResponse(); @@ -323,11 +323,10 @@ })); base::RunLoop run_loop; - EXPECT_CALL(*federated_auth_request.get(), RequestToken) - .WillOnce([&](auto, auto, auto, auto) { - // When RequestToken is finally called, quit the RunLoop. - run_loop.Quit(); - }); + EXPECT_CALL(*federated_auth_request.get(), RequestToken).WillOnce([&]() { + // When RequestToken is finally called, quit the RunLoop. + run_loop.Quit(); + }); interceptor.WillStartRequest(); auto result = interceptor.WillProcessResponse(); @@ -381,11 +380,10 @@ })); base::RunLoop run_loop; - EXPECT_CALL(*federated_auth_request.get(), RequestToken) - .WillOnce([&](auto, auto, auto, auto) { - // When RequestToken is finally called, quit the RunLoop. - run_loop.Quit(); - }); + EXPECT_CALL(*federated_auth_request.get(), RequestToken).WillOnce([&]() { + // When RequestToken is finally called, quit the RunLoop. + run_loop.Quit(); + });
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/content/browser/webid/navigation_interceptor_unittest.cc b/content/browser/webid/navigation_interceptor_unittest.cc
index 838d463..44fdedf1 100644
--- a/content/browser/webid/navigation_interceptor_unittest.cc
+++ b/content/browser/webid/navigation_interceptor_unittest.cc
@@ -71,6 +71,7 @@
idp_get_params,
password_manager::CredentialMediationRequirement mediation_requirement,
NavigationHandle* navigation_handle,
+ const GURL& intercepted_url,
RequestTokenCallback callback),
(override));
MOCK_METHOD(void, CancelTokenRequest, (), (override));
@@ -269,11 +270,10 @@
}));
base::RunLoop run_loop;
- EXPECT_CALL(*federated_auth_request.get(), RequestToken)
- .WillOnce([&](auto, auto, auto, auto) {
- // When RequestToken is finally called, quit the RunLoop.
- run_loop.Quit();
- });
+ EXPECT_CALL(*federated_auth_request.get(), RequestToken).WillOnce([&]() {
+ // When RequestToken is finally called, quit the RunLoop.
+ run_loop.Quit();
+ });
interceptor.WillStartRequest();
auto result = interceptor.WillProcessResponse();
@@ -323,11 +323,10 @@
}));
base::RunLoop run_loop;
- EXPECT_CALL(*federated_auth_request.get(), RequestToken)
- .WillOnce([&](auto, auto, auto, auto) {
- // When RequestToken is finally called, quit the RunLoop.
- run_loop.Quit();
- });
+ EXPECT_CALL(*federated_auth_request.get(), RequestToken).WillOnce([&]() {
+ // When RequestToken is finally called, quit the RunLoop.
+ run_loop.Quit();
+ });
interceptor.WillStartRequest();
auto result = interceptor.WillProcessResponse();
@@ -381,11 +380,10 @@
}));
base::RunLoop run_loop;
- EXPECT_CALL(*federated_auth_request.get(), RequestToken)
- .WillOnce([&](auto, auto, auto, auto) {
- // When RequestToken is finally called, quit the RunLoop.
- run_loop.Quit();
- });
+ EXPECT_CALL(*federated_auth_request.get(), RequestToken).WillOnce([&]() {
+ // When RequestToken is finally called, quit the RunLoop.
+ run_loop.Quit();
+ });
interceptor.WillStartRequest();
auto result = interceptor.WillProcessResponse();
@@ -517,7 +515,7 @@
}));
EXPECT_CALL(*federated_auth_request.get(), RequestToken)
- .WillOnce(WithArgs<3>(
+ .WillOnce(WithArgs<4>(
[](blink::mojom::FederatedAuthRequest::RequestTokenCallback
callback) {
std::move(callback).Run(
@@ -1066,6 +1064,152 @@
EXPECT_TRUE(was_resumed);
}
+TEST_F(NavigationInterceptorTest,
+ WillRedirectRequestWithValidSameOriginInterception) {
+ // Uses an in-process data decoder service for testing.
+ data_decoder::test::InProcessDataDecoder in_process_data_decoder;
+
+ NavigateAndCommit(GURL("https://rp.example/"));
+
+ std::unique_ptr<MockFederatedAuthRequest> federated_auth_request =
+ std::make_unique<MockFederatedAuthRequest>(
+ web_contents()->GetPrimaryMainFrame());
+ InterceptorMockNavigationHandle mock_navigation_handle(web_contents());
+ EXPECT_CALL(mock_navigation_handle, GetPreviousRenderFrameHostId)
+ .WillRepeatedly(
+ Return(web_contents()->GetPrimaryMainFrame()->GetGlobalId()));
+ mock_navigation_handle.set_render_frame_host(
+ web_contents()->GetPrimaryMainFrame());
+ mock_navigation_handle.set_is_in_primary_main_frame(true);
+
+ // Simulate a redirect: idp.example/redirect -> idp.example/login.
+ // Note that NavigationHandle::GetURL() returns the post-redirect URL
+ // when WillRedirectRequest is called.
+ mock_navigation_handle.set_url(GURL("https://idp.example/login"));
+ mock_navigation_handle.set_is_same_document(false);
+ mock_navigation_handle.set_redirect_chain(
+ {GURL("https://idp.example/redirect"),
+ GURL("https://idp.example/login")});
+
+ auto headers = base::MakeRefCounted<net::HttpResponseHeaders>("");
+ headers->AddHeader("FedCM-Intercept-Navigation",
+ net::structured_headers::SerializeDictionary(
+ webid::EncodeParams({
+ {"config_url", "https://idp.example/fedcm.json"},
+ {"client_id", "1234"},
+ }))
+ .value());
+ mock_navigation_handle.set_response_headers(headers);
+
+ content::MockNavigationThrottleRegistry registry(&mock_navigation_handle);
+
+ webid::NavigationInterceptor interceptor(
+ registry,
+ base::BindLambdaForTesting(
+ [&federated_auth_request](RenderFrameHost* rfh) -> Request* {
+ return federated_auth_request.get();
+ }));
+
+ base::RunLoop run_loop;
+ bool request_token_called = false;
+ EXPECT_CALL(*federated_auth_request.get(),
+ RequestToken(_, _, _, GURL("https://idp.example/redirect"), _))
+ .WillOnce([&]() {
+ request_token_called = true;
+ run_loop.Quit();
+ });
+
+ bool was_cancelled = false;
+ interceptor.set_cancel_deferred_navigation_callback_for_testing(
+ base::BindLambdaForTesting(
+ [&](NavigationThrottle::ThrottleCheckResult result) {
+ was_cancelled = true;
+ run_loop.Quit();
+ }));
+
+ interceptor.WillStartRequest();
+ auto result = interceptor.WillRedirectRequest();
+ EXPECT_EQ(result, content::NavigationThrottle::DEFER);
+
+ run_loop.Run();
+
+ EXPECT_FALSE(was_cancelled);
+ EXPECT_TRUE(request_token_called);
+}
+
+TEST_F(NavigationInterceptorTest,
+ WillRedirectRequestWithCrossOriginBypassAttempt) {
+ // Uses an in-process data decoder service for testing.
+ data_decoder::test::InProcessDataDecoder in_process_data_decoder;
+
+ NavigateAndCommit(GURL("https://rp.example/"));
+
+ std::unique_ptr<MockFederatedAuthRequest> federated_auth_request =
+ std::make_unique<MockFederatedAuthRequest>(
+ web_contents()->GetPrimaryMainFrame());
+ InterceptorMockNavigationHandle mock_navigation_handle(web_contents());
+ EXPECT_CALL(mock_navigation_handle, GetPreviousRenderFrameHostId)
+ .WillRepeatedly(
+ Return(web_contents()->GetPrimaryMainFrame()->GetGlobalId()));
+ mock_navigation_handle.set_render_frame_host(
+ web_contents()->GetPrimaryMainFrame());
+ mock_navigation_handle.set_is_in_primary_main_frame(true);
+
+ // Simulate a redirect: attacker.example -> victim.example.
+ // Note that NavigationHandle::GetURL() returns the post-redirect URL
+ // when WillRedirectRequest is called.
+ mock_navigation_handle.set_url(GURL("https://victim.example/"));
+ mock_navigation_handle.set_is_same_document(false);
+ mock_navigation_handle.set_redirect_chain(
+ {GURL("https://attacker.example/redirect"),
+ GURL("https://victim.example/")});
+
+ auto headers = base::MakeRefCounted<net::HttpResponseHeaders>("");
+ headers->AddHeader(
+ "FedCM-Intercept-Navigation",
+ net::structured_headers::SerializeDictionary(
+ webid::EncodeParams({
+ {"config_url", "https://victim.example/fedcm.json"},
+ {"client_id", "1234"},
+ }))
+ .value());
+ mock_navigation_handle.set_response_headers(headers);
+
+ content::MockNavigationThrottleRegistry registry(&mock_navigation_handle);
+
+ webid::NavigationInterceptor interceptor(
+ registry,
+ base::BindLambdaForTesting(
+ [&federated_auth_request](RenderFrameHost* rfh) -> Request* {
+ return federated_auth_request.get();
+ }));
+
+ base::RunLoop run_loop;
+ bool request_token_called = false;
+ EXPECT_CALL(*federated_auth_request.get(), RequestToken)
+ .WillRepeatedly([&]() {
+ request_token_called = true;
+ run_loop.Quit();
+ });
+
+ bool was_cancelled = false;
+ interceptor.set_cancel_deferred_navigation_callback_for_testing(
+ base::BindLambdaForTesting(
+ [&](NavigationThrottle::ThrottleCheckResult result) {
+ was_cancelled = true;
+ run_loop.Quit();
+ }));
+
+ interceptor.WillStartRequest();
+ auto result = interceptor.WillRedirectRequest();
+ EXPECT_EQ(result, content::NavigationThrottle::DEFER);
+
+ run_loop.Run();
+
+ EXPECT_TRUE(was_cancelled);
+ EXPECT_FALSE(request_token_called);
+}
+
class EmbedderLoginNavigationInterceptorTest
: public RenderViewHostTestHarness {
public:
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page