blob: 91f95d159012834821b4a0e74f09e8c92c4a7019 [file] [log] [blame]
// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "content/browser/tpcd_heuristics/opener_heuristic_tab_helper.h"
#include <cstdint>
#include <utility>
#include <variant>
#include "base/functional/bind.h"
#include "base/functional/callback_forward.h"
#include "base/memory/weak_ptr.h"
#include "base/rand_util.h"
#include "base/time/clock.h"
#include "base/time/default_clock.h"
#include "base/time/time.h"
#include "components/content_settings/core/common/features.h"
#include "content/browser/btm/btm_bounce_detector.h"
#include "content/browser/btm/btm_service_impl.h"
#include "content/browser/tpcd_heuristics/opener_heuristic_metrics.h"
#include "content/common/features.h"
#include "content/public/browser/content_browser_client.h"
#include "content/public/browser/navigation_handle.h"
#include "content/public/browser/render_frame_host.h"
#include "content/public/common/content_client.h"
#include "net/base/schemeful_site.h"
#include "services/metrics/public/cpp/ukm_builders.h"
#include "services/metrics/public/cpp/ukm_recorder.h"
#include "services/metrics/public/cpp/ukm_source_id.h"
using content_settings::features::EnableForIframeTypes;
namespace content {
namespace {
// We don't need to protect this with a lock since it's only set while
// single-threaded in tests.
base::Clock* g_clock = nullptr;
base::Clock* GetClock() {
return g_clock ? g_clock : base::DefaultClock::GetInstance();
}
} // namespace
OpenerHeuristicTabHelper::OpenerHeuristicTabHelper(WebContents* web_contents)
: WebContentsObserver(web_contents),
WebContentsUserData<OpenerHeuristicTabHelper>(*web_contents) {}
OpenerHeuristicTabHelper::~OpenerHeuristicTabHelper() = default;
/* static */
base::Clock* OpenerHeuristicTabHelper::SetClockForTesting(base::Clock* clock) {
return std::exchange(g_clock, clock);
}
void OpenerHeuristicTabHelper::InitPopup(
const GURL& popup_url,
base::WeakPtr<OpenerHeuristicTabHelper> opener) {
if (web_contents()->IsPartitionedPopin()) {
// Skip heuristics if this is a popin as we should treat this context as as
// embedded and not its own window for storage access purposes.
// See https://explainers-by-googlers.github.io/partitioned-popins/
return;
}
popup_observer_ =
std::make_unique<PopupObserver>(web_contents(), popup_url, opener);
BtmServiceImpl* btm_service =
BtmServiceImpl::Get(web_contents()->GetBrowserContext());
if (!btm_service) {
// If BTM is disabled, we can't look up past interaction.
// TODO(rtarpine): consider falling back to SiteEngagementService.
return;
}
btm_service->storage()
->AsyncCall(&BtmStorage::Read)
.WithArgs(popup_url)
.Then(base::BindOnce(&OpenerHeuristicTabHelper::GotPopupDipsState,
weak_factory_.GetWeakPtr()));
}
void OpenerHeuristicTabHelper::GotPopupDipsState(const BtmState& state) {
popup_observer_->SetPastInteractionTimeAndType(
state.user_activation_times(), state.web_authn_assertion_times());
}
void OpenerHeuristicTabHelper::PrimaryPageChanged(Page& page) {
page_id_++;
}
void OpenerHeuristicTabHelper::DidOpenRequestedURL(
WebContents* new_contents,
RenderFrameHost* source_render_frame_host,
const GURL& url,
const Referrer& referrer,
WindowOpenDisposition disposition,
ui::PageTransition transition,
bool started_from_context_menu,
bool renderer_initiated) {
if (source_render_frame_host->GetMainFrame() !=
web_contents()->GetPrimaryMainFrame()) {
// Not sure exactly when this happens, but it seems to involve devtools.
// Cf. crbug.com/1448789
return;
}
if (!PassesIframeInitiatorCheck(source_render_frame_host)) {
return;
}
if (!new_contents->HasOpener()) {
// Ignore if popup doesn't have opener access.
return;
}
// Create an OpenerHeuristicTabHelper for the popup.
//
// Note: TabHelpers::AttachTabHelpers() creates OpenerHeuristicTabHelper, but
// on Android that can happen after DidOpenRequestedURL() is called (on other
// platforms it seems to happen first). So create it now if it doesn't already
// exist.
OpenerHeuristicTabHelper::CreateForWebContents(new_contents);
OpenerHeuristicTabHelper::FromWebContents(new_contents)
->InitPopup(url, weak_factory_.GetWeakPtr());
}
bool OpenerHeuristicTabHelper::PassesIframeInitiatorCheck(
RenderFrameHost* source_render_frame_host) {
if (source_render_frame_host->IsInPrimaryMainFrame()) {
return true;
}
switch (
content_settings::features::kTpcdPopupHeuristicEnableForIframeInitiator
.Get()) {
case EnableForIframeTypes::kNone:
return false;
case EnableForIframeTypes::kAll:
return true;
case EnableForIframeTypes::kFirstParty: {
// Check that the frame tree consists of only first-party iframes.
std::string main_frame_site = GetSiteForBtm(
source_render_frame_host->GetMainFrame()->GetLastCommittedURL());
RenderFrameHost* rfh_itr = source_render_frame_host;
while (rfh_itr->GetParent() != nullptr) {
if (GetSiteForBtm(rfh_itr->GetLastCommittedURL()) != main_frame_site) {
return false;
}
rfh_itr = rfh_itr->GetParent();
}
return true;
}
}
}
WEB_CONTENTS_USER_DATA_KEY_IMPL(OpenerHeuristicTabHelper);
OpenerHeuristicTabHelper::PopupObserver::PopupObserver(
WebContents* web_contents,
const GURL& initial_url,
base::WeakPtr<OpenerHeuristicTabHelper> opener)
: WebContentsObserver(web_contents),
popup_id_(static_cast<int32_t>(base::RandUint64())),
initial_url_(initial_url),
opener_(opener),
opener_page_id_(opener->page_id()),
opener_source_id_(
opener->web_contents()->GetPrimaryMainFrame()->GetPageUkmSourceId()),
opener_origin_(opener->web_contents()
->GetPrimaryMainFrame()
->GetLastCommittedOrigin()) {}
OpenerHeuristicTabHelper::PopupObserver::~PopupObserver() = default;
void OpenerHeuristicTabHelper::PopupObserver::SetPastInteractionTimeAndType(
TimestampRange user_activation_times,
TimestampRange web_authn_assertion_times) {
CHECK(std::holds_alternative<FieldNotSet>(time_since_interaction_))
<< "SetPastInteractionTime() called more than once";
base::Time most_recent_user_activation =
user_activation_times ? user_activation_times.value().second
: base::Time::Min();
base::Time most_recent_authentication =
web_authn_assertion_times ? web_authn_assertion_times.value().second
: base::Time::Min();
base::Time most_recent_interaction;
if (most_recent_user_activation >= most_recent_authentication) {
most_recent_interaction = most_recent_user_activation;
past_interaction_type_ = BtmInteractionType::UserActivation;
} else {
most_recent_interaction = most_recent_authentication;
past_interaction_type_ = BtmInteractionType::UserActivation;
}
if (most_recent_interaction != base::Time::Min()) {
// Technically we should use the time when the pop-up first opened. But
// since we only report this metric at hourly granularity, it shouldn't
// matter.
time_since_interaction_ = GetClock()->Now() - most_recent_interaction;
} else {
time_since_interaction_ = NoInteraction();
past_interaction_type_ = BtmInteractionType::NoInteraction;
}
// TODO(rtarpine): consider ignoring interactions that are too old. (This
// shouldn't happen since BTM already discards old timestamps.)
EmitPastInteractionIfReady();
}
void OpenerHeuristicTabHelper::PopupObserver::EmitPastInteractionIfReady() {
if (std::holds_alternative<FieldNotSet>(time_since_interaction_) ||
!initial_source_id_.has_value()) {
// Not enough information to emit event yet.
return;
}
auto has_iframe = GetOpenerHasSameSiteIframe(initial_url_);
int32_t bucketized_time = -1;
if (auto* time = std::get_if<base::TimeDelta>(&time_since_interaction_)) {
bucketized_time =
Bucketize3PCDHeuristicSample(time->InHours(), base::Days(30).InHours());
}
// Record past interaction in UKM.
ukm::builders::OpenerHeuristic_PopupPastInteraction(
initial_source_id_.value())
.SetHoursSinceLastInteraction(bucketized_time)
.SetOpenerHasSameSiteIframe(static_cast<int64_t>(has_iframe))
.SetPopupId(popup_id_)
.SetInteractionType(static_cast<int32_t>(past_interaction_type_))
.Record(ukm::UkmRecorder::Get());
EmitTopLevelAndCreateGrant(
initial_url_, has_iframe, /*is_current_interaction=*/false,
/*interaction_type=*/BtmInteractionType::UserActivation,
/*should_record_popup_and_maybe_grant=*/
std::holds_alternative<base::TimeDelta>(time_since_interaction_),
/*grant_duration=*/
content_settings::features::kTpcdWritePopupPastInteractionHeuristicsGrants
.Get());
}
void OpenerHeuristicTabHelper::PopupObserver::DidFinishNavigation(
NavigationHandle* navigation_handle) {
if (!navigation_handle->IsInPrimaryMainFrame() ||
!navigation_handle->HasCommitted() ||
navigation_handle->IsSameDocument()) {
return;
}
url_index_ += navigation_handle->GetRedirectChain().size();
// This is only called on the first committed navigation in the new popup.
// Only get the source id, time, and ad-tagged status for the first commit.
// Ignore the rest.
if (!initial_source_id_.has_value()) {
commit_time_ = GetClock()->Now();
if (navigation_handle->GetRedirectChain().size() > 1) {
// Get a source id for the URL the popup was originally opened with,
// even though the user was redirected elsewhere.
initial_source_id_ = btm::GetRedirectSourceId(navigation_handle, 0);
} else {
// No redirect happened, get the source id for the committed page.
initial_source_id_ = navigation_handle->GetNextPageUkmSourceId();
}
is_last_navigation_ad_tagged_ =
navigation_handle->GetNavigationInitiatorActivationAndAdStatus() ==
blink::mojom::NavigationInitiatorActivationAndAdStatus::
kStartedWithTransientActivationFromAd;
EmitPastInteractionIfReady();
}
}
void OpenerHeuristicTabHelper::PopupObserver::FrameReceivedUserActivation(
RenderFrameHost* render_frame_host) {
RecordInteractionAndCreateGrant(render_frame_host,
BtmInteractionType::UserActivation);
}
void OpenerHeuristicTabHelper::PopupObserver::WebAuthnAssertionRequestSucceeded(
RenderFrameHost* render_frame_host) {
RecordInteractionAndCreateGrant(render_frame_host,
BtmInteractionType::Authentication);
}
void OpenerHeuristicTabHelper::PopupObserver::RecordInteractionAndCreateGrant(
RenderFrameHost* render_frame_host,
BtmInteractionType interaction_type) {
if (!render_frame_host->IsInPrimaryMainFrame()) {
return;
}
if (interaction_reported_) {
// Only report the first interaction.
return;
}
if (!commit_time_.has_value()) {
// Not sure if this can happen. What happens if the user clicks before the
// popup loads its initial URL?
return;
}
const GURL& interaction_url = render_frame_host->GetLastCommittedURL();
auto time_since_committed = GetClock()->Now() - *commit_time_;
auto has_iframe = GetOpenerHasSameSiteIframe(interaction_url);
ukm::builders::OpenerHeuristic_PopupInteraction(
render_frame_host->GetPageUkmSourceId())
.SetSecondsSinceCommitted(Bucketize3PCDHeuristicSample(
time_since_committed.InSeconds(), base::Minutes(3).InSeconds()))
.SetUrlIndex(url_index_)
.SetOpenerHasSameSiteIframe(static_cast<int64_t>(has_iframe))
.SetPopupId(popup_id_)
.SetInteractionType(static_cast<int32_t>(interaction_type))
.Record(ukm::UkmRecorder::Get());
interaction_reported_ = true;
EmitTopLevelAndCreateGrant(
interaction_url, has_iframe,
/*is_current_interaction=*/true, interaction_type,
/*should_record_popup_and_maybe_grant=*/true,
/*grant_duration=*/
content_settings::features::
kTpcdWritePopupCurrentInteractionHeuristicsGrants.Get());
}
void OpenerHeuristicTabHelper::OnCookiesAccessed(
RenderFrameHost* render_frame_host,
const CookieAccessDetails& details) {
if (!render_frame_host->IsInLifecycleState(
RenderFrameHost::LifecycleState::kPrerendering)) {
OnCookiesAccessed(render_frame_host->GetPageUkmSourceId(), details);
}
}
void OpenerHeuristicTabHelper::OnCookiesAccessed(
NavigationHandle* navigation_handle,
const CookieAccessDetails& details) {
OnCookiesAccessed(navigation_handle->GetNextPageUkmSourceId(), details);
}
void OpenerHeuristicTabHelper::OnCookiesAccessed(
const ukm::SourceId& source_id,
const CookieAccessDetails& details) {
BtmServiceImpl* btm_service =
BtmServiceImpl::Get(web_contents()->GetBrowserContext());
if (!btm_service) {
// If BTM is disabled, we can't look up past popup events.
// TODO(rtarpine): consider falling back to SiteEngagementService.
return;
}
// Ignore same-domain cookie access.
if (details.first_party_url.is_empty() ||
GetSiteForBtm(details.first_party_url) == GetSiteForBtm(details.url)) {
return;
}
btm_service->storage()
->AsyncCall(&BtmStorage::ReadPopup)
.WithArgs(GetSiteForBtm(details.first_party_url),
GetSiteForBtm(details.url))
.Then(base::BindOnce(&OpenerHeuristicTabHelper::EmitPostPopupCookieAccess,
weak_factory_.GetWeakPtr(), source_id, details));
}
void OpenerHeuristicTabHelper::EmitPostPopupCookieAccess(
const ukm::SourceId& source_id,
const CookieAccessDetails& details,
std::optional<PopupsStateValue> value) {
if (!value.has_value()) {
return;
}
int32_t hours_since_opener = Bucketize3PCDHeuristicSample(
(GetClock()->Now() - value->last_popup_time).InHours(),
base::Days(30).InHours());
OptionalBool is_ad_tagged_cookie = IsAdTaggedCookieForHeuristics(details);
ukm::builders::OpenerHeuristic_PostPopupCookieAccess(source_id)
.SetAccessId(value->access_id)
.SetAccessSucceeded(!details.blocked_by_policy)
.SetIsAdTagged(static_cast<int64_t>(is_ad_tagged_cookie))
.SetHoursSincePopupOpened(hours_since_opener)
.Record(ukm::UkmRecorder::Get());
}
void OpenerHeuristicTabHelper::PopupObserver::EmitTopLevelAndCreateGrant(
const GURL& popup_url,
OptionalBool has_iframe,
bool is_current_interaction,
BtmInteractionType interaction_type,
bool should_record_popup_and_maybe_grant,
base::TimeDelta grant_duration) {
uint64_t access_id = base::RandUint64();
if (should_record_popup_and_maybe_grant) {
if (BtmServiceImpl* btm_service =
BtmServiceImpl::Get(web_contents()->GetBrowserContext())) {
btm_service->storage()
->AsyncCall(&BtmStorage::WritePopup)
.WithArgs(GetSiteForBtm(opener_origin_), GetSiteForBtm(popup_url),
access_id,
/*popup_time=*/GetClock()->Now(), is_current_interaction,
/*is_authentication_interaction=*/interaction_type ==
BtmInteractionType::Authentication)
.Then(base::BindOnce([](bool succeeded) { DCHECK(succeeded); }));
}
MaybeCreateOpenerHeuristicGrant(popup_url, grant_duration);
}
// Don't record multiple interaction UKM events for the same top level.
if (!toplevel_reported_) {
ukm::builders::OpenerHeuristic_TopLevel(opener_source_id_)
.SetAccessId(access_id)
.SetHasSameSiteIframe(static_cast<int64_t>(has_iframe))
.SetPopupProvider(static_cast<int64_t>(GetPopupProvider(initial_url_)))
.SetPopupId(popup_id_)
.SetIsAdTaggedPopupClick(is_last_navigation_ad_tagged_)
.Record(ukm::UkmRecorder::Get());
}
toplevel_reported_ = true;
}
void OpenerHeuristicTabHelper::PopupObserver::MaybeCreateOpenerHeuristicGrant(
const GURL& url,
base::TimeDelta grant_duration) {
if (!base::FeatureList::IsEnabled(
content_settings::features::kTpcdHeuristicsGrants) ||
!grant_duration.is_positive()) {
return;
}
if (is_last_navigation_ad_tagged_ &&
content_settings::features::kTpcdPopupHeuristicDisableForAdTaggedPopups
.Get()) {
return;
}
GetContentClient()->browser()->GrantCookieAccessDueToHeuristic(
web_contents()->GetBrowserContext(), net::SchemefulSite(opener_origin_),
net::SchemefulSite(url::Origin::Create(url)), grant_duration,
/*ignore_schemes=*/false);
}
OptionalBool
OpenerHeuristicTabHelper::PopupObserver::GetOpenerHasSameSiteIframe(
const GURL& popup_url) {
if (opener_ && opener_->page_id() == opener_page_id_) {
return ToOptionalBool(
HasSameSiteIframe(opener_->web_contents(), popup_url));
}
return OptionalBool::kUnknown;
}
} // namespace content