blob: f52d789eb77d9e05c5a4e133d15d0f60da812ddf [file] [log] [blame]
// Copyright 2024 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/btm/btm_navigation_flow_detector.h"
#include "base/check.h"
#include "base/rand_util.h"
#include "content/browser/btm/btm_utils.h"
#include "content/public/browser/web_contents.h"
#include "services/metrics/public/cpp/metrics_utils.h"
#include "services/metrics/public/cpp/ukm_builders.h"
#include "services/metrics/public/cpp/ukm_recorder.h"
namespace content {
namespace {
enum class QuantityBucket {
kZero = 0,
kOne,
kMultiple,
};
// Types that qualify a navigation for the
// DIPS.TrustIndicator.DirectNavigationV2 UKM event. Should only contain core
// page transition types (no qualifiers).
constexpr const std::array<ui::PageTransition, 2>&
kDirectNavigationPageTransitions{
ui::PAGE_TRANSITION_TYPED,
ui::PAGE_TRANSITION_AUTO_BOOKMARK,
};
bool IsPageTransitionDirectNavigation(ui::PageTransition page_transition) {
for (auto& direct_navigation_type : kDirectNavigationPageTransitions) {
if (ui::PageTransitionCoreTypeIs(page_transition, direct_navigation_type)) {
return true;
}
}
return false;
}
btm::DirectNavigationSource ToDirectNavigationSource(
ui::PageTransition page_transition) {
if (ui::PageTransitionCoreTypeIs(page_transition,
ui::PAGE_TRANSITION_TYPED)) {
return btm::DirectNavigationSource::kOmnibar;
}
if (ui::PageTransitionCoreTypeIs(page_transition,
ui::PAGE_TRANSITION_AUTO_BOOKMARK)) {
return btm::DirectNavigationSource::kBookmark;
}
return btm::DirectNavigationSource::kUnknown;
}
// Looks for a redirect to the current page that qualifies as a server-redirect
// exit from a suspected tracker flow (i.e., a single-hop server-side redirect)
// and returns it, if one exists. Returns nullptr otherwise.
const BtmServerRedirectInfo* GetEntrypointExitServerRedirect(
const BtmNavigationInfo& navigation) {
return navigation.server_redirects.size() == 1
? &navigation.server_redirects.front()
: nullptr;
}
// Matches true client redirects (HTML `<meta>` tag redirects, JavaScript
// `window.location.replace` redirects), as well as navigations initiated by
// page content without a user gesture.
bool WasClientRedirectLike(const BtmNavigationInfo& navigation) {
return navigation.was_renderer_initiated && !navigation.was_user_initiated;
}
QuantityBucket GetCrossSiteRedirectQuantity(
const std::string& initial_site,
const BtmNavigationInfo& navigation) {
std::string referring_site = initial_site;
size_t num_cross_site_redirects = 0;
for (const auto& server_redirect : navigation.server_redirects) {
std::string redirector_site = GetSiteForBtm(server_redirect.url);
if (redirector_site != referring_site) {
num_cross_site_redirects += 1;
if (num_cross_site_redirects >= 2) {
return QuantityBucket::kMultiple;
}
referring_site.swap(redirector_site);
}
}
const std::string destination_site =
GetSiteForBtm(navigation.destination.url);
if (destination_site != referring_site) {
num_cross_site_redirects += 1;
}
switch (num_cross_site_redirects) {
case 0:
return QuantityBucket::kZero;
case 1:
return QuantityBucket::kOne;
default:
return QuantityBucket::kMultiple;
}
}
void EmitSuspectedTrackerFlowUkm(ukm::SourceId referrer_source_id,
ukm::SourceId entrypoint_source_id,
bool did_entrypoint_access_storage,
int32_t flow_id,
BtmRedirectType exit_redirect_type) {
ukm::builders::DIPS_SuspectedTrackerFlowReferrerV2(referrer_source_id)
.SetFlowId(flow_id)
.Record(ukm::UkmRecorder::Get());
ukm::builders::DIPS_SuspectedTrackerFlowEntrypointV2(entrypoint_source_id)
.SetExitRedirectType(static_cast<int64_t>(exit_redirect_type))
.SetHadActiveStorageAccess(did_entrypoint_access_storage)
.SetFlowId(flow_id)
.Record(ukm::UkmRecorder::Get());
}
void MaybeEmitDirectNavigationUkm(const BtmNavigationInfo& navigation) {
if (!IsPageTransitionDirectNavigation(navigation.page_transition)) {
return;
}
ukm::SourceId source_id = navigation.server_redirects.empty()
? navigation.destination.source_id
: navigation.server_redirects.front().source_id;
ukm::builders::DIPS_TrustIndicator_DirectNavigationV2(source_id)
.SetNavigationSource(static_cast<int64_t>(
ToDirectNavigationSource(navigation.page_transition)))
.Record(ukm::UkmRecorder::Get());
}
} // namespace
namespace btm {
EntrypointInfo::EntrypointInfo(
const BtmServerRedirectInfo& server_redirect_info,
bool was_referral_client_redirect_like)
: site(GetSiteForBtm(server_redirect_info.url)),
source_id(server_redirect_info.source_id),
had_active_storage_access(server_redirect_info.did_write_cookies),
was_referral_client_redirect(was_referral_client_redirect_like) {}
EntrypointInfo::EntrypointInfo(const BtmNavigationInfo& referral)
: site(GetSiteForBtm(referral.destination.url)),
source_id(referral.destination.source_id),
had_active_storage_access(false),
was_referral_client_redirect(WasClientRedirectLike(referral)) {}
EntrypointInfo::EntrypointInfo(const BtmNavigationInfo& referral,
const BtmPageVisitInfo& entrypoint_visit)
: site(GetSiteForBtm(entrypoint_visit.url)),
source_id(entrypoint_visit.source_id),
had_active_storage_access(entrypoint_visit.had_active_storage_access),
was_referral_client_redirect(WasClientRedirectLike(referral)) {}
InFlowSuccessorInteractionState::InFlowSuccessorInteractionState(
btm::EntrypointInfo flow_entrypoint)
: flow_entrypoint_(flow_entrypoint) {}
InFlowSuccessorInteractionState::~InFlowSuccessorInteractionState() = default;
void InFlowSuccessorInteractionState::RecordActiveStorageAccessByEntrypoint() {
flow_entrypoint_.had_active_storage_access = true;
}
void InFlowSuccessorInteractionState::IncrementFlowIndex(size_t increment) {
flow_index_ += increment;
}
void InFlowSuccessorInteractionState::
RecordSuccessorInteractionAtCurrentFlowIndex() {
bool has_existing_record_for_current_index =
!successor_interaction_indices_.empty() &&
successor_interaction_indices_.back() == flow_index_;
if (!has_existing_record_for_current_index) {
successor_interaction_indices_.push_back(flow_index_);
}
}
bool InFlowSuccessorInteractionState::IsAtSuccessor() const {
return flow_index_ > 0;
}
} // namespace btm
BtmNavigationFlowDetector::BtmNavigationFlowDetector(WebContents* web_contents)
: WebContentsUserData<BtmNavigationFlowDetector>(*web_contents),
page_visit_observer_(
web_contents,
base::BindRepeating(
&BtmNavigationFlowDetector::OnPageVisitReported,
// `base::Unretained()` is safe here because this class outlives
// `page_visit_observer_`, so `page_visit_observer_` won't run the
// callback after this class is destroyed.
base::Unretained(this))) {}
BtmNavigationFlowDetector::~BtmNavigationFlowDetector() = default;
void BtmNavigationFlowDetector::OnPageVisitReported(
BtmPageVisitInfo page_visit,
BtmNavigationInfo navigation) {
CHECK(!previous_page_to_current_page_.has_value() ||
previous_page_to_current_page_->destination.url == page_visit.url);
// Slide our sliding window by one report (page visit + navigation).
two_pages_ago_ = std::move(previous_page_);
two_pages_ago_to_previous_page_ = std::move(previous_page_to_current_page_);
previous_page_ = std::move(page_visit);
previous_page_to_current_page_ = std::move(navigation);
// Update IFSI tracking state based on the visit. To have all the information
// we need, we have to do this after the visit is reported but before we
// modify the IFSI flow state.
//
// Make sure in-visit storage accesses are propagated to IFSI tracking state
// entrypoints.
bool was_visit_for_successor_flow_entrypoint =
flow_status_ == btm::FlowStatus::kOngoing &&
successor_interaction_tracking_state_.has_value() &&
!successor_interaction_tracking_state_->IsAtSuccessor();
if (was_visit_for_successor_flow_entrypoint &&
previous_page_->had_active_storage_access) {
successor_interaction_tracking_state_
->RecordActiveStorageAccessByEntrypoint();
}
// Record any in-flow successor interactions.
if (previous_page_->received_user_activation &&
successor_interaction_tracking_state_.has_value() &&
successor_interaction_tracking_state_->IsAtSuccessor()) {
successor_interaction_tracking_state_
->RecordSuccessorInteractionAtCurrentFlowIndex();
}
// Update in-flow successor interaction tracking state based on the flow
// status after this report, and maybe emit InFlowSuccessorInteraction UKM.
bool did_start_new_flow = MaybeInitializeSuccessorInteractionTrackingState();
flow_status_ = FlowStatusAfterNavigation(did_start_new_flow);
if (flow_status_ == btm::FlowStatus::kOngoing && !did_start_new_flow) {
successor_interaction_tracking_state_->IncrementFlowIndex(
previous_page_to_current_page_->server_redirects.size() + 1);
}
if (flow_status_ == btm::FlowStatus::kEnded) {
MaybeEmitInFlowSuccessorInteraction();
}
if (flow_status_ != btm::FlowStatus::kOngoing) {
successor_interaction_tracking_state_.reset();
}
MaybeEmitDirectNavigationUkm(previous_page_to_current_page_.value());
MaybeEmitNavFlowNodeUkmForPreviousPage();
int32_t flow_id = static_cast<int32_t>(base::RandUint64());
const BtmServerRedirectInfo* server_redirect_entrypoint_exit =
GetEntrypointExitServerRedirect(previous_page_to_current_page_.value());
if (server_redirect_entrypoint_exit != nullptr) {
MaybeEmitSuspectedTrackerFlowUkmForServerRedirectExit(
*server_redirect_entrypoint_exit, flow_id);
} else {
MaybeEmitSuspectedTrackerFlowUkmForClientRedirectExit(flow_id);
MaybeEmitInFlowInteraction(flow_id);
}
}
void BtmNavigationFlowDetector::MaybeEmitNavFlowNodeUkmForPreviousPage() {
if (!CanEmitNavFlowNodeUkmForPreviousPage()) {
return;
}
ukm::builders::DIPS_NavigationFlowNode(previous_page_->source_id)
.SetWerePreviousAndNextSiteSame(GetSiteForBtm(two_pages_ago_->url) ==
GetSiteForCurrentPage())
.SetDidHaveUserActivation(previous_page_->received_user_activation)
.SetDidHaveSuccessfulWAA(
previous_page_->had_successful_web_authn_assertion)
.SetWereEntryAndExitRendererInitiated(
two_pages_ago_to_previous_page_->was_renderer_initiated &&
previous_page_to_current_page_->was_renderer_initiated)
.SetWasEntryUserInitiated(
two_pages_ago_to_previous_page_->was_user_initiated)
.SetWasExitUserInitiated(
previous_page_to_current_page_->was_user_initiated)
.SetVisitDurationMilliseconds(ukm::GetExponentialBucketMinForUserTiming(
previous_page_->visit_duration.InMilliseconds()))
.Record(ukm::UkmRecorder::Get());
}
bool BtmNavigationFlowDetector::CanEmitNavFlowNodeUkmForPreviousPage() const {
bool page_is_in_series_of_three =
two_pages_ago_.has_value() && !two_pages_ago_->url.is_empty();
if (!page_is_in_series_of_three) {
return false;
}
CHECK(previous_page_.has_value() &&
previous_page_to_current_page_.has_value());
bool page_has_valid_source_id =
previous_page_->source_id != ukm::kInvalidSourceId;
bool is_site_different_from_prior_page =
GetSiteForBtm(previous_page_->url) != GetSiteForBtm(two_pages_ago_->url);
bool is_site_different_from_next_page =
GetSiteForBtm(previous_page_->url) != GetSiteForCurrentPage();
return page_has_valid_source_id &&
previous_page_->had_active_storage_access &&
is_site_different_from_prior_page && is_site_different_from_next_page;
}
void BtmNavigationFlowDetector::
MaybeEmitSuspectedTrackerFlowUkmForServerRedirectExit(
const BtmServerRedirectInfo& exit_info,
int32_t flow_id) {
if (!CanEmitSuspectedTrackerFlowUkmForServerRedirectExit(exit_info)) {
return;
}
EmitSuspectedTrackerFlowUkm(previous_page_->source_id, exit_info.source_id,
exit_info.did_write_cookies, flow_id,
BtmRedirectType::kServer);
}
bool BtmNavigationFlowDetector::
CanEmitSuspectedTrackerFlowUkmForServerRedirectExit(
const BtmServerRedirectInfo& exit_info) const {
if (!previous_page_.has_value() ||
!previous_page_to_current_page_.has_value()) {
return false;
}
btm::EntrypointInfo entrypoint_info_for_server_redirect_exit(
exit_info, WasClientRedirectLike(*previous_page_to_current_page_));
return CanEmitSuspectedTrackerFlowUkm(
*previous_page_, entrypoint_info_for_server_redirect_exit,
GetSiteForCurrentPage());
}
void BtmNavigationFlowDetector::
MaybeEmitSuspectedTrackerFlowUkmForClientRedirectExit(int32_t flow_id) {
if (!CanEmitSuspectedTrackerFlowUkmForClientRedirectExit()) {
return;
}
EmitSuspectedTrackerFlowUkm(two_pages_ago_->source_id,
previous_page_->source_id,
previous_page_->had_active_storage_access,
flow_id, BtmRedirectType::kClient);
}
bool BtmNavigationFlowDetector::
CanEmitSuspectedTrackerFlowUkmForClientRedirectExit() const {
bool page_is_in_series_of_three =
two_pages_ago_.has_value() && !two_pages_ago_->url.is_empty();
if (!page_is_in_series_of_three) {
return false;
}
CHECK(previous_page_.has_value() &&
previous_page_to_current_page_.has_value());
if (!WasClientRedirectLike(*previous_page_to_current_page_)) {
return false;
}
btm::EntrypointInfo entrypoint_info(*two_pages_ago_to_previous_page_,
*previous_page_);
return CanEmitSuspectedTrackerFlowUkm(two_pages_ago_.value(), entrypoint_info,
GetSiteForCurrentPage());
}
bool BtmNavigationFlowDetector::CanEmitSuspectedTrackerFlowUkm(
const BtmPageVisitInfo& referrer_page_info,
const btm::EntrypointInfo& entrypoint_info,
const std::string& exit_site) const {
bool referrer_has_valid_source_id =
referrer_page_info.source_id != ukm::kInvalidSourceId;
bool entrypoint_has_valid_source_id =
entrypoint_info.source_id != ukm::kInvalidSourceId;
bool is_entrypoint_site_different_from_referrer =
entrypoint_info.site != GetSiteForBtm(referrer_page_info.url);
bool is_entrypoint_site_different_from_exit_page =
entrypoint_info.site != exit_site;
return referrer_has_valid_source_id && entrypoint_has_valid_source_id &&
is_entrypoint_site_different_from_referrer &&
is_entrypoint_site_different_from_exit_page &&
entrypoint_info.was_referral_client_redirect;
}
void BtmNavigationFlowDetector::MaybeEmitInFlowInteraction(int32_t flow_id) {
if (!CanEmitSuspectedTrackerFlowUkmForClientRedirectExit() ||
!two_pages_ago_to_previous_page_->server_redirects.empty() ||
!previous_page_->received_user_activation) {
return;
}
ukm::builders::DIPS_TrustIndicator_InFlowInteractionV2(
previous_page_->source_id)
.SetFlowId(flow_id)
.Record(ukm::UkmRecorder::Get());
}
void BtmNavigationFlowDetector::MaybeEmitInFlowSuccessorInteraction() {
if (!successor_interaction_tracking_state_.has_value() ||
successor_interaction_tracking_state_->successor_interaction_indices()
.empty()) {
return;
}
for (size_t index :
successor_interaction_tracking_state_->successor_interaction_indices()) {
ukm::builders::DIPS_TrustIndicator_InFlowSuccessorInteraction(
successor_interaction_tracking_state_->flow_entrypoint().source_id)
.SetSuccessorRedirectIndex(index)
.SetDidEntrypointAccessStorage(
successor_interaction_tracking_state_->flow_entrypoint()
.had_active_storage_access)
.Record(ukm::UkmRecorder::Get());
}
}
btm::FlowStatus BtmNavigationFlowDetector::FlowStatusAfterNavigation(
bool did_most_recent_navigation_start_new_flow) const {
if (!WasClientRedirectLike(*previous_page_to_current_page_)) {
return btm::FlowStatus::kInvalidated;
}
if (!successor_interaction_tracking_state_.has_value()) {
return btm::FlowStatus::kInvalidated;
}
if (did_most_recent_navigation_start_new_flow) {
bool is_still_on_entrypoint =
previous_page_to_current_page_->server_redirects.empty();
if (is_still_on_entrypoint) {
return btm::FlowStatus::kOngoing;
}
bool are_entrypoint_and_current_page_same_site =
successor_interaction_tracking_state_->flow_entrypoint().site ==
GetSiteForCurrentPage();
return are_entrypoint_and_current_page_same_site ? btm::FlowStatus::kOngoing
: btm::FlowStatus::kEnded;
}
QuantityBucket cross_site_redirect_quantity_bucket =
GetCrossSiteRedirectQuantity(GetSiteForBtm(previous_page_->url),
*previous_page_to_current_page_);
switch (cross_site_redirect_quantity_bucket) {
case QuantityBucket::kZero:
return btm::FlowStatus::kOngoing;
case QuantityBucket::kOne:
return btm::FlowStatus::kEnded;
case QuantityBucket::kMultiple:
return btm::FlowStatus::kInvalidated;
}
}
bool BtmNavigationFlowDetector::
MaybeInitializeSuccessorInteractionTrackingState() {
if (flow_status_ == btm::FlowStatus::kOngoing) {
return false;
}
if (!previous_page_ || !previous_page_to_current_page_) {
return false;
}
if (!WasClientRedirectLike(*previous_page_to_current_page_)) {
return false;
}
// Look for an entrypoint, which must either be the current page or the first
// server redirect since the prior page.
const std::vector<BtmServerRedirectInfo>& server_redirects =
previous_page_to_current_page_->server_redirects;
bool can_entrypoint_be_current_page = server_redirects.empty();
const std::string site_for_previous_page = GetSiteForBtm(previous_page_->url);
if (can_entrypoint_be_current_page) {
if (site_for_previous_page != GetSiteForCurrentPage()) {
successor_interaction_tracking_state_.emplace(
btm::EntrypointInfo(*previous_page_to_current_page_));
return true;
}
return false;
}
const BtmServerRedirectInfo& possible_entrypoint = server_redirects.front();
if (GetSiteForBtm(possible_entrypoint.url) == site_for_previous_page) {
return false;
}
bool had_cross_site_redirect_after_entrypoint =
GetCrossSiteRedirectQuantity(site_for_previous_page,
*previous_page_to_current_page_) ==
QuantityBucket::kMultiple;
if (had_cross_site_redirect_after_entrypoint) {
return false;
}
successor_interaction_tracking_state_.emplace(btm::EntrypointInfo(
possible_entrypoint,
WasClientRedirectLike(*previous_page_to_current_page_)));
successor_interaction_tracking_state_->IncrementFlowIndex(
server_redirects.size());
return true;
}
const std::string BtmNavigationFlowDetector::GetSiteForCurrentPage() const {
return GetSiteForBtm(previous_page_to_current_page_->destination.url);
}
WEB_CONTENTS_USER_DATA_KEY_IMPL(BtmNavigationFlowDetector);
} // namespace content