From 42ab0d1c5966ce350a06d2cfbfd316ddbdc1e7bb Mon Sep 17 00:00:00 2001 From: Alessandro Boron Date: Tue, 14 Jan 2025 13:17:37 +1100 Subject: [PATCH] Malicious site protection navigation detection (#3707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/1206329551987282/1207151848931030 Tech Design URL: https://app.asana.com/0/1206329551987282/1207273224076495/f **Description**: This PR adds the navigation logic for detecting a malicious site and navigating to a special error page if the site is malicious. The original idea in the tech design was to intercept the Request in `decidePolicyForNavigationAction` and check whether a site is malicious cancelling the request accordingly. We noticed that the above approach increases the page load time of websites due to the logic check. I opted for an approach where in `decidePolicyForNavigationAction` we start the detection task in parallel without waiting and in `decidePolicyForNavigationResponse` we evaluate the task’s result. Another approach I thought of was to perform the logic in the background in `didStartProvisionalNavigation`. The problem with this approach is that is called only for navigation that starts from the main frame so it would not be possible to intercept malicious iFrame URLs. --- DuckDuckGo-iOS.xcodeproj/project.pbxproj | 41 ++- .../AppLifecycle/AppStateTransitions.swift | 2 +- .../{Testing.swift => AppTesting.swift} | 4 +- .../MaliciousSiteProtectionManager.swift | 34 +-- .../Model/SpecialErrorModel.swift | 6 + .../SpecialErrorPageActionHandler.swift | 19 ++ .../SpecialErrorPageContextHandling.swift | 3 + .../WebViewNavigationHandling.swift | 16 +- ...rPageNavigationHandler+MaliciousSite.swift | 121 +++++++- ...pecialErrorPageNavigationHandler+SSL.swift | 6 +- .../SpecialErrorPageNavigationHandler.swift | 145 ++++++--- DuckDuckGo/TabViewController.swift | 94 +++--- DuckDuckGoTests/DownloadMocks.swift | 3 +- ...SiteProtectionNavigationHandlerTests.swift | 220 ++++++++++++++ .../SSLErrorPageNavigationHandlerTests.swift | 3 +- ...ageNavigationHandlerIntegrationTests.swift | 15 +- ...ecialErrorPageNavigationHandlerTests.swift | 285 +++++++++++++++++- ... MockMaliciousSiteProtectionManager.swift} | 16 +- ...ciousSiteProtectionNavigationHandler.swift | 70 +++++ 19 files changed, 936 insertions(+), 167 deletions(-) rename DuckDuckGo/AppLifecycle/AppStates/{Testing.swift => AppTesting.swift} (97%) create mode 100644 DuckDuckGoTests/SpecialErrorPage/MaliciousSiteProtectionNavigationHandlerTests.swift rename DuckDuckGoTests/SpecialErrorPage/TestDoubles/{DummyMaliciousSiteProtectionNavigationHandler.swift => MockMaliciousSiteProtectionManager.swift} (59%) create mode 100644 DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockMaliciousSiteProtectionNavigationHandler.swift diff --git a/DuckDuckGo-iOS.xcodeproj/project.pbxproj b/DuckDuckGo-iOS.xcodeproj/project.pbxproj index 305f06b78b..6427a9cf18 100644 --- a/DuckDuckGo-iOS.xcodeproj/project.pbxproj +++ b/DuckDuckGo-iOS.xcodeproj/project.pbxproj @@ -812,14 +812,15 @@ 9F254ACE2CF5D3540063B308 /* SpecialErrorPageNavigationHandlerIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254ACD2CF5D3540063B308 /* SpecialErrorPageNavigationHandlerIntegrationTests.swift */; }; 9F254AD22CF5D3A80063B308 /* MockSpecialErrorWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD12CF5D3A20063B308 /* MockSpecialErrorWebView.swift */; }; 9F254AD32CF5D3A80063B308 /* MockSpecialErrorWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD12CF5D3A20063B308 /* MockSpecialErrorWebView.swift */; }; - 9F254AD52CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD42CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift */; }; - 9F254AD62CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD42CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift */; }; + 9F254AD52CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD42CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift */; }; + 9F254AD62CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD42CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift */; }; 9F254AD82CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD72CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift */; }; 9F254AD92CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD72CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift */; }; 9F254ADB2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254ADA2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift */; }; 9F254ADC2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254ADA2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift */; }; 9F254ADE2CF636CF0063B308 /* DummyWKNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254ADD2CF636CF0063B308 /* DummyWKNavigation.swift */; }; 9F254ADF2CF636CF0063B308 /* DummyWKNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254ADD2CF636CF0063B308 /* DummyWKNavigation.swift */; }; + 9F254AF12CF8D5250063B308 /* MaliciousSiteProtection in Frameworks */ = {isa = PBXBuildFile; productRef = 9F254AF02CF8D5250063B308 /* MaliciousSiteProtection */; }; 9F254AFF2CF9FA1B0063B308 /* WebViewNavigationHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AFE2CF9FA1B0063B308 /* WebViewNavigationHandling.swift */; }; 9F254B012CF9FA8D0063B308 /* SpecialErrorPageActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254B002CF9FA8D0063B308 /* SpecialErrorPageActionHandler.swift */; }; 9F254B032CF9FB2E0063B308 /* SpecialErrorPageNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254B022CF9FB2E0063B308 /* SpecialErrorPageNavigationDelegate.swift */; }; @@ -870,6 +871,9 @@ 9FB027192C26BC29009EA190 /* BrowsersComparisonModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB027182C26BC29009EA190 /* BrowsersComparisonModel.swift */; }; 9FB0271B2C2927D0009EA190 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB0271A2C2927D0009EA190 /* OnboardingView.swift */; }; 9FB0271D2C293619009EA190 /* OnboardingIntroViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB0271C2C293619009EA190 /* OnboardingIntroViewModel.swift */; }; + 9FBC76672CFE33B5008B21E7 /* MaliciousSiteProtectionNavigationHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBC76662CFE33B5008B21E7 /* MaliciousSiteProtectionNavigationHandlerTests.swift */; }; + 9FBC766A2CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBC76692CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift */; }; + 9FBC766B2CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBC76692CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift */; }; 9FCFCD802C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCFCD7F2C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift */; }; 9FCFCD812C6B020D006EB7A0 /* LaunchOptionsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCFCD7D2C6AF52A006EB7A0 /* LaunchOptionsHandler.swift */; }; 9FCFCD852C75C91A006EB7A0 /* ProgressBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCFCD842C75C91A006EB7A0 /* ProgressBarView.swift */; }; @@ -1084,7 +1088,9 @@ CBD4F13E279EBFAB00B20FD7 /* HomeMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF14FC227970072001D94D0 /* HomeMessageView.swift */; }; CBD4F13F279EBFAF00B20FD7 /* HomeMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF14FC427970AB0001D94D0 /* HomeMessageViewModel.swift */; }; CBD4F140279EBFB300B20FD7 /* SwiftUICollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB1AEFB02799AA940031AE3D /* SwiftUICollectionViewCell.swift */; }; - CBD79F4D2D130F6500DBB45A /* Testing.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBD79F4C2D130F6300DBB45A /* Testing.swift */; }; + CBD79F482D1061DA00DBB45A /* NewAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBD79F472D1061D500DBB45A /* NewAppDelegate.swift */; }; + CBD79F4A2D1061E200DBB45A /* OldAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBD79F492D1061DF00DBB45A /* OldAppDelegate.swift */; }; + CBD79F4D2D130F6500DBB45A /* AppTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBD79F4C2D130F6300DBB45A /* AppTesting.swift */; }; CBDD5DDF29A6736A00832877 /* APIHeadersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDD5DDE29A6736A00832877 /* APIHeadersTests.swift */; }; CBDD5DE129A6741300832877 /* MockBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBDD5DE029A6741300832877 /* MockBundle.swift */; }; CBECDB6F2CD3DFBE005B8B87 /* PageRefreshMonitor in Frameworks */ = {isa = PBXBuildFile; productRef = CBECDB6E2CD3DFBE005B8B87 /* PageRefreshMonitor */; }; @@ -2734,7 +2740,7 @@ 9F254ACA2CF5CDC60063B308 /* SpecialErrorPageNavigationHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageNavigationHandlerTests.swift; sourceTree = ""; }; 9F254ACD2CF5D3540063B308 /* SpecialErrorPageNavigationHandlerIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageNavigationHandlerIntegrationTests.swift; sourceTree = ""; }; 9F254AD12CF5D3A20063B308 /* MockSpecialErrorWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSpecialErrorWebView.swift; sourceTree = ""; }; - 9F254AD42CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DummyMaliciousSiteProtectionNavigationHandler.swift; sourceTree = ""; }; + 9F254AD42CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMaliciousSiteProtectionNavigationHandler.swift; sourceTree = ""; }; 9F254AD72CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSSLErrorPageNavigationHandler.swift; sourceTree = ""; }; 9F254ADA2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSpecialErrorPageNavigationDelegate.swift; sourceTree = ""; }; 9F254ADD2CF636CF0063B308 /* DummyWKNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DummyWKNavigation.swift; sourceTree = ""; }; @@ -2786,6 +2792,8 @@ 9FB027182C26BC29009EA190 /* BrowsersComparisonModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowsersComparisonModel.swift; sourceTree = ""; }; 9FB0271A2C2927D0009EA190 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 9FB0271C2C293619009EA190 /* OnboardingIntroViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingIntroViewModel.swift; sourceTree = ""; }; + 9FBC76662CFE33B5008B21E7 /* MaliciousSiteProtectionNavigationHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaliciousSiteProtectionNavigationHandlerTests.swift; sourceTree = ""; }; + 9FBC76692CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMaliciousSiteProtectionManager.swift; sourceTree = ""; }; 9FCFCD7D2C6AF52A006EB7A0 /* LaunchOptionsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LaunchOptionsHandler.swift; path = ../DuckDuckGo/LaunchOptionsHandler.swift; sourceTree = ""; }; 9FCFCD7F2C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchOptionsHandlerTests.swift; sourceTree = ""; }; 9FCFCD842C75C91A006EB7A0 /* ProgressBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBarView.swift; sourceTree = ""; }; @@ -3050,7 +3058,9 @@ CBC88EE42C8097B500F0F8C5 /* URLCredentialCreator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLCredentialCreator.swift; sourceTree = ""; }; CBC8DC252AF6D4CD00BA681A /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/InfoPlist.strings; sourceTree = ""; }; CBD4F13B279EBF4A00B20FD7 /* HomeMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeMessage.swift; sourceTree = ""; }; - CBD79F4C2D130F6300DBB45A /* Testing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Testing.swift; sourceTree = ""; }; + CBD79F472D1061D500DBB45A /* NewAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewAppDelegate.swift; sourceTree = ""; }; + CBD79F492D1061DF00DBB45A /* OldAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OldAppDelegate.swift; sourceTree = ""; }; + CBD79F4C2D130F6300DBB45A /* AppTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTesting.swift; sourceTree = ""; }; CBD7AE812AF6D5B6009052FD /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; CBDD5DDE29A6736A00832877 /* APIHeadersTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APIHeadersTests.swift; sourceTree = ""; }; CBDD5DE029A6741300832877 /* MockBundle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockBundle.swift; sourceTree = ""; }; @@ -3344,6 +3354,7 @@ 31E69A63280F4CB600478327 /* DuckUI in Frameworks */, CB941A6E2B96AB08000F9E7A /* PrivacyDashboard in Frameworks */, F42D541D29DCA40B004C4FF1 /* DesignResourcesKit in Frameworks */, + 9F254AF12CF8D5250063B308 /* MaliciousSiteProtection in Frameworks */, 1E5918472CA422A7008ED2B3 /* Navigation in Frameworks */, F4D7F634298C00C3006C3AE9 /* FindInPageIOSJSSupport in Frameworks */, 4B94B79E2D4831D90014AAB8 /* SyncUI-iOS in Frameworks */, @@ -5316,6 +5327,7 @@ CBC88EE22C7F8B1700F0F8C5 /* SSLErrorPageNavigationHandlerTests.swift */, 9F254ACA2CF5CDC60063B308 /* SpecialErrorPageNavigationHandlerTests.swift */, 9F254ACD2CF5D3540063B308 /* SpecialErrorPageNavigationHandlerIntegrationTests.swift */, + 9FBC76662CFE33B5008B21E7 /* MaliciousSiteProtectionNavigationHandlerTests.swift */, ); path = SpecialErrorPage; sourceTree = ""; @@ -5324,10 +5336,11 @@ isa = PBXGroup; children = ( 9F254AD12CF5D3A20063B308 /* MockSpecialErrorWebView.swift */, - 9F254AD42CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift */, + 9F254AD42CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift */, 9F254AD72CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift */, 9F254ADA2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift */, 9F254ADD2CF636CF0063B308 /* DummyWKNavigation.swift */, + 9FBC76692CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift */, ); path = TestDoubles; sourceTree = ""; @@ -5939,7 +5952,7 @@ CBAD0EFE2CFE1D4E006267B8 /* Suspending.swift */, CBAD0F002CFE1D54006267B8 /* Background.swift */, CBF2594F2D37ED6100AC63E4 /* Resuming.swift */, - CBD79F4C2D130F6300DBB45A /* Testing.swift */, + CBD79F4C2D130F6300DBB45A /* AppTesting.swift */, ); path = AppStates; sourceTree = ""; @@ -7232,6 +7245,7 @@ 7B2CCBA42D11ABBA00FE5852 /* VPNWidgetSupport */, 315C77812CFA41A400699683 /* AIChat */, 4B94B79D2D4831D90014AAB8 /* SyncUI-iOS */, + 9F254AF02CF8D5250063B308 /* MaliciousSiteProtection */, ); productName = DuckDuckGo; productReference = 84E341921E2F7EFB00BDBA6F /* DuckDuckGo.app */; @@ -8451,7 +8465,7 @@ 98DA6ECA2181E41F00E65433 /* ThemeManager.swift in Sources */, F1D43AFC2B99C56000BAB743 /* RootDebugViewController+VanillaBrowser.swift in Sources */, C159DF072A430B60007834BB /* EmailSignupViewController.swift in Sources */, - CBD79F4D2D130F6500DBB45A /* Testing.swift in Sources */, + CBD79F4D2D130F6500DBB45A /* AppTesting.swift in Sources */, 37A6A8FE2AFD0208008580A3 /* FaviconsFetcherOnboarding.swift in Sources */, F1CA3C3B1F045B65005FADB3 /* Authenticator.swift in Sources */, CBD4F13D279EBFA000B20FD7 /* HomeMessageCollectionViewCell.swift in Sources */, @@ -8760,7 +8774,7 @@ CBC88EE32C7F8B1700F0F8C5 /* SSLErrorPageNavigationHandlerTests.swift in Sources */, 85C11E4120904BBE00BFFEB4 /* VariantManagerTests.swift in Sources */, 563A3D032D37C363001966FD /* AppConfigurationURLProviderTests.swift in Sources */, - 9F254AD52CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift in Sources */, + 9F254AD52CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift in Sources */, F1134ECE1F40EA9C00B73467 /* AtbParserTests.swift in Sources */, F189AEE41F18FDAF001EBAE1 /* LinkTests.swift in Sources */, 6F7FB8E12C660B3E00867DA7 /* NewTabPageFavoritesModelTests.swift in Sources */, @@ -8800,6 +8814,7 @@ 9F254ADF2CF636CF0063B308 /* DummyWKNavigation.swift in Sources */, 6F7BACD42CEE084B00F561D8 /* OmniBarEqualityCheckTests.swift in Sources */, 6F7FB8E72C66197E00867DA7 /* NewTabPageSectionsSettingsModelTests.swift in Sources */, + 9FBC766A2CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift in Sources */, 851CD674244D7E6000331B98 /* UserDefaultsExtension.swift in Sources */, 569437362BE5160600C0881B /* SyncSettingsViewControllerErrorTests.swift in Sources */, EEC02C162B065BE00045CE11 /* NetworkProtectionVPNLocationViewModelTests.swift in Sources */, @@ -8878,6 +8893,7 @@ 9F4CC51F2C48D758006A96EB /* ContextualDaxDialogsFactoryTests.swift in Sources */, 8521FDE6238D414B00A44CC3 /* FileStoreTests.swift in Sources */, F14E491F1E391CE900DC037C /* URLExtensionTests.swift in Sources */, + 9FBC76672CFE33B5008B21E7 /* MaliciousSiteProtectionNavigationHandlerTests.swift in Sources */, 9F23B8062C2BE22700950875 /* OnboardingIntroViewModelTests.swift in Sources */, 9F69331D2C5A191400CD6A5D /* MockTutorialSettings.swift in Sources */, 85D2187424BF25CD004373D2 /* FaviconsTests.swift in Sources */, @@ -8991,8 +9007,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 9F254AD62CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift in Sources */, + 9F254AD62CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift in Sources */, 85F21DB0210F5E32002631A6 /* AtbIntegrationTests.swift in Sources */, + 9FBC766B2CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift in Sources */, 9F254AD22CF5D3A80063B308 /* MockSpecialErrorWebView.swift in Sources */, 9F254ADE2CF636CF0063B308 /* DummyWKNavigation.swift in Sources */, 8551912724746EDC0010FDD0 /* SnapshotHelper.swift in Sources */, @@ -12405,6 +12422,10 @@ package = 98A16C2928A11BDE00A6C003 /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; productName = Bookmarks; }; + 9F254AF02CF8D5250063B308 /* MaliciousSiteProtection */ = { + isa = XCSwiftPackageProductDependency; + productName = MaliciousSiteProtection; + }; 9F8FE9482BAE50E50071E372 /* Lottie */ = { isa = XCSwiftPackageProductDependency; package = 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */; diff --git a/DuckDuckGo/AppLifecycle/AppStateTransitions.swift b/DuckDuckGo/AppLifecycle/AppStateTransitions.swift index 709a1638c6..53edb54426 100644 --- a/DuckDuckGo/AppLifecycle/AppStateTransitions.swift +++ b/DuckDuckGo/AppLifecycle/AppStateTransitions.swift @@ -92,7 +92,7 @@ extension Resuming { } -extension Testing { +extension AppTesting { func apply(event: AppEvent) -> any AppState { self } diff --git a/DuckDuckGo/AppLifecycle/AppStates/Testing.swift b/DuckDuckGo/AppLifecycle/AppStates/AppTesting.swift similarity index 97% rename from DuckDuckGo/AppLifecycle/AppStates/Testing.swift rename to DuckDuckGo/AppLifecycle/AppStates/AppTesting.swift index e9cdd258a2..df3f6224c7 100644 --- a/DuckDuckGo/AppLifecycle/AppStates/Testing.swift +++ b/DuckDuckGo/AppLifecycle/AppStates/AppTesting.swift @@ -1,5 +1,5 @@ // -// Testing.swift +// AppTesting.swift // DuckDuckGo // // Copyright © 2024 DuckDuckGo. All rights reserved. @@ -21,7 +21,7 @@ import Core import UIKit @MainActor -struct Testing: AppState { +struct AppTesting: AppState { init(application: UIApplication) { Pixel.isDryRun = true diff --git a/DuckDuckGo/MaliciousSiteProtection/MaliciousSiteProtectionManager.swift b/DuckDuckGo/MaliciousSiteProtection/MaliciousSiteProtectionManager.swift index 5175a16879..46dab041d0 100644 --- a/DuckDuckGo/MaliciousSiteProtection/MaliciousSiteProtectionManager.swift +++ b/DuckDuckGo/MaliciousSiteProtection/MaliciousSiteProtectionManager.swift @@ -18,38 +18,20 @@ // import Foundation +import MaliciousSiteProtection final class MaliciousSiteProtectionManager: MaliciousSiteDetecting { func evaluate(_ url: URL) async -> ThreatKind? { try? await Task.sleep(interval: 0.3) - return .none - } - -} - -// MARK: - To Remove - -// These entities are copied from BSK and they will be used to mock the library -import SpecialErrorPages - -protocol MaliciousSiteDetecting { - func evaluate(_ url: URL) async -> ThreatKind? -} - -public enum ThreatKind: String, CaseIterable, CustomStringConvertible { - public var description: String { rawValue } - - case phishing - case malware -} - -public extension ThreatKind { - var errorPageType: SpecialErrorKind { - switch self { - case .malware: .phishing // WIP in BSK - case .phishing: .phishing + switch url.absoluteString { + case "http://privacy-test-pages.site/security/badware/phishing.html": + return .phishing + case "http://privacy-test-pages.site/security/badware/malware.html": + return .malware + default: + return .none } } diff --git a/DuckDuckGo/SpecialErrorPage/Model/SpecialErrorModel.swift b/DuckDuckGo/SpecialErrorPage/Model/SpecialErrorModel.swift index cd4b3b3411..8e1f344fcb 100644 --- a/DuckDuckGo/SpecialErrorPage/Model/SpecialErrorModel.swift +++ b/DuckDuckGo/SpecialErrorPage/Model/SpecialErrorModel.swift @@ -19,6 +19,7 @@ import Foundation import SpecialErrorPages +import WebKit struct SpecialErrorModel: Equatable { let url: URL @@ -29,3 +30,8 @@ struct SSLSpecialError { let type: SSLErrorType let error: SpecialErrorModel } + +struct MaliciousSiteDetectionNavigationResponse: Equatable { + let navigationAction: WKNavigationAction + let errorData: SpecialErrorData +} diff --git a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageActionHandler.swift b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageActionHandler.swift index 2d12540cde..6008393fa1 100644 --- a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageActionHandler.swift +++ b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageActionHandler.swift @@ -18,6 +18,7 @@ // import Foundation +import SpecialErrorPages /// A type that defines actions for handling special error pages. /// @@ -26,11 +27,29 @@ import Foundation /// advanced information related to the error. protocol SpecialErrorPageActionHandler { /// Handles the action of navigating to the site associated with the error page + /// - Parameters: + /// - url: The URL that the user wants to visit. + /// - errorData: The special error information. + @MainActor + func visitSite(url: URL, errorData: SpecialErrorData) + + /// Handles the action of navigating to the site associated with the error page + @MainActor func visitSite() /// Handles the action of leaving the site associated with the error page + @MainActor func leaveSite() /// Handles the action of requesting more detailed information about the error + @MainActor func advancedInfoPresented() } + +extension SpecialErrorPageActionHandler { + + func visitSite(url: URL, errorData: SpecialErrorData) { } + + func visitSite() { } + +} diff --git a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageContextHandling.swift b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageContextHandling.swift index 0c3ce79206..6719cb0876 100644 --- a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageContextHandling.swift +++ b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageContextHandling.swift @@ -32,6 +32,9 @@ protocol SpecialErrorPageContextHandling: AnyObject { /// The URL that failed to load, if any. var failedURL: URL? { get } + /// A boolean value indicating whether the WebView request requires showing a special error page. + var isSpecialErrorPageRequest: Bool { get } + /// Attaches a web view to the special error page handling. func attachWebView(_ webView: WKWebView) diff --git a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/WebViewNavigationHandling.swift b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/WebViewNavigationHandling.swift index 31cb18a9bf..0c8b6ea7c0 100644 --- a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/WebViewNavigationHandling.swift +++ b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/WebViewNavigationHandling.swift @@ -19,6 +19,7 @@ import Foundation import WebKit +import MaliciousSiteProtection // MARK: - WebViewNavigation @@ -41,8 +42,16 @@ protocol WebViewNavigationHandling: AnyObject { /// - Parameters: /// - navigationAction: Details about the action that triggered the navigation request. /// - webView: The web view from which the navigation request began. - /// - Returns: A Boolean value that indicates whether the navigation action was handled. - func handleSpecialErrorNavigation(navigationAction: WKNavigationAction, webView: WKWebView) async -> Bool + @MainActor + func handleDecidePolicy(for navigationAction: WKNavigationAction, webView: WKWebView) + + /// Decides whether to to navigate to new content after the response to the navigation request is known or cancel the navigation and show a special error page based on the specified action information. + /// - Parameters: + /// - navigationResponse: Descriptive information about the navigation response. + /// - webView: The web view from which the navigation request began. + /// - Returns: A Boolean value that indicates whether to cancel or allow the navigation. + @MainActor + func handleDecidePolicy(for navigationResponse: WKNavigationResponse, webView: WKWebView) async -> Bool /// Handles authentication challenges received by the web view. /// @@ -50,6 +59,7 @@ protocol WebViewNavigationHandling: AnyObject { /// - webView: The web view that receives the authentication challenge. /// - challenge: The authentication challenge. /// - completionHandler: A completion handler block to execute with the response. + @MainActor func handleWebView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) /// Handles failures during provisional navigation. @@ -58,6 +68,7 @@ protocol WebViewNavigationHandling: AnyObject { /// - webView: The `WKWebView` instance that failed the navigation. /// - navigation: The navigation object for the operation. /// - error: The error that occurred. + @MainActor func handleWebView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WebViewNavigation, withError error: NSError) /// Handles the successful completion of a navigation in the web view. @@ -65,5 +76,6 @@ protocol WebViewNavigationHandling: AnyObject { /// - Parameters: /// - webView: The web view that loaded the content. /// - navigation: The navigation object that finished. + @MainActor func handleWebView(_ webView: WKWebView, didFinish navigation: WebViewNavigation) } diff --git a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+MaliciousSite.swift b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+MaliciousSite.swift index e49ea0e215..b63914ef26 100644 --- a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+MaliciousSite.swift +++ b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+MaliciousSite.swift @@ -22,26 +22,49 @@ import BrowserServicesKit import Core import SpecialErrorPages import WebKit +import MaliciousSiteProtection enum MaliciousSiteProtectionNavigationResult: Equatable { - case navigationHandled(SpecialErrorModel) + case navigationHandled(NavigationType) case navigationNotHandled + + enum NavigationType: Equatable { + case mainFrame(MaliciousSiteDetectionNavigationResponse) + case iFrame(maliciousURL: URL, error: SpecialErrorData) + } } protocol MaliciousSiteProtectionNavigationHandling: AnyObject { - /// Decides whether to cancel navigation to prevent opening the YouTube app from the web view. + /// Creates a task for detecting malicious sites based on the provided navigation action. /// /// - Parameters: - /// - navigationAction: The navigation action to evaluate. - /// - webView: The web view where navigation is occurring. - /// - Returns: `true` if the navigation should be canceled, `false` otherwise. - func handleMaliciousSiteProtectionNavigation(for navigationAction: WKNavigationAction, webView: WKWebView) async -> MaliciousSiteProtectionNavigationResult + /// - navigationAction: The `WKNavigationAction` object that contains information about + /// the navigation event. + /// - webView: The web view from which the navigation request began. + @MainActor + func makeMaliciousSiteDetectionTask(for navigationAction: WKNavigationAction, webView: WKWebView) + + /// Retrieves a task for detecting malicious sites based on the provided navigation response. + /// + /// - Parameters: + /// - navigationResponse: The `WKNavigationResponse` object that contains information about + /// the navigation event. + /// - webView: The web view from which the navigation request began. + /// - Returns: A `Task?` that represents the + /// asynchronous operation for detecting malicious sites. If the task cannot be found, + /// the function returns `nil`. + @MainActor + func getMaliciousSiteDectionTask(for navigationResponse: WKNavigationResponse, webView: WKWebView) -> Task? } final class MaliciousSiteProtectionNavigationHandler { private let maliciousSiteProtectionManager: MaliciousSiteDetecting private let storageCache: StorageCache + @MainActor private(set) var maliciousURLExemptions: [URL: ThreatKind] = [:] + @MainActor private(set) var bypassedMaliciousSiteThreatKind: ThreatKind? + @MainActor private(set) var maliciousSiteDetectionTasks: [URL: Task] = [:] + init( maliciousSiteProtectionManager: MaliciousSiteDetecting = MaliciousSiteProtectionManager(), storageCache: StorageCache = AppDependencyProvider.shared.storageCache @@ -56,10 +79,50 @@ final class MaliciousSiteProtectionNavigationHandler { extension MaliciousSiteProtectionNavigationHandler: MaliciousSiteProtectionNavigationHandling { @MainActor - func handleMaliciousSiteProtectionNavigation(for navigationAction: WKNavigationAction, webView: WKWebView) async -> MaliciousSiteProtectionNavigationResult { - // Implement logic to use `maliciousSiteProtectionManager.evaluate(url)` - // Return navigationNotHandled for the time being - return .navigationNotHandled + func makeMaliciousSiteDetectionTask(for navigationAction: WKNavigationAction, webView: WKWebView) { + + guard + let url = navigationAction.request.url, + url != URL(string: "about:blank") + else { + return + } + + handleMaliciousExemptions(for: navigationAction.navigationType, url: url) + + guard !shouldBypassMaliciousSiteProtection(for: url) else { + return + } + + let threatDetectionTask: Task = Task { + guard let threatKind = await maliciousSiteProtectionManager.evaluate(url) else { + return .navigationNotHandled + } + + if navigationAction.isTargetingMainFrame { + let errorData = SpecialErrorData.maliciousSite(kind: threatKind, url: url) + let response = MaliciousSiteDetectionNavigationResponse(navigationAction: navigationAction, errorData: errorData) + return .navigationHandled(.mainFrame(response)) + } else { + // Extract the URL of the source frame (the iframe) that initiated the navigation action + let iFrameTopURL = navigationAction.sourceFrame.safeRequest?.url ?? url + let errorData = SpecialErrorData.maliciousSite(kind: threatKind, url: iFrameTopURL) + return .navigationHandled(.iFrame(maliciousURL: url, error: errorData)) + } + } + + maliciousSiteDetectionTasks[url] = threatDetectionTask + } + + @MainActor + func getMaliciousSiteDectionTask(for navigationResponse: WKNavigationResponse, webView: WKWebView) -> Task? { + + guard let url = navigationResponse.response.url else { + assertionFailure("Could not find Malicious Site Detection Task for URL") + return nil + } + + return maliciousSiteDetectionTasks.removeValue(forKey: url) } } @@ -68,7 +131,10 @@ extension MaliciousSiteProtectionNavigationHandler: MaliciousSiteProtectionNavig extension MaliciousSiteProtectionNavigationHandler: SpecialErrorPageActionHandler { - func visitSite() { + func visitSite(url: URL, errorData: SpecialErrorData) { + maliciousURLExemptions[url] = errorData.threatKind + bypassedMaliciousSiteThreatKind = errorData.threatKind + // Fire Pixel } @@ -81,3 +147,36 @@ extension MaliciousSiteProtectionNavigationHandler: SpecialErrorPageActionHandle } } + +// MARK: - Private + +private extension MaliciousSiteProtectionNavigationHandler { + + @MainActor + func handleMaliciousExemptions(for navigationType: WKNavigationType, url: URL) { + // TODO: check storing redirects + // Re-set the flag every time we load a web page + bypassedMaliciousSiteThreatKind = maliciousURLExemptions[url] + } + + @MainActor + func shouldBypassMaliciousSiteProtection(for url: URL) -> Bool { + bypassedMaliciousSiteThreatKind != .none || url.isDuckDuckGo || url.isDuckURLScheme + } + +} + +// MARK: - Helpers + +private extension SpecialErrorData { + + var threatKind: ThreatKind? { + switch self { + case .ssl: + return nil + case let .maliciousSite(threatKind, _): + return threatKind + } + } + +} diff --git a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+SSL.swift b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+SSL.swift index 10d5b71f3a..101558af20 100644 --- a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+SSL.swift +++ b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+SSL.swift @@ -71,7 +71,11 @@ extension SSLErrorPageNavigationHandler: SSLSpecialErrorPageNavigationHandling { return nil } - let errorData = SpecialErrorData.ssl(type: errorType, domain: host, eTldPlus1: storageCache.tld.eTLDplus1(host)) + let errorData = SpecialErrorData.ssl( + type: errorType, + domain: host, + eTldPlus1: storageCache.tld.eTLDplus1(host) + ) return SSLSpecialError(type: errorType, error: SpecialErrorModel(url: failedURL, errorData: errorData)) } diff --git a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler.swift b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler.swift index 57422c2610..b7f05e26c3 100644 --- a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler.swift +++ b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler.swift @@ -21,18 +21,20 @@ import Foundation import WebKit import SpecialErrorPages import Core +import MaliciousSiteProtection typealias SpecialErrorPageManaging = SpecialErrorPageContextHandling & WebViewNavigationHandling & SpecialErrorPageUserScriptDelegate final class SpecialErrorPageNavigationHandler: SpecialErrorPageContextHandling { private var webView: WKWebView? - private(set) var errorData: SpecialErrorData? - private var errorPageType: SpecialErrorKind? - private(set) var isSpecialErrorPageVisible = false - private(set) var failedURL: URL? private weak var userScript: SpecialErrorPageUserScript? weak var delegate: SpecialErrorPageNavigationDelegate? + @MainActor private(set) var errorData: SpecialErrorData? + @MainActor private(set) var isSpecialErrorPageVisible = false + @MainActor private(set) var failedURL: URL? + @MainActor private(set) var isSpecialErrorPageRequest = false + private let sslErrorPageNavigationHandler: SSLSpecialErrorPageNavigationHandling & SpecialErrorPageActionHandler private let maliciousSiteProtectionNavigationHandler: MaliciousSiteProtectionNavigationHandling & SpecialErrorPageActionHandler @@ -58,41 +60,61 @@ final class SpecialErrorPageNavigationHandler: SpecialErrorPageContextHandling { extension SpecialErrorPageNavigationHandler: WebViewNavigationHandling { - func handleSpecialErrorNavigation(navigationAction: WKNavigationAction, webView: WKWebView) async -> Bool { - let result = await maliciousSiteProtectionNavigationHandler.handleMaliciousSiteProtectionNavigation(for: navigationAction, webView: webView) - - return await MainActor.run { - switch result { - case let .navigationHandled(model): - var request = navigationAction.request - request.url = model.url - failedURL = model.url - errorData = model.errorData - errorPageType = .phishing - loadSpecialErrorPage(request: request) - return true - case .navigationNotHandled: - return false - } + @MainActor + func handleDecidePolicy(for navigationAction: WKNavigationAction, webView: WKWebView) { + maliciousSiteProtectionNavigationHandler.makeMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + } + + @MainActor + func handleDecidePolicy(for navigationResponse: WKNavigationResponse, webView: WKWebView) async -> Bool { + guard let task = maliciousSiteProtectionNavigationHandler.getMaliciousSiteDectionTask(for: navigationResponse, webView: webView) else { + return false + } + + let result = await task.value + + switch result { + case let .navigationHandled(.mainFrame(response)): + // Re-use the same request to avoid that the new sideload request is intercepted and cancelled + // due to parameters added to the header. + var request = response.navigationAction.request + request.url = response.errorData.url + isSpecialErrorPageRequest = true + failedURL = response.errorData.url + errorData = response.errorData + loadSpecialErrorPage(request: request) + return true + case let .navigationHandled(.iFrame(maliciousURL, error)): + isSpecialErrorPageRequest = true + failedURL = maliciousURL + errorData = error + loadSpecialErrorPage(url: maliciousURL) + return true + case .navigationNotHandled: + isSpecialErrorPageRequest = false + return false } } - + + @MainActor func handleWebView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust else { return } sslErrorPageNavigationHandler.handleServerTrustChallenge(challenge, completionHandler: completionHandler) } - + + @MainActor func handleWebView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WebViewNavigation, withError error: NSError) { guard let sslSpecialError = sslErrorPageNavigationHandler.makeNewRequestURLAndSpecialErrorDataIfEnabled(error: error) else { return } failedURL = sslSpecialError.error.url sslErrorPageNavigationHandler.errorPageVisited(errorType: sslSpecialError.type) errorData = sslSpecialError.error.errorData - errorPageType = .ssl loadSpecialErrorPage(url: sslSpecialError.error.url) } - + + @MainActor func handleWebView(_ webView: WKWebView, didFinish navigation: WebViewNavigation) { + isSpecialErrorPageRequest = false userScript?.isEnabled = webView.url == failedURL if webView.url != failedURL { isSpecialErrorPageVisible = false @@ -105,45 +127,59 @@ extension SpecialErrorPageNavigationHandler: WebViewNavigationHandling { extension SpecialErrorPageNavigationHandler: SpecialErrorPageUserScriptDelegate { + @MainActor func leaveSiteAction() { - switch errorPageType { - case .ssl: - sslErrorPageNavigationHandler.leaveSite() - case .phishing: - maliciousSiteProtectionNavigationHandler.leaveSite() - default: - break + + func navigateBackIfPossible() { + if webView?.canGoBack == true { + _ = webView?.goBack() + } else { + closeTab() + } } - if webView?.canGoBack == true { - _ = webView?.goBack() - } else { + func closeTab() { delegate?.closeSpecialErrorPageTab() } + + guard let errorData else { return } + + switch errorData { + case .ssl: + sslErrorPageNavigationHandler.leaveSite() + navigateBackIfPossible() + case .maliciousSite: + maliciousSiteProtectionNavigationHandler.leaveSite() + closeTab() + } } + @MainActor func visitSiteAction() { - switch errorPageType { + defer { + isSpecialErrorPageVisible = false + _ = webView?.reload() + } + + guard let errorData, let url = webView?.url else { return } + + switch errorData { case .ssl: sslErrorPageNavigationHandler.visitSite() - case .phishing: - maliciousSiteProtectionNavigationHandler.visitSite() - default: - break + case .maliciousSite: + maliciousSiteProtectionNavigationHandler.visitSite(url: url, errorData: errorData) } - - isSpecialErrorPageVisible = false - _ = webView?.reload() } + @MainActor func advancedInfoPresented() { - switch errorPageType { + guard let errorData else { return } + + switch errorData { case .ssl: sslErrorPageNavigationHandler.advancedInfoPresented() - case .phishing: + case .maliciousSite: maliciousSiteProtectionNavigationHandler.advancedInfoPresented() - default: - break } } } @@ -152,10 +188,12 @@ extension SpecialErrorPageNavigationHandler: SpecialErrorPageUserScriptDelegate private extension SpecialErrorPageNavigationHandler { + @MainActor func loadSpecialErrorPage(url: URL) { loadSpecialErrorPage(request: URLRequest(url: url)) } + @MainActor func loadSpecialErrorPage(request: URLRequest) { let html = SpecialErrorPageHTMLTemplate.htmlFromTemplate webView?.loadSimulatedRequest(request, responseHTML: html) @@ -163,3 +201,18 @@ private extension SpecialErrorPageNavigationHandler { } } + +// MARK: - Helpers + +private extension SpecialErrorData { + + var url: URL? { + switch self { + case .ssl: + return nil + case let .maliciousSite(_, url): + return url + } + } + +} diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index d4663d0f80..2cc4d517b8 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -1356,73 +1356,82 @@ extension TabViewController: WKNavigationDelegate { duckPlayerNavigationHandler?.handleDidStartLoading(webView: webView) } - func webView(_ webView: WKWebView, - decidePolicyFor navigationResponse: WKNavigationResponse, - decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy { + let httpResponse = navigationResponse.response as? HTTPURLResponse + let didMarkAsInternal = internalUserDecider.markUserAsInternalIfNeeded(forUrl: webView.url, response: httpResponse) + if didMarkAsInternal { + Pixel.fire(pixel: .featureFlaggingInternalUserAuthenticated) + NotificationCenter.default.post(Notification(name: AppUserDefaults.Notifications.didVerifyInternalUser)) + } + // If the navigation has been handled by the special error page handler, cancel navigating to the new content as the special error page will be shown. + if !specialErrorPageNavigationHandler.isSpecialErrorPageRequest, await specialErrorPageNavigationHandler.handleDecidePolicy(for: navigationResponse, webView: webView) { + return .cancel + } else { + return await handleNavigationResponse(navigationResponse) + } + } + + private func handleNavigationResponse(_ navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy { + let httpResponse = navigationResponse.response as? HTTPURLResponse let mimeType = MIMEType(from: navigationResponse.response.mimeType, fileExtension: navigationResponse.response.url?.pathExtension) let urlSchemeType = navigationResponse.response.url.map { SchemeHandler.schemeType(for: $0) } ?? .unknown let urlNavigationalScheme = navigationResponse.response.url?.scheme.map { URL.NavigationalScheme(rawValue: $0) } - let httpResponse = navigationResponse.response as? HTTPURLResponse let isSuccessfulResponse = httpResponse?.isSuccessfulResponse ?? false lastHttpStatusCode = httpResponse?.statusCode - let didMarkAsInternal = internalUserDecider.markUserAsInternalIfNeeded(forUrl: webView.url, response: httpResponse) - if didMarkAsInternal { - Pixel.fire(pixel: .featureFlaggingInternalUserAuthenticated) - NotificationCenter.default.post(Notification(name: AppUserDefaults.Notifications.didVerifyInternalUser)) - } - // Important: Order of these checks matter! if urlSchemeType == .blob { // 1. To properly handle BLOB we need to trigger its download, if temporaryDownloadForPreviewedFile is set we allow its load in the web view if let temporaryDownloadForPreviewedFile, temporaryDownloadForPreviewedFile.url == navigationResponse.response.url { // BLOB already has a temporary downloaded so and we can allow loading it blobDownloadTargetFrame = nil - decisionHandler(.allow) + return .allow } else { // First we need to trigger download to handle it then in webView:navigationAction:didBecomeDownload - decisionHandler(.download) + return .download } } else if FilePreviewHelper.canAutoPreviewMIMEType(mimeType) { // 2. For this MIME type we are able to provide a better custom preview via FilePreviewHelper so it takes priority - let download = self.startDownload(with: navigationResponse, decisionHandler: decisionHandler) + let (policy, download) = await startDownload(with: navigationResponse) mostRecentAutoPreviewDownloadID = download?.id Pixel.fire(pixel: .downloadStarted, withAdditionalParameters: [PixelParameters.canAutoPreviewMIMEType: "1"]) + return policy } else if shouldTriggerDownloadAction(for: navigationResponse), let downloadMetadata = AppDependencyProvider.shared.downloadManager.downloadMetaData(for: navigationResponse.response) { // 3a. We know it is a download, but allow WebKit handle the "data" scheme natively if urlNavigationalScheme == .data { - decisionHandler(.download) - return + return .download } // 3b. We know the response should trigger the file download prompt - self.presentSaveToDownloadsAlert(with: downloadMetadata) { - self.startDownload(with: navigationResponse, decisionHandler: decisionHandler) - } cancelHandler: { - decisionHandler(.cancel) + switch await presentSaveToDownloadsAlert(with: downloadMetadata) { + case .success: + let (policy, _) = await startDownload(with: navigationResponse) + return policy + case .cancelled: + return .cancel } } else if navigationResponse.canShowMIMEType { // 4. WebView can preview the MIME type and it is not to be handled by our custom FilePreviewHelper url = webView.url if navigationResponse.isForMainFrame, let decision = setupOrClearTemporaryDownload(for: navigationResponse.response) { // Loading a file preview in web view - decisionHandler(decision) + return decision } else { // Loading HTML if navigationResponse.isForMainFrame && isSuccessfulResponse { adClickAttributionDetection.on2XXResponse(url: url) } - adClickAttributionLogic.onProvisionalNavigation { - decisionHandler(.allow) - } + await adClickAttributionLogic.onProvisionalNavigation() + + return .allow } } else { // Fallback - decisionHandler(.allow) + return .allow } } @@ -1865,6 +1874,11 @@ extension TabViewController: WKNavigationDelegate { self.delegate?.closeFindInPage(tab: self) } + // If navigating to the URL is allowed and the URL request is not sideloaded ask the specialErrorPageNavigationHandler forward the event to + // the SpecialErrorPageNavigationHandler. + if let self, decision == .allow, !self.specialErrorPageNavigationHandler.isSpecialErrorPageRequest { + self.specialErrorPageNavigationHandler.handleDecidePolicy(for: navigationAction, webView: webView) + } decisionHandler(decision) } } @@ -2151,26 +2165,19 @@ extension TabViewController { completion(.allow) } - @discardableResult - private func startDownload(with navigationResponse: WKNavigationResponse, - decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) -> Download? { + private func startDownload(with navigationResponse: WKNavigationResponse) async -> (responsePolicy: WKNavigationResponsePolicy, download: Download?) { let downloadManager = AppDependencyProvider.shared.downloadManager let cookieStore = webView.configuration.websiteDataStore.httpCookieStore let url = navigationResponse.response.url! if case .blob = SchemeHandler.schemeType(for: url) { - decisionHandler(.download) - - return nil + return (.download, nil) } else if let download = downloadManager.makeDownload(navigationResponse: navigationResponse, cookieStore: cookieStore) { downloadManager.startDownload(download) - decisionHandler(.cancel) - - return download + return (.cancel, download) } - decisionHandler(.cancel) - return nil + return (.cancel, nil) } /** @@ -2301,6 +2308,23 @@ extension TabViewController { } } + enum SaveToDownloadsResult { + case success + case cancelled + } + + private func presentSaveToDownloadsAlert(with downloadMetadata: DownloadMetadata) async -> SaveToDownloadsResult { + await withCheckedContinuation { continuation in + presentSaveToDownloadsAlert( + with: downloadMetadata, + saveToDownloadsHandler: { + continuation.resume(returning: .success) + }, cancelHandler: { + continuation.resume(returning: .cancelled) + } + ) + } + } private func registerForDownloadsNotifications() { NotificationCenter.default.addObserver(self, diff --git a/DuckDuckGoTests/DownloadMocks.swift b/DuckDuckGoTests/DownloadMocks.swift index 1601392430..9b7267b6e3 100644 --- a/DuckDuckGoTests/DownloadMocks.swift +++ b/DuckDuckGoTests/DownloadMocks.swift @@ -51,11 +51,12 @@ class MockDownloadSession: DownloadSession { } class MockNavigationResponse: WKNavigationResponse { + var url = URL(string: "https://www.duck.com")! var suggestedFileName: String? var mimeType: String? override var response: URLResponse { - let response = MockURLResponse(url: URL(string: "https://www.duck.com")!, + let response = MockURLResponse(url: url, mimeType: mimeType!, expectedContentLength: 1234, textEncodingName: "") diff --git a/DuckDuckGoTests/SpecialErrorPage/MaliciousSiteProtectionNavigationHandlerTests.swift b/DuckDuckGoTests/SpecialErrorPage/MaliciousSiteProtectionNavigationHandlerTests.swift new file mode 100644 index 0000000000..b888d3f7f3 --- /dev/null +++ b/DuckDuckGoTests/SpecialErrorPage/MaliciousSiteProtectionNavigationHandlerTests.swift @@ -0,0 +1,220 @@ +// +// MaliciousSiteProtectionNavigationHandlerTests.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Testing +import WebKit +import SpecialErrorPages +import MaliciousSiteProtection +@testable import DuckDuckGo + +@Suite("Special Error Pages - Malicious Site Protection Navigation Handler Unit Tests", .serialized) +struct MaliciousSiteProtectionNavigationHandlerTests { + private var sut: MaliciousSiteProtectionNavigationHandler! + private var mockMaliciousSiteProtectionManager: MockMaliciousSiteProtectionManager! + private var webView: MockWebView! + + @MainActor + init() { + webView = MockWebView() + mockMaliciousSiteProtectionManager = MockMaliciousSiteProtectionManager() + sut = MaliciousSiteProtectionNavigationHandler(maliciousSiteProtectionManager: mockMaliciousSiteProtectionManager) + } + + @MainActor + @Test( + "URLs that should not be handled do not create a Malicious Detection Task", + arguments: [ + "about:blank", + "https://duckduckgo.com?q=swift-testing", + "duck://player" + ] + ) + func unhandledURLTypes(path: String) async throws { + // GIVEN + let url = try #require(URL(string: path)) + let navigationAction = MockNavigationAction(request: URLRequest(url: url)) + + // WHEN + sut.makeMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + + // THEN + #expect(sut.maliciousSiteDetectionTasks[url] == nil) + } + + @MainActor + @Test("Non Bypassed Malicious Site creates a Malicious Detection Task", arguments: [ThreatKind.phishing, .malware]) + func whenBypassedMaliciousSiteThreatKindIsNotSetThenReturnNavigationHandled(threat: ThreatKind) throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + mockMaliciousSiteProtectionManager.threatKind = threat + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: true)) + + // WHEN + sut.makeMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + + // THEN + #expect(sut.maliciousSiteDetectionTasks[url] != nil) + } + + @MainActor + @Test( + "Bypassed Malicious Site does not create a Malicious Detection Task", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenBypassedMaliciousSiteThreatKindIsSetThenReturnNavigationNotHandled(threat: ThreatKind) throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + mockMaliciousSiteProtectionManager.threatKind = threat + sut.visitSite(url: url, errorData: .maliciousSite(kind: threat, url: url)) + let navigationAction = MockNavigationAction(request: URLRequest(url: url)) + + // WHEN + sut.makeMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + + // THEN + #expect(sut.maliciousSiteDetectionTasks[url] == nil) + } + + @MainActor + @Test("Retrieving Malicious Site Detection Task Nullifies it") + func whenHandleDecidePolicyForNavigationResponse_AndTaskIsNotNil_ReturnTaskAndRemoveItFromTheDictionary() throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let navigationAction = MockNavigationAction(request: URLRequest(url: url)) + sut.makeMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + let navigationResponse = MockNavigationResponse.with(url: url) + #expect(sut.maliciousSiteDetectionTasks[url] != nil) + + // WHEN + _ = try #require(sut.getMaliciousSiteDectionTask(for: navigationResponse, webView: webView)) + + // THEN + #expect(sut.maliciousSiteDetectionTasks[url] == nil) + } + + @MainActor + @Test("Do not handle navigation when Threat is nil") + func whenThreatKindIsNilThenReturnNavigationNotHandled() async throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let navigationAction = MockNavigationAction(request: URLRequest(url: url)) + mockMaliciousSiteProtectionManager.threatKind = nil + sut.makeMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + let navigationResponse = MockNavigationResponse.with(url: url) + + // WHEN + let result = try #require(sut.getMaliciousSiteDectionTask(for: navigationResponse, webView: webView)) + + // THEN + #expect(await result.value == .navigationNotHandled) + } + + @MainActor + @Test( + "Handle known threat in Main Frame", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenThreatKindIsNotNil_AndNavigationIsMainFrame_ThenReturnNavigationHandledMainFrame(threat: ThreatKind) async throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: true)) + mockMaliciousSiteProtectionManager.threatKind = threat + sut.makeMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + let navigationResponse = MockNavigationResponse.with(url: url) + + // WHEN + let result = try #require(sut.getMaliciousSiteDectionTask(for: navigationResponse, webView: webView)) + + // THEN + #expect(await result.value == .navigationHandled(.mainFrame(MaliciousSiteDetectionNavigationResponse(navigationAction: navigationAction, errorData: .maliciousSite(kind: threat, url: url))))) + } + + @MainActor + @Test( + "Handle known threat in IFrame", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenThreatKindIsNotNil_AndNavigationIsIFrame_ThenReturnNavigationHandledIFrame(threat: ThreatKind) async throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: false)) + mockMaliciousSiteProtectionManager.threatKind = threat + sut.makeMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + let navigationResponse = MockNavigationResponse.with(url: url) + + // WHEN + let result = try #require(sut.getMaliciousSiteDectionTask(for: navigationResponse, webView: webView)) + + // THEN + #expect(await result.value == .navigationHandled(.iFrame(maliciousURL: url, error: .maliciousSite(kind: threat, url: url)))) + } + + @MainActor + @Test( + "Visit Site sets Exemption URL and Threat Kind", + arguments: [ + ThreatKind.phishing, .malware + ] + ) + func whenVisitSiteActionThenSetExemptionURLAndThreatKind(threat: ThreatKind) throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let errorData = SpecialErrorData.maliciousSite(kind: threat, url: url) + #expect(sut.maliciousURLExemptions.isEmpty) + #expect(sut.bypassedMaliciousSiteThreatKind == nil) + + // WHEN + sut.visitSite(url: url, errorData: errorData) + + // THEN + #expect(sut.maliciousURLExemptions[url] == threat) + #expect(sut.bypassedMaliciousSiteThreatKind == threat) + } + + @Test("Leave Site Pixel", .disabled("Will be implmented in upcoming PR")) + func whenLeaveSiteActionThenFirePixel() throws { + + } + + @Test("Advanced Site Info Pixel", .disabled("Will be implmented in upcoming PR")) + func whenAdvancedSiteInfoActionThenFirePixel() throws { + + } + +} + +extension MockNavigationResponse { + + static func with(url: URL) -> MockNavigationResponse { + let response = MockNavigationResponse() + response.url = url + response.mimeType = "text/html" + return response + } + +} diff --git a/DuckDuckGoTests/SpecialErrorPage/SSLErrorPageNavigationHandlerTests.swift b/DuckDuckGoTests/SpecialErrorPage/SSLErrorPageNavigationHandlerTests.swift index d7b7ce923b..d03b1a8afc 100644 --- a/DuckDuckGoTests/SpecialErrorPage/SSLErrorPageNavigationHandlerTests.swift +++ b/DuckDuckGoTests/SpecialErrorPage/SSLErrorPageNavigationHandlerTests.swift @@ -131,8 +131,9 @@ final class SSLSpecialErrorPageTests { #expect(expectedCredential == nil) } + @MainActor @Test - func whenDidReceiveChallengeIfChallengeForCertificateValidationAndUserRequestBypassThenReturnsCredentials() { + func whenDidReceiveChallengeIfChallengeForCertificateValidationAndUserRequestBypassThenReturnsCredentials() throws { // GIVEN let protectionSpace = URLProtectionSpace(host: "", port: 4, protocol: nil, realm: nil, authenticationMethod: NSURLAuthenticationMethodServerTrust) let challenge = URLAuthenticationChallenge(protectionSpace: protectionSpace, proposedCredential: nil, previousFailureCount: 0, failureResponse: nil, error: nil, sender: ChallengeSender()) diff --git a/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerIntegrationTests.swift b/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerIntegrationTests.swift index 415386e733..f655b398bb 100644 --- a/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerIntegrationTests.swift +++ b/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerIntegrationTests.swift @@ -36,7 +36,7 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { sslErrorPageNavigationHandler = SSLErrorPageNavigationHandler(featureFlagger: featureFlagger) sut = SpecialErrorPageNavigationHandler( sslErrorPageNavigationHandler: sslErrorPageNavigationHandler, - maliciousSiteProtectionNavigationHandler: DummyMaliciousSiteProtectionNavigationHandler() + maliciousSiteProtectionNavigationHandler: MockMaliciousSiteProtectionNavigationHandler() ) } @@ -50,7 +50,7 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { @Test func whenCertificateExpiredThenExpectedErrorPageIsShown() async throws { // GIVEN - let error = NSError(domain: "test", + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorServerCertificateUntrusted, userInfo: ["_kCFStreamErrorCodeKey": errSSLCertExpired, NSURLErrorFailingURLErrorKey: try #require(URL(string: "https://expired.badssl.com"))]) @@ -84,7 +84,7 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { @Test func whenCertificateWrongHostThenExpectedErrorPageIsShown() async throws { // GIVEN - let error = NSError(domain: "test", + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorServerCertificateUntrusted, userInfo: ["_kCFStreamErrorCodeKey": errSSLHostNameMismatch, NSURLErrorFailingURLErrorKey: try #require(URL(string: "https://wrong.host.badssl.com"))]) @@ -118,7 +118,7 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { @Test func whenCertificateSelfSignedThenExpectedErrorPageIsShown() async throws { // GIVEN - let error = NSError(domain: "test", + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorServerCertificateUntrusted, userInfo: ["_kCFStreamErrorCodeKey": errSSLXCertChainInvalid, NSURLErrorFailingURLErrorKey: try #require(URL(string: "https://self-signed.badssl.com"))]) @@ -152,7 +152,7 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { @Test func whenOtherCertificateIssueThenExpectedErrorPageIsShown() async throws { // GIVEN - let error = NSError(domain: "test", + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorServerCertificateUntrusted, userInfo: ["_kCFStreamErrorCodeKey": errSSLUnknownRootCert, NSURLErrorFailingURLErrorKey: try #require(URL(string: "https://untrusted-root.badssl.com"))]) @@ -203,7 +203,7 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { @Test func whenNavigationEndedIfSSLFailureButURLIsDifferentFromNavigationURLThenSSLUserScriptIsNotEnabled() throws { // GIVEN - let error = NSError(domain: "test", + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorServerCertificateUntrusted, userInfo: ["_kCFStreamErrorCodeKey": errSSLUnknownRootCert, NSURLErrorFailingURLErrorKey: try #require(URL(string: "https://untrusted-root.badssl.com"))]) @@ -234,7 +234,7 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { sut.attachWebView(webView) let navigation = DummyWKNavigation() let error = NSError( - domain: "test", + domain: NSURLErrorDomain, code: NSURLErrorServerCertificateUntrusted, userInfo: [ "_kCFStreamErrorCodeKey": errSSLCertExpired, @@ -249,5 +249,4 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { // THEN #expect(script.isEnabled) } - } diff --git a/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerTests.swift b/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerTests.swift index d5ea9bd078..33710032d5 100644 --- a/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerTests.swift +++ b/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerTests.swift @@ -20,6 +20,7 @@ import Testing import WebKit import SpecialErrorPages +import MaliciousSiteProtection @testable import DuckDuckGo @Suite("Special Error Pages - SpecialErrorPageNavigationHandler Unit Tests", .serialized) @@ -27,6 +28,7 @@ final class SpecialErrorPageNavigationHandlerTests { private var sut: SpecialErrorPageNavigationHandler! private var webView: MockSpecialErrorWebView! private var sslErrorPageNavigationHandler: MockSSLErrorPageNavigationHandler! + private var maliciousSiteProtectionNavigationHandler: MockMaliciousSiteProtectionNavigationHandler! @MainActor init() { @@ -34,9 +36,10 @@ final class SpecialErrorPageNavigationHandlerTests { featureFlagger.enabledFeatureFlags = [.sslCertificatesBypass] webView = MockSpecialErrorWebView(frame: CGRect(), configuration: .nonPersistent()) sslErrorPageNavigationHandler = MockSSLErrorPageNavigationHandler() + maliciousSiteProtectionNavigationHandler = MockMaliciousSiteProtectionNavigationHandler() sut = SpecialErrorPageNavigationHandler( sslErrorPageNavigationHandler: sslErrorPageNavigationHandler, - maliciousSiteProtectionNavigationHandler: DummyMaliciousSiteProtectionNavigationHandler() + maliciousSiteProtectionNavigationHandler: maliciousSiteProtectionNavigationHandler ) } @@ -46,7 +49,154 @@ final class SpecialErrorPageNavigationHandlerTests { webView = nil } - @Test("Receive Challenge forward event to SSL Error Page Navigation Handler") + @MainActor + @Test("Decide Policy For Navigation Action forwards event to Malicious Site Protection Handler") + func whenHandleDecidePolicyForNavigationActionIsCalledThenAskMaliciousSiteProtectionNavigationHandlerToHandleTheDecision() throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let navigationAction = MockNavigationAction(request: URLRequest(url: url)) + + // WHEN + sut.handleDecidePolicy(for: navigationAction, webView: webView) + + // THEN + #expect(maliciousSiteProtectionNavigationHandler.didCallHandleMaliciousSiteProtectionForNavigationAction) + #expect(maliciousSiteProtectionNavigationHandler.capturedNavigationAction == navigationAction) + #expect(maliciousSiteProtectionNavigationHandler.capturedWebView == webView) + } + + @MainActor + @Test("Decide Policy For Navigation Response forwards event to Malicious Site Protection Handler") + func whenHandleDecidePolicyforNavigationResponseThenAskMaliciousSiteProtectionNavigationHandlerToHandleTheDecision() async throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let navigationResponse = MockNavigationResponse.with(url: url) + + // WHEN + _ = await sut.handleDecidePolicy(for: navigationResponse, webView: webView) + + // THEN + #expect(maliciousSiteProtectionNavigationHandler.didCallHandleMaliciousSiteProtectionForNavigationResponse) + #expect(maliciousSiteProtectionNavigationHandler.capturedNavigationResponse == navigationResponse) + #expect(maliciousSiteProtectionNavigationHandler.capturedWebView == webView) + } + + @MainActor + @Test("Decide Policy For Navigation Response returns false when malicious site detection Task is not found") + func whenHandleDecidePolicyForNavigationResponse_And_TaskIsNil_ThenReturnFalse() async throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let navigationResponse = MockNavigationResponse.with(url: url) + maliciousSiteProtectionNavigationHandler.task = nil + + // WHEN + let result = await sut.handleDecidePolicy(for: navigationResponse, webView: webView) + + // THEN + #expect(result == false) + } + + @MainActor + @Test( + "When Main Frame Threat Then Load Bundled Response And Return True", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenHandleDecidePolicyForNavigationResponse_AndMainFrameThreat_ThenLoadBundledReponseAndReturnTrue(threat: ThreatKind) async throws { + // GIVEN + sut.attachWebView(webView) + let url = try #require(URL(string: "https://www.example.com")) + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: true)) + let navigationResponse = MockNavigationResponse.with(url: url) + let errorData = SpecialErrorData.maliciousSite(kind: threat, url: url) + maliciousSiteProtectionNavigationHandler.task = Task { + .navigationHandled(.mainFrame(MaliciousSiteDetectionNavigationResponse(navigationAction: navigationAction, errorData: errorData))) + } + var didCallLoadSimulatedRequest = false + webView.loadRequestHandler = { _, _ in + didCallLoadSimulatedRequest = true + } + + // WHEN + let result = await sut.handleDecidePolicy(for: navigationResponse, webView: webView) + + // THEN + #expect(sut.isSpecialErrorPageRequest) + #expect(sut.failedURL == url) + #expect(sut.errorData == errorData) + #expect(didCallLoadSimulatedRequest) + #expect(result) + } + + @MainActor + @Test( + "When iFrame Threat Then Load Bundled Response And Return True", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenHandleDecidePolicyForNavigationResponse_AndIFrameThreat_ThenLoadBundledReponseAndReturnTrue(threat: ThreatKind) async throws { + // GIVEN + sut.attachWebView(webView) + let topFrameURL = try #require(URL(string: "https://www.example.com")) + let iFrameURL = try #require(URL(string: "https://www.iframe.example.com")) + let navigationResponse = MockNavigationResponse.with(url: topFrameURL) + let errorData = SpecialErrorData.maliciousSite(kind: threat, url: topFrameURL) + maliciousSiteProtectionNavigationHandler.task = Task { + .navigationHandled(.iFrame(maliciousURL: iFrameURL, error: errorData)) + } + var didCallLoadSimulatedRequest = false + webView.loadRequestHandler = { _, _ in + didCallLoadSimulatedRequest = true + } + + // WHEN + let result = await sut.handleDecidePolicy(for: navigationResponse, webView: webView) + + // THEN + #expect(sut.isSpecialErrorPageRequest) + #expect(sut.failedURL == iFrameURL) + #expect(sut.errorData == errorData) + #expect(didCallLoadSimulatedRequest) + #expect(result) + } + + @MainActor + @Test( + "When No Threat Found Then Return False", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenHandleDecidePolicyForNavigationResponse_AndNoFrameThreat_ThenReturnFalse(threat: ThreatKind) async throws { + // GIVEN + sut.attachWebView(webView) + let url = try #require(URL(string: "https://www.example.com")) + let navigationResponse = MockNavigationResponse.with(url: url) + maliciousSiteProtectionNavigationHandler.task = Task { + .navigationNotHandled + } + var didCallLoadSimulatedRequest = false + webView.loadRequestHandler = { _, _ in + didCallLoadSimulatedRequest = true + } + + // WHEN + let result = await sut.handleDecidePolicy(for: navigationResponse, webView: webView) + + // THEN + #expect(sut.isSpecialErrorPageRequest == false) + #expect(sut.failedURL == nil) + #expect(didCallLoadSimulatedRequest == false) + #expect(result == false) + } + + @MainActor + @Test("Receive Challenge forwards event to SSL Error Page Navigation Handler") func whenDidHandleWebViewReceiveChallengeIsCalledAskSSLErrorPageNavigationHandlerToHandleTheChallenge() { // GIVEN let protectionSpace = URLProtectionSpace(host: "", port: 4, protocol: nil, realm: nil, authenticationMethod: NSURLAuthenticationMethodServerTrust) @@ -74,17 +224,41 @@ final class SpecialErrorPageNavigationHandlerTests { #expect(sslErrorPageNavigationHandler.didCallLeaveSite) } - @Test("Leave Site forward event to Malicious Site Protection Navigation Handler", .disabled("Will implement in upcoming PR")) - func whenLeaveSite_AndPhishingError_ThenCallLeaveSiteOnMaliciousSiteProtectioneNavigationHandler() { + @MainActor + @Test( + "Leave Site forward event to Malicious Site Protection Navigation Handler", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenLeaveSite_AndMaliciousSiteError_ThenCallLeaveSiteOnMaliciousSiteProtectioneNavigationHandler(threat: ThreatKind) async throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let errorData = SpecialErrorData.maliciousSite(kind: threat, url: url) + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: true)) + let navigationResponse = MockNavigationResponse.with(url: url) + maliciousSiteProtectionNavigationHandler.task = Task { + .navigationHandled(.mainFrame(MaliciousSiteDetectionNavigationResponse(navigationAction: navigationAction, errorData: errorData))) + } + _ = await sut.handleDecidePolicy(for: navigationResponse, webView: webView) + + #expect(!maliciousSiteProtectionNavigationHandler.didCallLeaveSite) + + // WHEN + sut.leaveSiteAction() + // THEN + #expect(maliciousSiteProtectionNavigationHandler.didCallLeaveSite) } @MainActor - @Test("Lave Site navigate Back") - func whenLeaveSite_AndWebViewCanNavigateBack_ThenNavigateBack() { + @Test("Lave Site navigates Back when SSL Error") + func whenLeaveSite_AndSSLError_AndWebViewCanNavigateBack_ThenNavigateBack() { // GIVEN webView.setCanGoBack(true) sut.attachWebView(webView) + sut.handleWebView(webView, didFailProvisionalNavigation: DummyWKNavigation(), withError: .genericSSL) #expect(!webView.didCallGoBack) // WHEN @@ -95,14 +269,47 @@ final class SpecialErrorPageNavigationHandlerTests { } @MainActor - @Test("Lave Site close Tab") - func whenLeaveSite_AndWebViewCannotNavigateBack_ThenAskDelegateToCloseTab() { + @Test("Lave Site closes Tab when SSL Error") + func whenLeaveSite_AndSSLError_AndWebViewCannotNavigateBack_ThenAskDelegateToCloseTab() { // GIVEN webView.setCanGoBack(false) let delegate = SpySpecialErrorPageNavigationDelegate() sut.delegate = delegate sut.attachWebView(webView) + sut.handleWebView(webView, didFailProvisionalNavigation: DummyWKNavigation(), withError: .genericSSL) + #expect(!delegate.didCallCloseSpecialErrorPageTab) + + // WHEN + sut.leaveSiteAction() + + // THEN + #expect(delegate.didCallCloseSpecialErrorPageTab) + } + + @MainActor + @Test( + "Lave Site closes Tab when Malicious Site Error", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenLeaveSite_AndMaliciousSiteError_AndWebViewCanNavigateBack_ThenNavigateBack(threat: ThreatKind) async throws { + // GIVEN + webView.setCanGoBack(true) + sut.attachWebView(webView) + let url = try #require(URL(string: "https://example.com")) + let errorData = SpecialErrorData.maliciousSite(kind: threat, url: url) + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: true)) + let navigationResponse = MockNavigationResponse.with(url: url) + maliciousSiteProtectionNavigationHandler.task = Task { + .navigationHandled(.mainFrame(MaliciousSiteDetectionNavigationResponse(navigationAction: navigationAction, errorData: errorData))) + } + _ = await sut.handleDecidePolicy(for: navigationResponse, webView: webView) + let delegate = SpySpecialErrorPageNavigationDelegate() + sut.delegate = delegate #expect(!delegate.didCallCloseSpecialErrorPageTab) + // WHEN sut.leaveSiteAction() @@ -113,8 +320,11 @@ final class SpecialErrorPageNavigationHandlerTests { @MainActor @Test("Visit Site forward event to SSL Error Page Navigation Handler") - func whenVisitSite_AndSSLError_ThenCallVisitSiteOnSSLErrorPageNavigationHandler() { + func whenVisitSite_AndSSLError_ThenCallVisitSiteOnSSLErrorPageNavigationHandler() throws { // GIVEN + let url = try #require(URL(string: "https://example.com")) + webView.setCurrentURL(url) + sut.attachWebView(webView) sut.handleWebView(webView, didFailProvisionalNavigation: DummyWKNavigation(), withError: .genericSSL) #expect(!sslErrorPageNavigationHandler.didCallVisitSite) @@ -125,9 +335,34 @@ final class SpecialErrorPageNavigationHandlerTests { #expect(sslErrorPageNavigationHandler.didCallVisitSite) } - @Test("Visit Site forward event to Malicious Site Protection Navigation Handler", .disabled("Will implement in upcoming PR")) - func whenVisitSite_AndPhishingError_ThenCallVisitSiteOnMaliciousSiteProtectioneNavigationHandler() { + @MainActor + @Test( + "Visit Site forward event to Malicious Site Protection Navigation Handler", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenVisitSite_AndPhishingError_ThenCallVisitSiteOnMaliciousSiteProtectioneNavigationHandler(threat: ThreatKind) async throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + webView.setCurrentURL(url) + sut.attachWebView(webView) + let errorData = SpecialErrorData.maliciousSite(kind: threat, url: url) + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: true)) + let navigationResponse = MockNavigationResponse.with(url: url) + maliciousSiteProtectionNavigationHandler.task = Task { + .navigationHandled(.mainFrame(MaliciousSiteDetectionNavigationResponse(navigationAction: navigationAction, errorData: errorData))) + } + _ = await sut.handleDecidePolicy(for: navigationResponse, webView: webView) + + #expect(!maliciousSiteProtectionNavigationHandler.didCallVisitSite) + // WHEN + sut.visitSiteAction() + + // THEN + #expect(maliciousSiteProtectionNavigationHandler.didCallVisitSite) } @MainActor @@ -161,16 +396,38 @@ final class SpecialErrorPageNavigationHandlerTests { #expect(sslErrorPageNavigationHandler.didCalladvancedInfoPresented) } - @Test("Advanced Info Presented forward event to Malicious Site Protection Navigation Handler", .disabled("Will implement in upcoming PR")) - func whenAdvancedInfoPresented_AndPhishingError_ThenCallAdvancedInfoPresentedOnMaliciousSiteProtectionNavigationHandler() { + @MainActor + @Test( + "Advanced Info Presented forward event to Malicious Site Protection Navigation Handler", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenAdvancedInfoPresented_AndPhishingError_ThenCallAdvancedInfoPresentedOnMaliciousSiteProtectionNavigationHandler(threat: ThreatKind) async throws { + let url = try #require(URL(string: "https://www.example.com")) + let errorData = SpecialErrorData.maliciousSite(kind: threat, url: url) + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: true)) + let navigationResponse = MockNavigationResponse.with(url: url) + maliciousSiteProtectionNavigationHandler.task = Task { + .navigationHandled(.mainFrame(MaliciousSiteDetectionNavigationResponse(navigationAction: navigationAction, errorData: errorData))) + } + _ = await sut.handleDecidePolicy(for: navigationResponse, webView: webView) + + #expect(!maliciousSiteProtectionNavigationHandler.didCallAdvancedInfoPresented) + + // WHEN + sut.advancedInfoPresented() + // THEN + #expect(maliciousSiteProtectionNavigationHandler.didCallAdvancedInfoPresented) } } private extension NSError { static let genericSSL = NSError( - domain: "test", + domain: NSURLErrorDomain, code: NSURLErrorServerCertificateUntrusted, userInfo: [ "_kCFStreamErrorCodeKey": errSSLUnknownRootCert, diff --git a/DuckDuckGoTests/SpecialErrorPage/TestDoubles/DummyMaliciousSiteProtectionNavigationHandler.swift b/DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockMaliciousSiteProtectionManager.swift similarity index 59% rename from DuckDuckGoTests/SpecialErrorPage/TestDoubles/DummyMaliciousSiteProtectionNavigationHandler.swift rename to DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockMaliciousSiteProtectionManager.swift index e842c63cfa..325bb5a46d 100644 --- a/DuckDuckGoTests/SpecialErrorPage/TestDoubles/DummyMaliciousSiteProtectionNavigationHandler.swift +++ b/DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockMaliciousSiteProtectionManager.swift @@ -1,5 +1,5 @@ // -// DummyMaliciousSiteProtectionNavigationHandler.swift +// MockMaliciousSiteProtectionManager.swift // DuckDuckGo // // Copyright © 2024 DuckDuckGo. All rights reserved. @@ -18,17 +18,15 @@ // import Foundation -import WebKit +import MaliciousSiteProtection @testable import DuckDuckGo -class DummyMaliciousSiteProtectionNavigationHandler: MaliciousSiteProtectionNavigationHandling & SpecialErrorPageActionHandler { - func handleMaliciousSiteProtectionNavigation(for navigationAction: WKNavigationAction, webView: WKWebView) async -> DuckDuckGo.MaliciousSiteProtectionNavigationResult { - .navigationNotHandled - } +final class MockMaliciousSiteProtectionManager: MaliciousSiteDetecting { - func visitSite() {} + var threatKind: ThreatKind? - func leaveSite() {} + func evaluate(_ url: URL) async -> MaliciousSiteProtection.ThreatKind? { + threatKind + } - func advancedInfoPresented() {} } diff --git a/DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockMaliciousSiteProtectionNavigationHandler.swift b/DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockMaliciousSiteProtectionNavigationHandler.swift new file mode 100644 index 0000000000..a861c7c247 --- /dev/null +++ b/DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockMaliciousSiteProtectionNavigationHandler.swift @@ -0,0 +1,70 @@ +// +// MockMaliciousSiteProtectionNavigationHandler.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import WebKit +import SpecialErrorPages +@testable import DuckDuckGo + +final class MockMaliciousSiteProtectionNavigationHandler: MaliciousSiteProtectionNavigationHandling & SpecialErrorPageActionHandler { + private(set) var didCallHandleMaliciousSiteProtectionForNavigationAction = false + private(set) var capturedNavigationAction: WKNavigationAction? + private(set) var capturedWebView: WKWebView? + + private(set) var didCallHandleMaliciousSiteProtectionForNavigationResponse = false + private(set) var capturedNavigationResponse: WKNavigationResponse? + + private(set) var didCallVisitSite = false + private(set) var capturedVisitSiteURL: URL? + private(set) var capturedErrorData: SpecialErrorData? + + private(set) var didCallLeaveSite = false + + private(set) var didCallAdvancedInfoPresented = false + + var task: Task? + + func makeMaliciousSiteDetectionTask(for navigationAction: WKNavigationAction, webView: WKWebView) { + didCallHandleMaliciousSiteProtectionForNavigationAction = true + capturedNavigationAction = navigationAction + capturedWebView = webView + } + + func getMaliciousSiteDectionTask(for navigationResponse: WKNavigationResponse, webView: WKWebView) -> Task? { + didCallHandleMaliciousSiteProtectionForNavigationResponse = true + capturedNavigationResponse = navigationResponse + capturedWebView = webView + + return task + } + + func visitSite(url: URL, errorData: SpecialErrorData) { + didCallVisitSite = true + capturedVisitSiteURL = url + capturedErrorData = errorData + } + + func leaveSite() { + didCallLeaveSite = true + } + + func advancedInfoPresented() { + didCallAdvancedInfoPresented = true + } +}