| // 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/browser/lookalikes/lookalike_url_service.h" |
| |
| #include <utility> |
| |
| #include "base/containers/contains.h" |
| #include "base/functional/bind.h" |
| #include "base/functional/callback.h" |
| #include "base/memory/scoped_refptr.h" |
| #include "base/memory/singleton.h" |
| #include "base/metrics/field_trial_params.h" |
| #include "base/metrics/histogram_macros.h" |
| #include "base/time/default_clock.h" |
| #include "base/trace_event/trace_event.h" |
| #include "chrome/browser/content_settings/host_content_settings_map_factory.h" |
| #include "chrome/browser/engagement/site_engagement_service_factory.h" |
| #include "chrome/browser/profiles/incognito_helpers.h" |
| #include "chrome/browser/profiles/profile.h" |
| #include "chrome/browser/profiles/profile_keyed_service_factory.h" |
| #include "chrome/browser/safe_browsing/user_interaction_observer.h" |
| #include "chrome/common/channel_info.h" |
| #include "components/content_settings/core/browser/host_content_settings_map.h" |
| #include "components/lookalikes/core/lookalike_url_util.h" |
| #include "components/lookalikes/core/safety_tips_config.h" |
| #include "components/security_state/core/security_state.h" |
| #include "components/site_engagement/content/site_engagement_score.h" |
| #include "components/site_engagement/content/site_engagement_service.h" |
| #include "components/url_formatter/spoof_checks/top_domains/top_domain_util.h" |
| #include "net/base/registry_controlled_domains/registry_controlled_domain.h" |
| #include "net/base/url_util.h" |
| #include "services/metrics/public/cpp/ukm_source_id.h" |
| #include "url/url_constants.h" |
| |
| using lookalikes::DomainInfo; |
| using lookalikes::LookalikeActionType; |
| using lookalikes::LookalikeUrlMatchType; |
| using security_state::SafetyTipStatus; |
| |
| namespace { |
| |
| constexpr base::TimeDelta kEngagedSiteUpdateInterval = base::Seconds(60); |
| |
| class LookalikeUrlServiceFactory : public ProfileKeyedServiceFactory { |
| public: |
| static LookalikeUrlService* GetForProfile(Profile* profile) { |
| return static_cast<LookalikeUrlService*>( |
| GetInstance()->GetServiceForBrowserContext(profile, |
| /*create_service=*/true)); |
| } |
| static LookalikeUrlServiceFactory* GetInstance() { |
| return base::Singleton<LookalikeUrlServiceFactory>::get(); |
| } |
| |
| LookalikeUrlServiceFactory(const LookalikeUrlServiceFactory&) = delete; |
| LookalikeUrlServiceFactory& operator=(const LookalikeUrlServiceFactory&) = |
| delete; |
| |
| private: |
| friend struct base::DefaultSingletonTraits<LookalikeUrlServiceFactory>; |
| |
| // LookalikeUrlServiceFactory(); |
| LookalikeUrlServiceFactory() |
| : ProfileKeyedServiceFactory( |
| "LookalikeUrlServiceFactory", |
| ProfileSelections::Builder() |
| .WithRegular(ProfileSelection::kOwnInstance) |
| // TODO(crbug.com/40257657): Check if this service is needed in |
| // Guest mode. |
| .WithGuest(ProfileSelection::kOwnInstance) |
| .Build()) { |
| DependsOn(site_engagement::SiteEngagementServiceFactory::GetInstance()); |
| } |
| |
| ~LookalikeUrlServiceFactory() override {} |
| |
| // BrowserContextKeyedServiceFactory: |
| KeyedService* BuildServiceInstanceFor( |
| content::BrowserContext* profile) const override { |
| return new LookalikeUrlService(static_cast<Profile*>(profile)); |
| } |
| }; |
| |
| // static |
| std::vector<DomainInfo> UpdateEngagedSitesOnWorkerThread( |
| base::Time now, |
| scoped_refptr<HostContentSettingsMap> map) { |
| TRACE_EVENT0("navigation", |
| "LookalikeUrlService UpdateEngagedSitesOnWorkerThread"); |
| std::vector<DomainInfo> new_engaged_sites; |
| |
| auto details = |
| site_engagement::SiteEngagementService::GetAllDetailsInBackground(now, |
| map); |
| TRACE_EVENT1("navigation", "LookalikeUrlService SiteEngagementService", |
| "site_count", details.size()); |
| for (const site_engagement::mojom::SiteEngagementDetails& detail : details) { |
| if (!detail.origin.SchemeIsHTTPOrHTTPS()) { |
| continue; |
| } |
| // Ignore sites with an engagement score below threshold. |
| if (!site_engagement::SiteEngagementService::IsEngagementAtLeast( |
| detail.total_score, blink::mojom::EngagementLevel::MEDIUM)) { |
| continue; |
| } |
| const DomainInfo domain_info = lookalikes::GetDomainInfo(detail.origin); |
| if (domain_info.domain_and_registry.empty()) { |
| continue; |
| } |
| new_engaged_sites.push_back(domain_info); |
| } |
| |
| return new_engaged_sites; |
| } |
| |
| // Gets the eTLD+1 of the provided hostname, including private registries (e.g. |
| // foo.blogspot.com returns blogspot.com. |
| std::string GetETLDPlusOneWithPrivateRegistries(const std::string& hostname) { |
| return net::registry_controlled_domains::GetDomainAndRegistry( |
| hostname, net::registry_controlled_domains::INCLUDE_PRIVATE_REGISTRIES); |
| } |
| |
| void RecordReputationStatusWithEngagedSitesTime(base::TimeTicks start) { |
| UMA_HISTOGRAM_TIMES( |
| "Security.SafetyTips.GetReputationStatusWithEngagedSitesTime", |
| base::TimeTicks::Now() - start); |
| } |
| |
| } // namespace |
| |
| LookalikeUrlService::LookalikeUrlService(Profile* profile) |
| : profile_(profile), clock_(base::DefaultClock::GetInstance()) {} |
| |
| LookalikeUrlService::~LookalikeUrlService() = default; |
| |
| // static |
| LookalikeUrlService* LookalikeUrlService::Get(Profile* profile) { |
| return LookalikeUrlServiceFactory::GetForProfile(profile); |
| } |
| |
| bool LookalikeUrlService::EngagedSitesNeedUpdating() const { |
| if (last_engagement_fetch_time_.is_null()) |
| return true; |
| const base::TimeDelta elapsed = clock_->Now() - last_engagement_fetch_time_; |
| return elapsed >= kEngagedSiteUpdateInterval; |
| } |
| |
| void LookalikeUrlService::ForceUpdateEngagedSites( |
| EngagedSitesCallback callback) { |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); |
| TRACE_EVENT0("navigation", "LookalikeUrlService::ForceUpdateEngagedSites"); |
| |
| // Queue an update on a worker thread if necessary. |
| if (!update_in_progress_) { |
| update_in_progress_ = true; |
| |
| base::ThreadPool::PostTaskAndReplyWithResult( |
| FROM_HERE, |
| {base::TaskPriority::USER_BLOCKING, |
| base::TaskShutdownBehavior::SKIP_ON_SHUTDOWN}, |
| base::BindOnce( |
| &UpdateEngagedSitesOnWorkerThread, |
| clock_->Now(), |
| base::WrapRefCounted( |
| HostContentSettingsMapFactory::GetForProfile(profile_))), |
| base::BindOnce(&LookalikeUrlService::OnUpdateEngagedSitesCompleted, |
| weak_factory_.GetWeakPtr())); |
| } |
| |
| // Postpone the execution of the callback after the update is completed. |
| pending_update_complete_callbacks_.push_back(std::move(callback)); |
| } |
| |
| void LookalikeUrlService::OnUpdateEngagedSitesCompleted( |
| std::vector<DomainInfo> new_engaged_sites) { |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); |
| DCHECK(update_in_progress_); |
| TRACE_EVENT0("navigation", |
| "LookalikeUrlService::OnUpdateEngagedSitesCompleted"); |
| engaged_sites_.swap(new_engaged_sites); |
| last_engagement_fetch_time_ = clock_->Now(); |
| update_in_progress_ = false; |
| |
| // Call pending callbacks. |
| std::vector<EngagedSitesCallback> callbacks; |
| callbacks.swap(pending_update_complete_callbacks_); |
| for (auto&& callback : callbacks) { |
| std::move(callback).Run(engaged_sites_); |
| } |
| } |
| |
| const std::vector<DomainInfo> LookalikeUrlService::GetLatestEngagedSites() |
| const { |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); |
| return engaged_sites_; |
| } |
| |
| void LookalikeUrlService::SetClockForTesting(base::Clock* clock) { |
| clock_ = clock; |
| } |
| |
| LookalikeUrlService::LookalikeUrlCheckResult |
| LookalikeUrlService::CheckUrlForLookalikes( |
| const GURL& url, |
| const std::vector<DomainInfo>& engaged_sites, |
| bool stop_checking_on_allowlist_or_ignore) const { |
| LookalikeUrlCheckResult result; |
| |
| // Don't warn on non-HTTP(s) sites or non-public domains. |
| if (!url.SchemeIsHTTPOrHTTPS() || net::HostStringIsLocalhost(url.host()) || |
| net::IsHostnameNonUnique(url.host()) || |
| lookalikes::GetETLDPlusOne(url.host()).empty() || |
| lookalikes::IsSafeTLD(url.host())) { |
| return result; |
| } |
| |
| if (IsIgnored(url)) { |
| result.is_warning_previously_dismissed = true; |
| if (stop_checking_on_allowlist_or_ignore) { |
| return result; |
| } |
| } |
| |
| // When there's no proto (like at browser start), fail-safe and don't block. |
| const auto* proto = lookalikes::GetSafetyTipsRemoteConfigProto(); |
| if (!proto) { |
| return result; |
| } |
| |
| // If the host is allowlisted by policy, don't show any warning. |
| if (lookalikes::IsAllowedByEnterprisePolicy(profile_->GetPrefs(), url)) { |
| result.is_allowlisted = true; |
| if (stop_checking_on_allowlist_or_ignore) { |
| return result; |
| } |
| } |
| |
| // GetDomainInfo() is expensive, so do possible early-abort checks first. |
| base::TimeTicks get_domain_info_start = base::TimeTicks::Now(); |
| const DomainInfo navigated_domain = lookalikes::GetDomainInfo(url); |
| result.get_domain_info_duration = |
| base::TimeTicks::Now() - get_domain_info_start; |
| |
| if (IsTopDomain(navigated_domain)) { |
| return result; |
| } |
| |
| // Ensure that this URL is not already engaged. We can't use the synchronous |
| // SiteEngagementService::IsEngagementAtLeast as it has side effects. We check |
| // in PerformChecks to ensure we have up-to-date engaged_sites. This check |
| // ignores the scheme which is okay since it's more conservative: If the user |
| // is engaged with http://domain.test, not showing the warning on |
| // https://domain.test is acceptable. |
| if (base::Contains(engaged_sites, navigated_domain.domain_and_registry, |
| &DomainInfo::domain_and_registry)) { |
| return result; |
| } |
| |
| const lookalikes::LookalikeTargetAllowlistChecker in_target_allowlist = |
| base::BindRepeating( |
| &lookalikes::IsTargetHostAllowlistedBySafetyTipsComponent, proto); |
| std::string matched_domain; |
| if (GetMatchingDomain(navigated_domain, engaged_sites, in_target_allowlist, |
| proto, &matched_domain, &result.match_type)) { |
| DCHECK(!matched_domain.empty()); |
| result.suggested_url = |
| GetSuggestedURL(result.match_type, url, matched_domain); |
| |
| if (lookalikes::IsUrlAllowlistedBySafetyTipsComponent( |
| proto, url.GetWithEmptyPath(), result.suggested_url)) { |
| result.is_allowlisted = true; |
| if (stop_checking_on_allowlist_or_ignore) { |
| return result; |
| } |
| } |
| result.action_type = GetActionForMatchType( |
| proto, chrome::GetChannel(), navigated_domain.domain_and_registry, |
| result.match_type); |
| } else if (ShouldBlockBySpoofCheckResult(navigated_domain)) { |
| result.match_type = LookalikeUrlMatchType::kFailedSpoofChecks; |
| result.suggested_url = GURL(); |
| |
| // Domains that trigger spoof checking are allowlisted as if they were |
| // spoofing themselves, so pass in the spoofing URL as the canonical. |
| if (lookalikes::IsUrlAllowlistedBySafetyTipsComponent( |
| proto, url.GetWithEmptyPath(), url)) { |
| result.is_allowlisted = true; |
| if (stop_checking_on_allowlist_or_ignore) { |
| return result; |
| } |
| } |
| result.action_type = GetActionForMatchType( |
| proto, chrome::GetChannel(), navigated_domain.domain_and_registry, |
| result.match_type); |
| } |
| |
| return result; |
| } |
| |
| void LookalikeUrlService::CheckSafetyTipStatus( |
| const GURL& url, |
| content::WebContents* web_contents, |
| SafetyTipCheckCallback callback) { |
| DCHECK(url.SchemeIsHTTPOrHTTPS()); |
| if (EngagedSitesNeedUpdating()) { |
| ForceUpdateEngagedSites(base::BindOnce( |
| &LookalikeUrlService::CheckSafetyTipStatusWithEngagedSites, |
| weak_factory_.GetWeakPtr(), url, std::move(callback))); |
| // If the engaged sites need updating, there's nothing to do until callback. |
| return; |
| } |
| |
| CheckSafetyTipStatusWithEngagedSites(url, std::move(callback), |
| GetLatestEngagedSites()); |
| } |
| |
| void LookalikeUrlService::CheckSafetyTipStatusWithEngagedSites( |
| const GURL& url, |
| SafetyTipCheckCallback callback, |
| const std::vector<DomainInfo>& engaged_sites) { |
| base::TimeTicks start = base::TimeTicks::Now(); |
| |
| LookalikeUrlCheckResult lookalike_result = |
| CheckUrlForLookalikes(url, engaged_sites, |
| /*stop_checking_on_allowlist_or_ignore=*/false); |
| |
| SafetyTipCheckResult result; |
| result.url = url; |
| |
| if (lookalike_result.action_type != LookalikeActionType::kShowSafetyTip) { |
| std::move(callback).Run(result); |
| RecordReputationStatusWithEngagedSitesTime(start); |
| return; |
| } |
| |
| result.safety_tip_status = SafetyTipStatus::kNone; |
| result.suggested_url = lookalike_result.suggested_url; |
| result.safety_tip_status = SafetyTipStatus::kLookalike; |
| result.lookalike_heuristic_triggered = true; |
| |
| if (lookalike_result.is_allowlisted) { |
| // This will record a UKM but it won't show a warning. |
| result.safety_tip_status = SafetyTipStatus::kNone; |
| std::move(callback).Run(result); |
| RecordReputationStatusWithEngagedSitesTime(start); |
| return; |
| } |
| |
| if (lookalike_result.is_warning_previously_dismissed) { |
| result.safety_tip_status = SafetyTipStatus::kLookalikeIgnored; |
| // The local allowlist is used by both the interstitial and safety tips, so |
| // it's possible to hit this case even when we're not in the conditions |
| // above. It's also possible to get kNone here when a domain is added to |
| // the server-side allowlist after it has been ignored. In these cases, |
| // there's no additional action required. |
| } |
| std::move(callback).Run(result); |
| RecordReputationStatusWithEngagedSitesTime(start); |
| } |
| |
| bool LookalikeUrlService::IsIgnored(const GURL& url) const { |
| return warning_dismissed_etld1s_.count( |
| GetETLDPlusOneWithPrivateRegistries(url.host())) > 0; |
| } |
| |
| void LookalikeUrlService::SetUserIgnore(const GURL& url) { |
| warning_dismissed_etld1s_.insert( |
| GetETLDPlusOneWithPrivateRegistries(url.host())); |
| } |
| |
| void LookalikeUrlService::OnUIDisabledFirstVisit(const GURL& url) { |
| warning_dismissed_etld1s_.insert( |
| GetETLDPlusOneWithPrivateRegistries(url.host())); |
| } |
| |
| void LookalikeUrlService::ResetWarningDismissedETLDPlusOnesForTesting() { |
| warning_dismissed_etld1s_.clear(); |
| } |
| |
| // static |
| void LookalikeUrlService::EnsureFactoryBuilt() { |
| LookalikeUrlServiceFactory::GetInstance(); |
| } |