| // Copyright 2012 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "components/safe_browsing/content/renderer/phishing_classifier/phishing_classifier_delegate.h" |
| |
| #include <memory> |
| #include <optional> |
| |
| #include "base/compiler_specific.h" |
| #include "base/containers/span.h" |
| #include "base/files/scoped_temp_dir.h" |
| #include "base/functional/bind.h" |
| #include "base/memory/raw_ptr.h" |
| #include "base/memory/ref_counted_memory.h" |
| #include "base/memory/scoped_refptr.h" |
| #include "base/run_loop.h" |
| #include "base/strings/string_number_conversions.h" |
| #include "base/strings/utf_string_conversions.h" |
| #include "base/test/gmock_callback_support.h" |
| #include "base/test/metrics/histogram_tester.h" |
| #include "base/test/scoped_feature_list.h" |
| #include "chrome/test/base/chrome_render_view_test.h" |
| #include "chrome/test/base/chrome_unit_test_suite.h" |
| #include "components/safe_browsing/content/common/safe_browsing.mojom-shared.h" |
| #include "components/safe_browsing/content/renderer/phishing_classifier/features.h" |
| #include "components/safe_browsing/content/renderer/phishing_classifier/phishing_classifier.h" |
| #include "components/safe_browsing/content/renderer/phishing_classifier/scorer.h" |
| #include "components/safe_browsing/core/common/fbs/client_model_generated.h" |
| #include "components/safe_browsing/core/common/features.h" |
| #include "components/safe_browsing/core/common/proto/csd.pb.h" |
| #include "content/public/renderer/render_frame.h" |
| #include "mojo/public/cpp/base/proto_wrapper.h" |
| #include "services/service_manager/public/cpp/interface_provider.h" |
| #include "testing/gmock/include/gmock/gmock.h" |
| #include "third_party/blink/public/platform/web_url.h" |
| #include "third_party/blink/public/platform/web_url_request.h" |
| #include "url/gurl.h" |
| |
| using base::ASCIIToUTF16; |
| using safe_browsing::mojom::ClientSideDetectionType::kTriggerModels; |
| using ::testing::_; |
| using ::testing::Eq; |
| using ::testing::InSequence; |
| using ::testing::Mock; |
| using ::testing::Pointee; |
| using ::testing::Property; |
| using ::testing::StrictMock; |
| |
| namespace safe_browsing { |
| |
| namespace { |
| |
| std::string GetFlatBufferString(int version) { |
| flatbuffers::FlatBufferBuilder builder(1024); |
| std::vector<flatbuffers::Offset<flat::Hash>> hashes; |
| // Make sure this is sorted. |
| std::vector<std::string> hashes_vector = {"feature1", "feature2", "feature3", |
| "token one", "token two"}; |
| for (std::string& feature : hashes_vector) { |
| std::vector<uint8_t> hash_data(feature.begin(), feature.end()); |
| hashes.push_back(flat::CreateHashDirect(builder, &hash_data)); |
| } |
| flatbuffers::Offset<flatbuffers::Vector<flatbuffers::Offset<flat::Hash>>> |
| hashes_flat = builder.CreateVector(hashes); |
| |
| std::vector<flatbuffers::Offset<flat::ClientSideModel_::Rule>> rules; |
| std::vector<int32_t> rule_feature1 = {}; |
| std::vector<int32_t> rule_feature2 = {0}; |
| std::vector<int32_t> rule_feature3 = {0, 1}; |
| rules.push_back( |
| flat::ClientSideModel_::CreateRuleDirect(builder, &rule_feature1, 0.5)); |
| rules.push_back( |
| flat::ClientSideModel_::CreateRuleDirect(builder, &rule_feature2, 2)); |
| rules.push_back( |
| flat::ClientSideModel_::CreateRuleDirect(builder, &rule_feature3, 3)); |
| flatbuffers::Offset< |
| flatbuffers::Vector<flatbuffers::Offset<flat::ClientSideModel_::Rule>>> |
| rules_flat = builder.CreateVector(rules); |
| |
| std::vector<int32_t> page_terms_vector = {3, 4}; |
| flatbuffers::Offset<flatbuffers::Vector<int32_t>> page_term_flat = |
| builder.CreateVector(page_terms_vector); |
| |
| std::vector<uint32_t> page_words_vector = {1000U, 2000U, 3000U}; |
| flatbuffers::Offset<flatbuffers::Vector<uint32_t>> page_word_flat = |
| builder.CreateVector(page_words_vector); |
| |
| std::vector< |
| flatbuffers::Offset<safe_browsing::flat::TfLiteModelMetadata_::Threshold>> |
| thresholds_vector = {}; |
| flatbuffers::Offset<flat::TfLiteModelMetadata> tflite_metadata_flat = |
| flat::CreateTfLiteModelMetadataDirect(builder, 0, &thresholds_vector, 0, |
| 0); |
| |
| flat::ClientSideModelBuilder csd_model_builder(builder); |
| csd_model_builder.add_hashes(hashes_flat); |
| csd_model_builder.add_rule(rules_flat); |
| csd_model_builder.add_page_term(page_term_flat); |
| csd_model_builder.add_page_word(page_word_flat); |
| csd_model_builder.add_max_words_per_term(2); |
| csd_model_builder.add_murmur_hash_seed(12345U); |
| csd_model_builder.add_max_shingles_per_page(10); |
| csd_model_builder.add_shingle_size(3); |
| csd_model_builder.add_tflite_metadata(tflite_metadata_flat); |
| csd_model_builder.add_version(version); |
| |
| builder.Finish(csd_model_builder.Finish()); |
| return std::string(reinterpret_cast<char*>(builder.GetBufferPointer()), |
| builder.GetSize()); |
| } |
| |
| class MockPhishingClassifier : public PhishingClassifier { |
| public: |
| explicit MockPhishingClassifier(content::RenderFrame* render_frame) |
| : PhishingClassifier(render_frame) {} |
| |
| MockPhishingClassifier(const MockPhishingClassifier&) = delete; |
| MockPhishingClassifier& operator=(const MockPhishingClassifier&) = delete; |
| |
| ~MockPhishingClassifier() override = default; |
| |
| MOCK_METHOD1(BeginClassification, void(DoneCallback)); |
| MOCK_METHOD0(CancelPendingClassification, void()); |
| MOCK_METHOD1( |
| SetClientSideDetectionType, |
| void(std::optional<safe_browsing::mojom::ClientSideDetectionType>)); |
| }; |
| } // namespace |
| |
| class PhishingClassifierDelegateTest |
| : public ChromeRenderViewTest, |
| public ::testing::WithParamInterface<double> { |
| protected: |
| void SetUp() override { |
| ChromeRenderViewTest::SetUp(); |
| |
| if (GetParam() >= 0) { |
| feature_list_.InitAndEnableFeatureWithParameters( |
| kClientSideDetectionNewObservers, |
| {{"ClassificationDelay", base::NumberToString(GetParam())}}); |
| } |
| |
| content::RenderFrame* render_frame = GetMainRenderFrame(); |
| classifier_ = new StrictMock<MockPhishingClassifier>(render_frame); |
| render_frame->GetAssociatedInterfaceRegistry()->RemoveInterface( |
| mojom::PhishingDetector::Name_); |
| delegate_ = PhishingClassifierDelegate::Create(render_frame, classifier_); |
| classifier_not_ready_ = false; |
| } |
| |
| void TearDown() override { |
| // `delegate_` owns `classifier_` and the RenderFrame owns `delegate_`; |
| // clear these pointers now to avoid dangling references. |
| classifier_ = nullptr; |
| delegate_ = nullptr; |
| ChromeRenderViewTest::TearDown(); |
| } |
| |
| // Runs the ClassificationDone callback, then verify if message sent |
| // by FakeRenderThread is correct. |
| void RunAndVerifyClassificationDone(const ClientPhishingRequest& verdict) { |
| delegate_->ClassificationDone(verdict, |
| PhishingClassifier::Result::kSuccess); |
| } |
| |
| void OnStartPhishingDetection(const GURL& url) { |
| EXPECT_CALL(*classifier_, CancelPendingClassification()) |
| .Times(testing::AtMost(1)); |
| EXPECT_CALL(*classifier_, |
| SetClientSideDetectionType(std::optional(kTriggerModels))); |
| delegate_->StartPhishingDetection( |
| url, kTriggerModels, |
| base::BindOnce(&PhishingClassifierDelegateTest::VerifyRequestProto, |
| base::Unretained(this))); |
| } |
| |
| void StartPhishingDetectionWithCallback( |
| const GURL& url, |
| PhishingClassifierDelegate::StartPhishingDetectionCallback callback) { |
| EXPECT_CALL(*classifier_, CancelPendingClassification()) |
| .Times(testing::AtMost(1)); |
| EXPECT_CALL(*classifier_, |
| SetClientSideDetectionType(std::optional(kTriggerModels))); |
| delegate_->StartPhishingDetection(url, kTriggerModels, std::move(callback)); |
| } |
| |
| void SimulateRedirection(const GURL& redir_url) { |
| delegate_->last_url_received_from_browser_ = redir_url; |
| } |
| |
| void SimulatePageTrantitionForwardOrBack(const char* html, const char* url) { |
| LoadHTMLWithUrlOverride(html, url); |
| delegate_->last_main_frame_transition_ = ui::PAGE_TRANSITION_FORWARD_BACK; |
| } |
| |
| void VerifyRequestProto(mojom::PhishingDetectorResult result, |
| std::optional<mojo_base::ProtoWrapper> proto) { |
| if (result == mojom::PhishingDetectorResult::CLASSIFIER_NOT_READY) { |
| classifier_not_ready_ = true; |
| return; |
| } |
| |
| if (result != mojom::PhishingDetectorResult::SUCCESS) |
| return; |
| |
| ASSERT_TRUE(proto.has_value()); |
| auto verdict = proto->As<ClientPhishingRequest>(); |
| ASSERT_TRUE(verdict.has_value()); |
| EXPECT_EQ("http://host.test/", verdict->url()); |
| EXPECT_EQ(0.8f, verdict->client_score()); |
| EXPECT_FALSE(verdict->is_phishing()); |
| } |
| |
| void SetScorer(int model_version) { |
| std::string model_str = GetFlatBufferString(model_version); |
| base::MappedReadOnlyRegion mapped_region = |
| base::ReadOnlySharedMemoryRegion::Create(model_str.length()); |
| mapped_region.mapping.GetMemoryAsSpan<char>().copy_from(model_str); |
| ScorerStorage::GetInstance()->SetScorer( |
| Scorer::Create(mapped_region.region.Duplicate(), base::File())); |
| } |
| |
| // Owned by |delegate_|. |
| raw_ptr<StrictMock<MockPhishingClassifier>> classifier_; |
| raw_ptr<PhishingClassifierDelegate> delegate_; // Owned by the RenderFrame. |
| bool classifier_not_ready_; |
| base::test::ScopedFeatureList feature_list_; |
| }; |
| |
| TEST_P(PhishingClassifierDelegateTest, Navigation) { |
| SetScorer(/*model_version=*/1); |
| ASSERT_TRUE(classifier_->is_ready()); |
| |
| // Test an initial load. We expect classification to happen normally. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| std::string html = "<html><body>dummy</body></html>"; |
| GURL url("http://host.test/index.html"); |
| LoadHTMLWithUrlOverride(html.c_str(), url.spec().c_str()); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| OnStartPhishingDetection(url); |
| { |
| base::RunLoop run_loop; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop.QuitClosure())); |
| delegate_->PageCaptured(false); |
| run_loop.Run(); |
| Mock::VerifyAndClearExpectations(classifier_); |
| } |
| |
| // Reloading the same page will trigger a new classification. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| LoadHTMLWithUrlOverride(html.c_str(), url.spec().c_str()); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| // Start phishing detection without a fresh page should still classify, |
| // because the top level URL still match. |
| { |
| base::RunLoop run_loop; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop.QuitClosure())); |
| OnStartPhishingDetection(url); |
| delegate_->PageCaptured(false); |
| run_loop.Run(); |
| Mock::VerifyAndClearExpectations(classifier_); |
| } |
| |
| // Even if the page is captured first, it shouldn't matter since the |
| // browser request will start the classification. |
| { |
| base::RunLoop run_loop; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop.QuitClosure())); |
| delegate_->PageCaptured(false); |
| OnStartPhishingDetection(url); |
| run_loop.Run(); |
| Mock::VerifyAndClearExpectations(classifier_); |
| } |
| |
| { |
| base::RunLoop run_loop; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop.QuitClosure())); |
| OnStartPhishingDetection(url); |
| delegate_->PageCaptured(false); |
| run_loop.Run(); |
| Mock::VerifyAndClearExpectations(classifier_); |
| } |
| |
| // Now load a new toplevel page, which is completely different to the browser |
| // side request URL. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| GURL new_url("http://host2.com"); |
| LoadHTMLWithUrlOverride("dummy2", new_url.spec().c_str()); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| { |
| base::RunLoop run_loop; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop.QuitClosure())); |
| delegate_->PageCaptured(false); |
| // Satisfy condition to start classification from both browser and renderer. |
| OnStartPhishingDetection(new_url); |
| delegate_->PageCaptured(false); |
| run_loop.Run(); |
| Mock::VerifyAndClearExpectations(classifier_); |
| } |
| |
| // No classification should happen on back/forward navigation. |
| // Note: in practice, the browser will not send a StartPhishingDetection IPC |
| // in this case. However, we want to make sure that the delegate behaves |
| // correctly regardless. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()).Times(1); |
| // Simulate a go back navigation, i.e. back to http://host.test/index.html. |
| SimulatePageTrantitionForwardOrBack(html.c_str(), url.spec().c_str()); |
| Mock::VerifyAndClearExpectations(classifier_); |
| OnStartPhishingDetection(new_url); |
| delegate_->PageCaptured(false); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| // Simulate a go forward navigation, i.e. forward to http://host.test |
| SimulatePageTrantitionForwardOrBack("dummy2", new_url.spec().c_str()); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| OnStartPhishingDetection(url); |
| delegate_->PageCaptured(false); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| // Now go back again and navigate to a different place within |
| // the same page. No classification should happen. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| // Simulate a go back again to http://host.test/index.html |
| SimulatePageTrantitionForwardOrBack(html.c_str(), url.spec().c_str()); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| OnStartPhishingDetection(url); |
| delegate_->PageCaptured(false); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| OnStartPhishingDetection(url); |
| delegate_->PageCaptured(false); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| // The delegate will cancel pending classification on destruction. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| } |
| |
| TEST_P(PhishingClassifierDelegateTest, NoPhishingModel) { |
| ASSERT_FALSE(classifier_->is_ready()); |
| ScorerStorage::GetInstance()->SetScorer(nullptr); |
| // The scorer is nullptr so the classifier should still not be ready. |
| ASSERT_FALSE(classifier_->is_ready()); |
| } |
| |
| TEST_P(PhishingClassifierDelegateTest, HasFlatBufferModel) { |
| ASSERT_FALSE(classifier_->is_ready()); |
| |
| SetScorer(/*model_version=*/1); |
| ASSERT_TRUE(classifier_->is_ready()); |
| |
| // The delegate will cancel pending classification on destruction. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| } |
| |
| TEST_P(PhishingClassifierDelegateTest, HasVisualTfLiteModel) { |
| ASSERT_FALSE(classifier_->is_ready()); |
| |
| base::ScopedTempDir temp_dir; |
| ASSERT_TRUE(temp_dir.CreateUniqueTempDir()); |
| base::FilePath file_path = |
| temp_dir.GetPath().AppendASCII("visual_model.tflite"); |
| base::File file(file_path, base::File::FLAG_OPEN_ALWAYS | |
| base::File::FLAG_READ | |
| base::File::FLAG_WRITE); |
| |
| file.WriteAtCurrentPos(base::byte_span_from_cstring("visual model file")); |
| |
| std::string model_str = GetFlatBufferString(0); |
| base::MappedReadOnlyRegion mapped_region = |
| base::ReadOnlySharedMemoryRegion::Create(model_str.length()); |
| UNSAFE_TODO(memcpy(mapped_region.mapping.memory(), model_str.data(), |
| model_str.length())); |
| ScorerStorage::GetInstance()->SetScorer( |
| Scorer::Create(mapped_region.region.Duplicate(), std::move(file))); |
| ASSERT_TRUE(classifier_->is_ready()); |
| |
| // The delegate will cancel pending classification on destruction. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| } |
| |
| TEST_P(PhishingClassifierDelegateTest, NoScorerWithRetry) { |
| base::test::ScopedFeatureList scoped_list; |
| scoped_list.InitWithFeatures( |
| {{safe_browsing::kClientSideDetectionRetryLimit}}, {}); |
| // For this test, we'll create the delegate with no scorer available yet. |
| ASSERT_FALSE(classifier_->is_ready()); |
| |
| // Queue up a pending classification, cancel it, then queue up another one. |
| GURL url("http://host.test"); |
| LoadHTMLWithUrlOverride("dummy", url.spec().c_str()); |
| OnStartPhishingDetection(url); |
| delegate_->PageCaptured(false); |
| |
| GURL url2("http://host2.com"); |
| LoadHTMLWithUrlOverride("dummy", url2.spec().c_str()); |
| OnStartPhishingDetection(url2); |
| delegate_->PageCaptured(false); |
| |
| // If there is such delay on kCsdClassificationDelay, the retry request will |
| // be started after that delay (plus some buffer for reducing flakiness), but |
| // retry timeout delay is still 0. |
| task_environment_.FastForwardBy( |
| base::Seconds(kCsdClassificationDelay.Get() + 0.2)); |
| |
| // Now set a scorer, which should cause a classifier to be created, and |
| // classification will happen again because the scorer is set within timeout. |
| base::RunLoop run_loop; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop.QuitClosure())); |
| SetScorer(/*model_version=*/1); |
| run_loop.Run(); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| // Manually start a classification, so that when a new scorer is set, it |
| // should cancel. |
| base::RunLoop run_loop2; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop2.QuitClosure())); |
| OnStartPhishingDetection(url2); |
| run_loop2.Run(); |
| |
| // If we set a new scorer while a classification is going on the |
| // classification should be cancelled. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| SetScorer(/*model_version=*/2); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| // The delegate will cancel pending classification on destruction. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| } |
| |
| TEST_P(PhishingClassifierDelegateTest, NoScorer_Ref_WithRetry) { |
| base::test::ScopedFeatureList scoped_list; |
| scoped_list.InitWithFeatures( |
| {{safe_browsing::kClientSideDetectionRetryLimit}}, {}); |
| // Similar to the last test, but navigates within the page before |
| // setting the scorer. |
| ASSERT_FALSE(classifier_->is_ready()); |
| |
| // Queue up a pending classification, cancel it, then queue up another one. |
| GURL url("http://host.test"); |
| LoadHTMLWithUrlOverride("dummy", url.spec().c_str()); |
| OnStartPhishingDetection(url); |
| delegate_->PageCaptured(false); |
| |
| OnStartPhishingDetection(url); |
| delegate_->PageCaptured(false); |
| |
| // If there is such delay on kCsdClassificationDelay, the retry request will |
| // be started after that delay (plus some buffer for reducing flakiness), but |
| // retry timeout delay is still 0. |
| task_environment_.FastForwardBy( |
| base::Seconds(kCsdClassificationDelay.Get() + 0.2)); |
| |
| // Now set a scorer, which should cause a classifier to be created, and |
| // classification will happen again because the scorer is set within timeout. |
| base::RunLoop run_loop; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop.QuitClosure())); |
| SetScorer(/*model_version=*/1); |
| run_loop.Run(); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| // Manually start a classification, so that when a new scorer is set, it |
| // should cancel. |
| base::RunLoop run_loop2; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop2.QuitClosure())); |
| OnStartPhishingDetection(url); |
| run_loop2.Run(); |
| |
| // If we set a new scorer while a classification is going on the |
| // classification should be cancelled. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| SetScorer(/*model_version=*/2); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| // The delegate will cancel pending classification on destruction. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| } |
| |
| TEST_P(PhishingClassifierDelegateTest, NoScorer) { |
| std::map<std::string, std::string> feature_params; |
| feature_params["RetryTimeMax"] = "0"; |
| base::test::ScopedFeatureList scoped_list; |
| scoped_list.InitWithFeaturesAndParameters( |
| {{safe_browsing::kClientSideDetectionRetryLimit, feature_params}}, {}); |
| |
| // For this test, we'll create the delegate with no scorer available yet. |
| ASSERT_FALSE(classifier_->is_ready()); |
| |
| // Queue up a pending classification, cancel it, then queue up another one. |
| GURL url("http://host.test"); |
| LoadHTMLWithUrlOverride("dummy", url.spec().c_str()); |
| OnStartPhishingDetection(url); |
| delegate_->PageCaptured(false); |
| |
| GURL url2("http://host2.com"); |
| LoadHTMLWithUrlOverride("dummy", url2.spec().c_str()); |
| OnStartPhishingDetection(url2); |
| delegate_->PageCaptured(false); |
| |
| // If there is such delay on kCsdClassificationDelay, the retry request will |
| // be started after that delay (plus some buffer for reducing flakiness), but |
| // retry timeout delay is still 0. |
| task_environment_.FastForwardBy( |
| base::Seconds(kCsdClassificationDelay.Get() + 0.2)); |
| |
| // Now set a scorer, which should cause a classifier to be created, but no |
| // classification will start, because the retry timeout is 0. |
| SetScorer(/*model_version=*/1); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| // Manually start a classification, so that when a new scorer is set, it |
| // should cancel. |
| base::RunLoop run_loop; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop.QuitClosure())); |
| OnStartPhishingDetection(url2); |
| run_loop.Run(); |
| |
| // If we set a new scorer while a classification is going on the |
| // classification should be cancelled. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| SetScorer(/*model_version=*/2); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| // The delegate will cancel pending classification on destruction. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| } |
| |
| TEST_P(PhishingClassifierDelegateTest, NoScorer_Ref) { |
| std::map<std::string, std::string> feature_params; |
| feature_params["RetryTimeMax"] = "0"; |
| base::test::ScopedFeatureList scoped_list; |
| scoped_list.InitWithFeaturesAndParameters( |
| {{safe_browsing::kClientSideDetectionRetryLimit, feature_params}}, {}); |
| |
| // Similar to the last test, but navigates within the page before |
| // setting the scorer. |
| ASSERT_FALSE(classifier_->is_ready()); |
| |
| // Queue up a pending classification, cancel it, then queue up another one. |
| GURL url("http://host.test"); |
| LoadHTMLWithUrlOverride("dummy", url.spec().c_str()); |
| OnStartPhishingDetection(url); |
| delegate_->PageCaptured(false); |
| |
| OnStartPhishingDetection(url); |
| delegate_->PageCaptured(false); |
| |
| // If there is such delay on kCsdClassificationDelay, the retry request will |
| // be started after that delay (plus some buffer for reducing flakiness), but |
| // retry timeout delay is still 0. |
| task_environment_.FastForwardBy( |
| base::Seconds(kCsdClassificationDelay.Get() + 0.2)); |
| |
| // Now set a scorer, which should cause a classifier to be created, but no |
| // classification will start, because the timeout delay is 0 seconds. |
| SetScorer(/*model_version=*/1); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| // Manually start a classification |
| base::RunLoop run_loop; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop.QuitClosure())); |
| OnStartPhishingDetection(url); |
| run_loop.Run(); |
| |
| // If we set a new scorer while a classification is going on the |
| // classification should be cancelled. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| SetScorer(/*model_version=*/2); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| // The delegate will cancel pending classification on destruction. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| } |
| |
| TEST_P(PhishingClassifierDelegateTest, NoScorerWithinTimeout) { |
| std::map<std::string, std::string> feature_params; |
| feature_params["RetryTimeMax"] = "0"; |
| base::test::ScopedFeatureList scoped_list; |
| scoped_list.InitWithFeaturesAndParameters( |
| {{safe_browsing::kClientSideDetectionRetryLimit, feature_params}}, {}); |
| // Similar to the last test, but the timeout delay is 0 seconds, so we expect |
| // classifier not ready to occur, and setting the scorer will not retry the |
| // classification. |
| ASSERT_FALSE(classifier_->is_ready()); |
| EXPECT_FALSE(classifier_not_ready_); |
| |
| // Queue up a pending classification. |
| GURL url("http://host.test"); |
| LoadHTMLWithUrlOverride("dummy", url.spec().c_str()); |
| OnStartPhishingDetection(url); |
| delegate_->PageCaptured(false); |
| |
| // If there is such delay on kCsdClassificationDelay, the retry request will |
| // be started after that delay (plus some buffer for reducing flakiness), but |
| // retry timeout delay is still 0. |
| task_environment_.FastForwardBy( |
| base::Seconds(kCsdClassificationDelay.Get() + 0.2)); |
| |
| EXPECT_TRUE(classifier_not_ready_); |
| } |
| |
| TEST_P(PhishingClassifierDelegateTest, NoStartPhishingDetection) { |
| // Tests the behavior when OnStartPhishingDetection has not yet been called |
| // when the page load finishes. |
| SetScorer(/*model_version=*/1); |
| ASSERT_TRUE(classifier_->is_ready()); |
| |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| GURL url("http://host.test"); |
| LoadHTMLWithUrlOverride("<html><body>phish</body></html>", |
| url.spec().c_str()); |
| Mock::VerifyAndClearExpectations(classifier_); |
| delegate_->PageCaptured(false); |
| Mock::VerifyAndClearExpectations(classifier_); |
| // Now simulate the StartPhishingDetection IPC. We expect classification |
| // to begin. |
| base::RunLoop run_loop; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop.QuitClosure())); |
| OnStartPhishingDetection(url); |
| run_loop.Run(); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| // Now try again, but this time we will navigate the page away before |
| // the IPC is sent. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| GURL url2("http://host2.com"); |
| LoadHTMLWithUrlOverride("<html><body>phish</body></html>", |
| url2.spec().c_str()); |
| Mock::VerifyAndClearExpectations(classifier_); |
| delegate_->PageCaptured(false); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| GURL url3("http://host3.com"); |
| LoadHTMLWithUrlOverride("<html><body>phish</body></html>", |
| url3.spec().c_str()); |
| Mock::VerifyAndClearExpectations(classifier_); |
| OnStartPhishingDetection(url); |
| |
| // In this test, the original page is a redirect, which we do not get a |
| // StartPhishingDetection IPC for. We simulate the redirection event to |
| // load a new page while reusing the original session history entry, and |
| // check that classification begins correctly for the landing page. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| GURL url4("http://host4.com"); |
| LoadHTMLWithUrlOverride("<html><body>phish</body></html>", |
| url4.spec().c_str()); |
| Mock::VerifyAndClearExpectations(classifier_); |
| delegate_->PageCaptured(false); |
| Mock::VerifyAndClearExpectations(classifier_); |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| |
| // Now the redirecting URL HTML has been loaded. |
| GURL redir_url("http://host4.com/redir"); |
| LoadHTMLWithUrlOverride("123", redir_url.spec().c_str()); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| OnStartPhishingDetection(url4); |
| { |
| base::RunLoop run_loop2; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop2.QuitClosure())); |
| // The below essentially called OnStartPhishingDetection by replacing the |
| // URL. |
| SimulateRedirection(redir_url); |
| // Page has finally captured for the redirecting URL. With the layout |
| // complete, it will start classification on landing page. |
| delegate_->PageCaptured(false); |
| run_loop2.Run(); |
| Mock::VerifyAndClearExpectations(classifier_); |
| } |
| |
| // The delegate will cancel pending classification on destruction. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| } |
| |
| TEST_P(PhishingClassifierDelegateTest, |
| IgnorePreliminaryCaptureAndDoesNotCancelClassification) { |
| // Tests that preliminary PageCaptured notifications are ignored. |
| SetScorer(/*model_version=*/1); |
| ASSERT_TRUE(classifier_->is_ready()); |
| |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| GURL url("http://host.test"); |
| LoadHTMLWithUrlOverride("<html><body>phish</body></html>", |
| url.spec().c_str()); |
| Mock::VerifyAndClearExpectations(classifier_); |
| OnStartPhishingDetection(url); |
| delegate_->PageCaptured(true); |
| |
| // Once the non-preliminary capture happens, classification should begin. |
| { |
| base::RunLoop run_loop; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop.QuitClosure())); |
| delegate_->PageCaptured(false); |
| run_loop.Run(); |
| Mock::VerifyAndClearExpectations(classifier_); |
| } |
| |
| // The delegate will cancel pending classification on destruction. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| } |
| |
| TEST_P(PhishingClassifierDelegateTest, DuplicatePageCaptureDoesNotCancel) { |
| // Tests that a second PageCaptured notification causes classification to |
| // be cancelled. |
| SetScorer(/*model_version=*/1); |
| ASSERT_TRUE(classifier_->is_ready()); |
| |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| GURL url("http://host.test"); |
| LoadHTMLWithUrlOverride("<html><body>phish</body></html>", |
| url.spec().c_str()); |
| Mock::VerifyAndClearExpectations(classifier_); |
| OnStartPhishingDetection(url); |
| { |
| base::RunLoop run_loop; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop.QuitClosure())); |
| delegate_->PageCaptured(false); |
| run_loop.Run(); |
| Mock::VerifyAndClearExpectations(classifier_); |
| } |
| |
| delegate_->PageCaptured(false); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| // The delegate will cancel pending classification on destruction. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| } |
| |
| TEST_P(PhishingClassifierDelegateTest, PhishingDetectionDone) { |
| // Tests that a SafeBrowsingHostMsg_PhishingDetectionDone IPC is |
| // sent to the browser whenever we finish classification. |
| SetScorer(/*model_version=*/1); |
| ASSERT_TRUE(classifier_->is_ready()); |
| |
| // Start by loading a page to populate the delegate's state. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| GURL url("http://host.test"); |
| LoadHTMLWithUrlOverride("<html><body>phish</body></html>", |
| url.spec().c_str()); |
| Mock::VerifyAndClearExpectations(classifier_); |
| OnStartPhishingDetection(url); |
| { |
| base::RunLoop run_loop; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop.QuitClosure())); |
| delegate_->PageCaptured(false); |
| run_loop.Run(); |
| Mock::VerifyAndClearExpectations(classifier_); |
| } |
| |
| // Now run the callback to simulate the classifier finishing. |
| ClientPhishingRequest verdict; |
| verdict.set_url(url.spec()); |
| verdict.set_client_score(0.8f); |
| verdict.set_is_phishing(false); // Send IPC even if site is not phishing. |
| RunAndVerifyClassificationDone(verdict); |
| |
| // The delegate will cancel pending classification on destruction. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| } |
| |
| TEST_P(PhishingClassifierDelegateTest, ClassificationDoneWithUrlQueryMismatch) { |
| SetScorer(/*model_version=*/1); |
| ASSERT_TRUE(classifier_->is_ready()); |
| |
| GURL url("http://host.test/index.html"); |
| EXPECT_CALL(*classifier_, CancelPendingClassification()) |
| .Times(testing::AnyNumber()); |
| LoadHTMLWithUrlOverride("<html><body>phish</body></html>", |
| url.spec().c_str()); |
| Mock::VerifyAndClearExpectations(classifier_); |
| |
| bool callback_called = false; |
| StartPhishingDetectionWithCallback( |
| url, base::BindOnce( |
| [](bool* called, mojom::PhishingDetectorResult result, |
| std::optional<mojo_base::ProtoWrapper> proto) { |
| *called = true; |
| EXPECT_EQ(mojom::PhishingDetectorResult::SUCCESS, result); |
| ASSERT_TRUE(proto.has_value()); |
| auto verdict_out = proto->As<ClientPhishingRequest>(); |
| ASSERT_TRUE(verdict_out.has_value()); |
| EXPECT_EQ("http://host.test/index.html?ws=workspace", |
| verdict_out->url()); |
| }, |
| &callback_called)); |
| |
| { |
| base::RunLoop run_loop; |
| EXPECT_CALL(*classifier_, BeginClassification(_)) |
| .WillOnce(base::test::RunOnceClosure(run_loop.QuitClosure())); |
| delegate_->PageCaptured(false); |
| run_loop.Run(); |
| Mock::VerifyAndClearExpectations(classifier_); |
| } |
| |
| // Now run the callback to simulate the classifier finishing. |
| // Set verdict URL to have query parameters. |
| ClientPhishingRequest verdict; |
| verdict.set_url("http://host.test/index.html?ws=workspace"); |
| verdict.set_client_score(0.8f); |
| verdict.set_is_phishing(false); |
| |
| // This should not crash on DCHECK even though URLs differ by query. |
| RunAndVerifyClassificationDone(verdict); |
| |
| EXPECT_TRUE(callback_called); |
| |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| } |
| |
| TEST_P(PhishingClassifierDelegateTest, |
| NewPageLoadWhileBrowserRequestWaitsForLoad) { |
| base::HistogramTester histograms; |
| SetScorer(/*model_version=*/1); |
| ASSERT_TRUE(classifier_->is_ready()); |
| GURL url("http://host.test/index.html"); |
| |
| // 1. Start phishing detection (browser request). |
| OnStartPhishingDetection(url); |
| |
| // 2. Navigate away before PageCaptured is called (renderer_layout_finished_ |
| // is false). Simulate navigation to a new page. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| GURL new_url("http://host2.test"); |
| LoadHTMLWithUrlOverride("<html><body>new page</body></html>", |
| new_url.spec().c_str()); |
| |
| histograms.ExpectBucketCount( |
| "SBClientPhishing.Classifier.Event", |
| static_cast<int>(SBPhishingClassifierEvent:: |
| kNewPageLoadWhileBrowserRequestWaitsForLoad), |
| 1); |
| |
| // The delegate will cancel pending classification on destruction. |
| EXPECT_CALL(*classifier_, CancelPendingClassification()); |
| } |
| |
| INSTANTIATE_TEST_SUITE_P(All, |
| PhishingClassifierDelegateTest, |
| ::testing::Values(-1.0, 0.0, 0.5)); |
| |
| } // namespace safe_browsing |