| // Copyright 2018 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "chrome/common/google_url_loader_throttle.h" |
| |
| #include <optional> |
| |
| #include "base/feature_list.h" |
| #include "base/functional/bind.h" |
| #include "base/functional/callback.h" |
| #include "base/metrics/histogram_macros.h" |
| #include "base/time/time.h" |
| #include "build/build_config.h" |
| #include "chrome/common/chrome_features.h" |
| #include "chrome/common/renderer_configuration.mojom.h" |
| #include "components/google/core/common/google_util.h" |
| #include "components/safe_search_api/safe_search_util.h" |
| #include "net/base/url_util.h" |
| #include "services/network/public/cpp/resource_request.h" |
| #include "services/network/public/mojom/fetch_api.mojom.h" |
| #include "services/network/public/mojom/url_response_head.mojom.h" |
| #include "services/network/public/mojom/x_frame_options.mojom.h" |
| |
| #if BUILDFLAG(ENABLE_EXTENSIONS) |
| #include "extensions/common/extension_urls.h" |
| #endif |
| |
| #if BUILDFLAG(ENABLE_BOUND_SESSION_CREDENTIALS) |
| #include "chrome/common/bound_session_request_throttled_handler.h" |
| #include "net/cookies/cookie_util.h" |
| #endif |
| |
| namespace { |
| #if BUILDFLAG(IS_ANDROID) |
| const char kCCTClientDataHeader[] = "X-CCT-Client-Data"; |
| #endif |
| |
| #if BUILDFLAG(ENABLE_BOUND_SESSION_CREDENTIALS) |
| enum class RequestBoundSessionStatus { |
| kNotCovered, |
| kCoveredWithFreshCookie, |
| kCoveredWithMissingCookie |
| }; |
| |
| RequestBoundSessionStatus GetRequestBoundSessionStatus( |
| const GURL& request_url, |
| chrome::mojom::BoundSessionThrottlerParams* |
| bound_session_throttler_params) { |
| // No bound session. |
| if (!bound_session_throttler_params || |
| bound_session_throttler_params->domain.empty()) { |
| return RequestBoundSessionStatus::kNotCovered; |
| } |
| |
| // Check if the request requires the short lived cookie. |
| if (!request_url.DomainIs(net::cookie_util::CookieDomainAsHost( |
| bound_session_throttler_params->domain))) { |
| return RequestBoundSessionStatus::kNotCovered; |
| } |
| |
| if (!bound_session_throttler_params->path.empty() && |
| !net::cookie_util::IsOnPath(bound_session_throttler_params->path, |
| request_url.path())) { |
| return RequestBoundSessionStatus::kNotCovered; |
| } |
| |
| // Short lived cookie is fresh. |
| if (bound_session_throttler_params->cookie_expiry_date > base::Time::Now()) { |
| return RequestBoundSessionStatus::kCoveredWithFreshCookie; |
| } |
| |
| // Short lived cookie has expired. |
| return RequestBoundSessionStatus::kCoveredWithMissingCookie; |
| } |
| |
| bool IsCoveredRequestBoundSessionStatus(RequestBoundSessionStatus status) { |
| switch (status) { |
| case RequestBoundSessionStatus::kNotCovered: |
| return false; |
| case RequestBoundSessionStatus::kCoveredWithFreshCookie: |
| case RequestBoundSessionStatus::kCoveredWithMissingCookie: |
| return true; |
| } |
| } |
| |
| void RecordBoundSessionStatusMetrics(bool was_deferred, |
| bool is_main_frame_navigation) { |
| UMA_HISTOGRAM_BOOLEAN( |
| "Signin.BoundSessionCredentials.CoveredRequestWasDeferred", was_deferred); |
| if (is_main_frame_navigation) { |
| UMA_HISTOGRAM_BOOLEAN( |
| "Signin.BoundSessionCredentials.CoveredNavigationRequestWasDeferred", |
| was_deferred); |
| } |
| } |
| #endif // BUILDFLAG(ENABLE_BOUND_SESSION_CREDENTIALS) |
| } // namespace |
| |
| // static |
| void GoogleURLLoaderThrottle::UpdateCorsExemptHeader( |
| network::mojom::NetworkContextParams* params) { |
| params->cors_exempt_header_list.push_back( |
| safe_search_api::kGoogleAppsAllowedDomains); |
| params->cors_exempt_header_list.push_back( |
| safe_search_api::kYouTubeRestrictHeaderName); |
| #if BUILDFLAG(IS_ANDROID) |
| params->cors_exempt_header_list.push_back(kCCTClientDataHeader); |
| #endif |
| } |
| |
| GoogleURLLoaderThrottle::GoogleURLLoaderThrottle( |
| #if BUILDFLAG(IS_ANDROID) |
| const std::string& client_data_header, |
| #endif |
| #if BUILDFLAG(ENABLE_BOUND_SESSION_CREDENTIALS) |
| std::unique_ptr<BoundSessionRequestThrottledHandler> |
| bound_session_request_throttled_handler, |
| #endif |
| chrome::mojom::DynamicParamsPtr dynamic_params) |
| : |
| #if BUILDFLAG(IS_ANDROID) |
| client_data_header_(client_data_header), |
| #endif |
| #if BUILDFLAG(ENABLE_BOUND_SESSION_CREDENTIALS) |
| bound_session_request_throttled_handler_( |
| std::move(bound_session_request_throttled_handler)), |
| #endif |
| dynamic_params_(std::move(dynamic_params)) { |
| } |
| |
| GoogleURLLoaderThrottle::~GoogleURLLoaderThrottle() = default; |
| |
| #if BUILDFLAG(ENABLE_BOUND_SESSION_CREDENTIALS) |
| // static |
| bool GoogleURLLoaderThrottle::ShouldDeferRequestForBoundSession( |
| const GURL& request_url, |
| chrome::mojom::BoundSessionThrottlerParams* |
| bound_session_throttler_params) { |
| RequestBoundSessionStatus status = |
| GetRequestBoundSessionStatus(request_url, bound_session_throttler_params); |
| return status == RequestBoundSessionStatus::kCoveredWithMissingCookie; |
| } |
| #endif // BUILDFLAG(ENABLE_BOUND_SESSION_CREDENTIALS) |
| |
| void GoogleURLLoaderThrottle::DetachFromCurrentSequence() {} |
| |
| void GoogleURLLoaderThrottle::WillStartRequest( |
| network::ResourceRequest* request, |
| bool* defer) { |
| if (dynamic_params_->force_safe_search) { |
| GURL new_url; |
| safe_search_api::ForceGoogleSafeSearch(request->url, &new_url); |
| if (!new_url.is_empty()) { |
| request->url = new_url; |
| } |
| } |
| |
| static_assert(safe_search_api::YOUTUBE_RESTRICT_OFF == 0, |
| "OFF must be first"); |
| if (dynamic_params_->youtube_restrict > |
| safe_search_api::YOUTUBE_RESTRICT_OFF && |
| dynamic_params_->youtube_restrict < |
| safe_search_api::YOUTUBE_RESTRICT_COUNT) { |
| safe_search_api::ForceYouTubeRestrict( |
| request->url, &request->cors_exempt_headers, |
| static_cast<safe_search_api::YouTubeRestrictMode>( |
| dynamic_params_->youtube_restrict)); |
| } |
| |
| if (!dynamic_params_->allowed_domains_for_apps.empty() && |
| request->url.DomainIs("google.com")) { |
| request->cors_exempt_headers.SetHeader( |
| safe_search_api::kGoogleAppsAllowedDomains, |
| dynamic_params_->allowed_domains_for_apps); |
| } |
| |
| #if BUILDFLAG(IS_ANDROID) |
| if (!client_data_header_.empty() && |
| google_util::IsGoogleAssociatedDomainUrl(request->url)) { |
| request->cors_exempt_headers.SetHeader(kCCTClientDataHeader, |
| client_data_header_); |
| } |
| #endif |
| #if BUILDFLAG(ENABLE_BOUND_SESSION_CREDENTIALS) |
| // `network::mojom::RequestDestination::kDocument` means that this is a |
| // navigation request. |
| is_main_frame_navigation_ = |
| request->is_outermost_main_frame && |
| request->destination == network::mojom::RequestDestination::kDocument; |
| sends_cookies_ = request->SendsCookies(); |
| if (sends_cookies_) { |
| RequestBoundSessionStatus status = GetRequestBoundSessionStatus( |
| request->url, dynamic_params_->bound_session_throttler_params.get()); |
| if (IsCoveredRequestBoundSessionStatus(status)) { |
| is_covered_by_bound_session_ = true; |
| } |
| if (status == RequestBoundSessionStatus::kCoveredWithMissingCookie) { |
| CHECK(bound_session_request_throttled_handler_); |
| *defer = true; |
| is_deferred_for_bound_session_ = true; |
| CHECK(!bound_session_request_throttled_start_time_.has_value()); |
| bound_session_request_throttled_start_time_ = base::TimeTicks::Now(); |
| bound_session_request_throttled_handler_->HandleRequestBlockedOnCookie( |
| base::BindOnce( |
| &GoogleURLLoaderThrottle::OnDeferRequestForBoundSessionCompleted, |
| weak_factory_.GetWeakPtr())); |
| } |
| } |
| #endif |
| } |
| |
| void GoogleURLLoaderThrottle::WillRedirectRequest( |
| net::RedirectInfo* redirect_info, |
| const network::mojom::URLResponseHead& response_head, |
| bool* defer, |
| std::vector<std::string>* to_be_removed_headers, |
| net::HttpRequestHeaders* modified_headers, |
| net::HttpRequestHeaders* modified_cors_exempt_headers) { |
| // URLLoaderThrottles can only change the redirect URL when the network |
| // service is enabled. The non-network service path handles this in |
| // ChromeNetworkDelegate. |
| if (dynamic_params_->force_safe_search) { |
| safe_search_api::ForceGoogleSafeSearch(redirect_info->new_url, |
| &redirect_info->new_url); |
| } |
| |
| if (dynamic_params_->youtube_restrict > |
| safe_search_api::YOUTUBE_RESTRICT_OFF && |
| dynamic_params_->youtube_restrict < |
| safe_search_api::YOUTUBE_RESTRICT_COUNT) { |
| safe_search_api::ForceYouTubeRestrict( |
| redirect_info->new_url, modified_cors_exempt_headers, |
| static_cast<safe_search_api::YouTubeRestrictMode>( |
| dynamic_params_->youtube_restrict)); |
| } |
| |
| if (!dynamic_params_->allowed_domains_for_apps.empty() && |
| redirect_info->new_url.DomainIs("google.com")) { |
| modified_cors_exempt_headers->SetHeader( |
| safe_search_api::kGoogleAppsAllowedDomains, |
| dynamic_params_->allowed_domains_for_apps); |
| } |
| |
| #if BUILDFLAG(IS_ANDROID) |
| if (!client_data_header_.empty() && |
| !google_util::IsGoogleAssociatedDomainUrl(redirect_info->new_url)) { |
| to_be_removed_headers->push_back(kCCTClientDataHeader); |
| } |
| #endif |
| #if BUILDFLAG(ENABLE_BOUND_SESSION_CREDENTIALS) |
| if (sends_cookies_) { |
| RequestBoundSessionStatus status = GetRequestBoundSessionStatus( |
| redirect_info->new_url, |
| dynamic_params_->bound_session_throttler_params.get()); |
| if (IsCoveredRequestBoundSessionStatus(status)) { |
| is_covered_by_bound_session_ = true; |
| } |
| if (status == RequestBoundSessionStatus::kCoveredWithMissingCookie) { |
| CHECK(bound_session_request_throttled_handler_); |
| *defer = true; |
| is_deferred_for_bound_session_ = true; |
| CHECK(!bound_session_request_throttled_start_time_.has_value()); |
| bound_session_request_throttled_start_time_ = base::TimeTicks::Now(); |
| bound_session_request_throttled_handler_->HandleRequestBlockedOnCookie( |
| base::BindOnce( |
| &GoogleURLLoaderThrottle::OnDeferRequestForBoundSessionCompleted, |
| weak_factory_.GetWeakPtr())); |
| } |
| } |
| #endif |
| } |
| |
| void GoogleURLLoaderThrottle::WillProcessResponse( |
| const GURL& response_url, |
| network::mojom::URLResponseHead* response_head, |
| bool* defer) { |
| #if BUILDFLAG(ENABLE_BOUND_SESSION_CREDENTIALS) |
| if (is_covered_by_bound_session_) { |
| RecordBoundSessionStatusMetrics(is_deferred_for_bound_session_, |
| is_main_frame_navigation_); |
| } |
| if (deferred_request_resume_trigger_) { |
| UMA_HISTOGRAM_ENUMERATION( |
| "Signin.BoundSessionCredentials.DeferredRequestUnblockTrigger.Success", |
| deferred_request_resume_trigger_.value()); |
| } |
| #endif // BUILDFLAG(ENABLE_BOUND_SESSION_CREDENTIALS) |
| |
| #if BUILDFLAG(ENABLE_EXTENSIONS) |
| // Built-in additional protection for the chrome web store origin by |
| // ensuring that the X-Frame-Options protection mechanism is set to either |
| // DENY or SAMEORIGIN. |
| if (response_url.SchemeIsHTTPOrHTTPS() && |
| extension_urls::IsWebstoreDomain(response_url)) { |
| // TODO(mkwst): Consider shifting this to a NavigationThrottle rather than |
| // relying on implicit ordering between this check and the time at which |
| // ParsedHeaders is created. |
| CHECK(response_head); |
| CHECK(response_head->parsed_headers); |
| if (response_head->parsed_headers->xfo != |
| network::mojom::XFrameOptionsValue::kDeny) { |
| response_head->headers->SetHeader("X-Frame-Options", "SAMEORIGIN"); |
| response_head->parsed_headers->xfo = |
| network::mojom::XFrameOptionsValue::kSameOrigin; |
| } |
| } |
| #endif // BUILDFLAG(ENABLE_EXTENSIONS) |
| } |
| |
| #if BUILDFLAG(ENABLE_BOUND_SESSION_CREDENTIALS) |
| void GoogleURLLoaderThrottle::WillOnCompleteWithError( |
| const network::URLLoaderCompletionStatus& status) { |
| if (is_covered_by_bound_session_) { |
| RecordBoundSessionStatusMetrics(is_deferred_for_bound_session_, |
| is_main_frame_navigation_); |
| } |
| if (deferred_request_resume_trigger_) { |
| UMA_HISTOGRAM_ENUMERATION( |
| "Signin.BoundSessionCredentials.DeferredRequestUnblockTrigger.Failure", |
| *deferred_request_resume_trigger_); |
| } |
| } |
| |
| void GoogleURLLoaderThrottle::OnDeferRequestForBoundSessionCompleted( |
| BoundSessionRequestThrottledHandler::UnblockAction unblock_action, |
| chrome::mojom::ResumeBlockedRequestsTrigger resume_trigger) { |
| // Use `PostTask` to avoid resuming the request before it has been deferred |
| // then the request will hang. This can happen if |
| // `BoundSessionRequestThrottledHandler::HandleRequestBlockedOnCookie()` |
| // calls the callback synchronously. |
| base::SequencedTaskRunner::GetCurrentDefault()->PostTask( |
| FROM_HERE, base::BindOnce(&GoogleURLLoaderThrottle::ResumeOrCancelRequest, |
| weak_factory_.GetWeakPtr(), unblock_action, |
| resume_trigger)); |
| } |
| |
| void GoogleURLLoaderThrottle::ResumeOrCancelRequest( |
| BoundSessionRequestThrottledHandler::UnblockAction unblock_action, |
| chrome::mojom::ResumeBlockedRequestsTrigger resume_trigger) { |
| CHECK(is_deferred_for_bound_session_); |
| CHECK(bound_session_request_throttled_start_time_.has_value()); |
| base::TimeDelta duration = |
| base::TimeTicks::Now() - *bound_session_request_throttled_start_time_; |
| UMA_HISTOGRAM_MEDIUM_TIMES( |
| "Signin.BoundSessionCredentials.DeferredRequestDelay", duration); |
| if (is_main_frame_navigation_) { |
| UMA_HISTOGRAM_MEDIUM_TIMES( |
| "Signin.BoundSessionCredentials.DeferredNavigationRequestDelay", |
| duration); |
| } |
| bound_session_request_throttled_start_time_ = std::nullopt; |
| deferred_request_resume_trigger_ = resume_trigger; |
| |
| switch (unblock_action) { |
| case BoundSessionRequestThrottledHandler::UnblockAction::kResume: |
| delegate_->Resume(); |
| break; |
| case BoundSessionRequestThrottledHandler::UnblockAction::kCancel: |
| delegate_->CancelWithError(net::ERR_ABORTED); |
| break; |
| } |
| } |
| #endif |