From 13b000616ae138ffc186f04f4556a270935a8ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81yp?= Date: Thu, 29 Aug 2024 22:33:49 +0200 Subject: [PATCH] Handle SSL errors gracefully (#3271) Task/Issue URL: https://app.asana.com/0/72649045549333/1207295365843956/f If users visit a page with certificate or other security issues, provide warning information and allow users to proceed (if they choose to). --- .../privacy_tests/10_expired_certificate.yaml | 37 +++ Core/FeatureFlag.swift | 3 + Core/PixelEvent.swift | 15 +- DuckDuckGo.xcodeproj/project.pbxproj | 26 +- .../xcshareddata/swiftpm/Package.resolved | 8 +- DuckDuckGo/OmniBar.swift | 2 +- DuckDuckGo/PrivacyIconLogic.swift | 4 +- DuckDuckGo/SpecialErrorPageUserScript.swift | 35 ++ DuckDuckGo/TabManager.swift | 6 +- DuckDuckGo/TabViewController.swift | 151 +++++++-- ...ViewControllerLongPressMenuExtension.swift | 3 +- DuckDuckGo/URLCredentialCreator.swift | 37 +++ DuckDuckGo/UserScripts.swift | 9 +- DuckDuckGoTests/MockTabDelegate.swift | 10 +- DuckDuckGoTests/PrivacyIconLogicTests.swift | 21 +- DuckDuckGoTests/SpecialErrorPageTests.swift | 302 ++++++++++++++++++ .../SpecialErrorPageUserScriptTests.swift | 50 +++ .../TabViewControllerDaxDialogTests.swift | 2 +- 18 files changed, 674 insertions(+), 47 deletions(-) create mode 100644 .maestro/privacy_tests/10_expired_certificate.yaml create mode 100644 DuckDuckGo/SpecialErrorPageUserScript.swift create mode 100644 DuckDuckGo/URLCredentialCreator.swift create mode 100644 DuckDuckGoTests/SpecialErrorPageTests.swift create mode 100644 DuckDuckGoTests/SpecialErrorPageUserScriptTests.swift diff --git a/.maestro/privacy_tests/10_expired_certificate.yaml b/.maestro/privacy_tests/10_expired_certificate.yaml new file mode 100644 index 0000000000..c3f629a774 --- /dev/null +++ b/.maestro/privacy_tests/10_expired_certificate.yaml @@ -0,0 +1,37 @@ +appId: com.duckduckgo.mobile.ios +tags: + - privacy + +--- + +# Set up +- runFlow: + file: ../shared/setup.yaml + +- assertVisible: + id: "searchEntry" + +# Test 1 - Leave the dangerous site +- tapOn: + id: "searchEntry" +- inputText: "https://expired.badssl.com" +- pressKey: Enter +- assertVisible: "Warning: This site may be insecure" +- assertNotVisible: + id: "LogoIcon" +- tapOn: "Leave This Site" +- assertNotVisible: "Warning: This site may be insecure" + +# Test 2 - Visit the dangerous site +- tapOn: + id: "searchEntry" +- inputText: "https://expired.badssl.com" +- pressKey: Enter +- tapOn: "Advanced" +- scroll +- tapOn: "Accept Risk and Visit Site" +- assertVisible: + id: "LogoIcon" +- assertVisible: "expired.badssl.com" + + diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index bfb533e0a8..453f602486 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -36,6 +36,7 @@ public enum FeatureFlag: String { case history case newTabPageSections case duckPlayer + case sslCertificatesBypass } extension FeatureFlag: FeatureFlagSourceProviding { @@ -71,6 +72,8 @@ extension FeatureFlag: FeatureFlagSourceProviding { return .internalOnly case .duckPlayer: return .remoteReleasable(.feature(.duckPlayer)) + case .sslCertificatesBypass: + return .remoteReleasable(.subfeature(sslCertificatesSubfeature.allowBypass)) } } } diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 96368efcce..0acf868275 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -780,6 +780,12 @@ extension Pixel { case duckPlayerContingencySettingsDisplayed case duckPlayerContingencyLearnMoreClicked + // MARK: Certificate warnings + case certificateWarningDisplayed(_ errorType: String) + case certificateWarningLeaveClicked + case certificateWarningAdvancedClicked + case certificateWarningProceedClicked + // MARK: Unified Feedback Form case pproFeedbackFeatureRequest(description: String, source: String) case pproFeedbackGeneralFeedback(description: String, source: String) @@ -1562,7 +1568,14 @@ extension Pixel.Event { case .duckPlayerSettingNeverOverlayYoutube: return "duckplayer_setting_never_overlay_youtube" case .duckPlayerContingencySettingsDisplayed: return "duckplayer_ios_contingency_settings-displayed" case .duckPlayerContingencyLearnMoreClicked: return "duckplayer_ios_contingency_learn-more-clicked" - + + // MARK: Certificate warnings + case .certificateWarningDisplayed(let errorType): + return "m_certificate_warning_displayed_\(errorType)" + case .certificateWarningLeaveClicked: return "m_certificate_warning_leave_clicked" + case .certificateWarningAdvancedClicked: return "m_certificate_warning_advanced_clicked" + case .certificateWarningProceedClicked: return "m_certificate_warning_proceed_clicked" + // MARK: Unified Feedback Form case .pproFeedbackFeatureRequest: return "m_ppro_feedback_feature-request" case .pproFeedbackGeneralFeedback: return "m_ppro_feedback_general-feedback" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 30f22fda82..2fbf8dc901 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -872,9 +872,11 @@ CB2A7EF4285383B300885F67 /* AppLastCompiledRulesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB2A7EF3285383B300885F67 /* AppLastCompiledRulesStore.swift */; }; CB48D3332B90CE9F00631D8B /* UserBehaviorMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB48D3312B90CE9F00631D8B /* UserBehaviorMonitor.swift */; }; CB48D3372B90DF2000631D8B /* UserBehaviorMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB48D3352B90CECD00631D8B /* UserBehaviorMonitorTests.swift */; }; + CB4FA44E2C78AACE00A16F5A /* SpecialErrorPageUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB4FA44D2C78AACE00A16F5A /* SpecialErrorPageUserScript.swift */; }; CB5516D0286500290079B175 /* TrackerRadarIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85519124247468580010FDD0 /* TrackerRadarIntegrationTests.swift */; }; CB5516D1286500290079B175 /* ContentBlockingRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CA904C24FD2DB000D41DDF /* ContentBlockingRulesTests.swift */; }; CB5516D2286500290079B175 /* AtbServerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F21DBD21121147002631A6 /* AtbServerTests.swift */; }; + CB6D8E982C80A9B100D0E772 /* SpecialErrorPages in Frameworks */ = {isa = PBXBuildFile; productRef = CB6D8E972C80A9B100D0E772 /* SpecialErrorPages */; }; CB825C922C071B1400BCC586 /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB825C912C071B1400BCC586 /* AlertView.swift */; }; CB825C962C071C9300BCC586 /* AlertViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB825C952C071C9300BCC586 /* AlertViewPresenter.swift */; }; CB84C7BD29A3EF530088A5B8 /* AppConfigurationURLProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB24F70E29A3EB15006DCC58 /* AppConfigurationURLProvider.swift */; }; @@ -884,6 +886,9 @@ CB9B873E278C93C2001F4906 /* HomeMessage.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CB9B873D278C93C2001F4906 /* HomeMessage.xcassets */; }; CBAA195A27BFE15600A4BD49 /* NSManagedObjectContextExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBAA195927BFE15600A4BD49 /* NSManagedObjectContextExtension.swift */; }; CBC83E3429B631780008E19C /* Configuration in Frameworks */ = {isa = PBXBuildFile; productRef = CBC83E3329B631780008E19C /* Configuration */; }; + CBC88EE12C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC88EE02C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift */; }; + CBC88EE32C7F8B1700F0F8C5 /* SpecialErrorPageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC88EE22C7F8B1700F0F8C5 /* SpecialErrorPageTests.swift */; }; + CBC88EE52C8097B500F0F8C5 /* URLCredentialCreator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBC88EE42C8097B500F0F8C5 /* URLCredentialCreator.swift */; }; CBCCF96828885DEE006F4A71 /* AppPrivacyConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C4BC3127C3F9B600C40026 /* AppPrivacyConfigurationTests.swift */; }; CBD4F13C279EBF4A00B20FD7 /* HomeMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBD4F13B279EBF4A00B20FD7 /* HomeMessage.swift */; }; CBD4F13D279EBFA000B20FD7 /* HomeMessageCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF14FC627970C8A001D94D0 /* HomeMessageCollectionViewCell.swift */; }; @@ -2632,6 +2637,7 @@ CB4448752AF6D51D001F93F7 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/InfoPlist.strings; sourceTree = ""; }; CB48D3312B90CE9F00631D8B /* UserBehaviorMonitor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserBehaviorMonitor.swift; sourceTree = ""; }; CB48D3352B90CECD00631D8B /* UserBehaviorMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserBehaviorMonitorTests.swift; sourceTree = ""; }; + CB4FA44D2C78AACE00A16F5A /* SpecialErrorPageUserScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageUserScript.swift; sourceTree = ""; }; CB5038622AF6D563007FD69F /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/InfoPlist.strings; sourceTree = ""; }; CB5418622BD90CD000C2CD26 /* BrokenSitePromptViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrokenSitePromptViewModel.swift; sourceTree = ""; }; CB6ABD002AF6D52B004A8224 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -2652,6 +2658,9 @@ CBAA195B27C3982A00A4BD49 /* PrivacyFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyFeatures.swift; sourceTree = ""; }; CBB6B2542AF6D543006B777C /* lt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lt; path = lt.lproj/InfoPlist.strings; sourceTree = ""; }; CBC7AB542AF6D583008CB798 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/InfoPlist.strings; sourceTree = ""; }; + CBC88EE02C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageUserScriptTests.swift; sourceTree = ""; }; + CBC88EE22C7F8B1700F0F8C5 /* SpecialErrorPageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageTests.swift; sourceTree = ""; }; + 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 = ""; }; CBD7AE812AF6D5B6009052FD /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -3039,6 +3048,7 @@ D6BC8ACB2C5AA3860025375B /* DuckPlayer in Frameworks */, CBC83E3429B631780008E19C /* Configuration in Frameworks */, D61CDA182B7CF78300A0FBB9 /* ZIPFoundation in Frameworks */, + CB6D8E982C80A9B100D0E772 /* SpecialErrorPages in Frameworks */, 851F74262B9A1BFD00747C42 /* Suggestions in Frameworks */, 98A16C2D28A11D6200A6C003 /* BrowserServicesKit in Frameworks */, 8599690F29D2F1C100DBF9FA /* DDGSync in Frameworks */, @@ -5545,6 +5555,7 @@ F13B4BBF1F180D8A00814661 /* TabsModel.swift */, 988AC354257E47C100793C64 /* RequeryLogic.swift */, B652DEFC287BE67400C12A9C /* UserScripts.swift */, + CB4FA44D2C78AACE00A16F5A /* SpecialErrorPageUserScript.swift */, ); name = Model; sourceTree = ""; @@ -5555,6 +5566,7 @@ 984147C224F026A300362052 /* Tab.storyboard */, F1386BA31E6846C40062FC3C /* TabDelegate.swift */, F159BDA31F0BDB5A00B4A01D /* TabViewController.swift */, + CBC88EE42C8097B500F0F8C5 /* URLCredentialCreator.swift */, CB2A7EEE283D185100885F67 /* RulesCompilationMonitor.swift */, 9820EAF422613CD30089094D /* WebProgressWorker.swift */, 83004E852193E5ED00DA013C /* TabViewControllerBrowsingMenuExtension.swift */, @@ -5583,6 +5595,8 @@ F13B4BF81F18CA0600814661 /* TabsModelTests.swift */, F189AED61F18F6DE001EBAE1 /* TabTests.swift */, D625AAEA2BBEEFC900BC189A /* TabURLInterceptorTests.swift */, + CBC88EE02C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift */, + CBC88EE22C7F8B1700F0F8C5 /* SpecialErrorPageTests.swift */, ); name = Tabs; sourceTree = ""; @@ -6473,6 +6487,7 @@ 858D009C2B9799FC004E5B4C /* History */, 851F74252B9A1BFD00747C42 /* Suggestions */, D6BC8ACA2C5AA3860025375B /* DuckPlayer */, + CB6D8E972C80A9B100D0E772 /* SpecialErrorPages */, ); productName = Core; productReference = F143C2E41E4A4CD400CFDE3A /* Core.framework */; @@ -7282,6 +7297,7 @@ F4C9FBF528340DDA002281CC /* AutofillInterfaceEmailTruncator.swift in Sources */, 1E016AB42949FEB500F21625 /* OmniBarNotificationViewModel.swift in Sources */, 6AC6DAB328804F97002723C0 /* BarsAnimator.swift in Sources */, + CB4FA44E2C78AACE00A16F5A /* SpecialErrorPageUserScript.swift in Sources */, 56D060242C35918D003BAEB5 /* ContextualOnboardingList.swift in Sources */, EE0153ED2A6FF9E6002A8B26 /* NetworkProtectionRootView.swift in Sources */, EEF0F8CC2ABC832300630031 /* NetworkProtectionDebugFeatures.swift in Sources */, @@ -7591,6 +7607,7 @@ 3132FA2827A0788400DD7A12 /* PassKitPreviewHelper.swift in Sources */, 6FA343922C3D3C3B00470677 /* FavoriteIconView.swift in Sources */, 9F4CC5272C4E230C006A96EB /* PrivacyIconContextualOnboardingAnimator.swift in Sources */, + CBC88EE52C8097B500F0F8C5 /* URLCredentialCreator.swift in Sources */, 8505836C219F424500ED4EDB /* TextFieldWithInsets.swift in Sources */, CBD4F13F279EBFAF00B20FD7 /* HomeMessageViewModel.swift in Sources */, 1E4DCF4A27B6A38000961E25 /* DownloadListRepresentable.swift in Sources */, @@ -7646,6 +7663,7 @@ CBDD5DE129A6741300832877 /* MockBundle.swift in Sources */, C158AC7B297AB5DC0008723A /* MockSecureVault.swift in Sources */, 569437342BE4E41500C0881B /* SyncErrorHandlerSyncErrorsAlertsTests.swift in Sources */, + CBC88EE32C7F8B1700F0F8C5 /* SpecialErrorPageTests.swift in Sources */, 85C11E4120904BBE00BFFEB4 /* VariantManagerTests.swift in Sources */, F1134ECE1F40EA9C00B73467 /* AtbParserTests.swift in Sources */, F189AEE41F18FDAF001EBAE1 /* LinkTests.swift in Sources */, @@ -7745,6 +7763,7 @@ 85D2187424BF25CD004373D2 /* FaviconsTests.swift in Sources */, 9F6933212C5B9A5B00CD6A5D /* OnboardingHostingControllerMock.swift in Sources */, 9F6933192C59BB0300CD6A5D /* OnboardingPixelReporterMock.swift in Sources */, + CBC88EE12C7F834300F0F8C5 /* SpecialErrorPageUserScriptTests.swift in Sources */, 56D0602D2C383FD2003BAEB5 /* OnboardingSuggestedSearchesProviderTests.swift in Sources */, 85AD49EE2B6149110085D2D1 /* CookieStorageTests.swift in Sources */, 569437242BDD405400C0881B /* SyncBookmarksAdapterTests.swift in Sources */, @@ -10631,7 +10650,7 @@ repositoryURL = "https://github.com/DuckDuckGo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 189.1.0; + version = 190.0.0; }; }; 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */ = { @@ -10846,6 +10865,11 @@ package = C14882EB27F211A000D59F0C /* XCRemoteSwiftPackageReference "SwiftSoup" */; productName = SwiftSoup; }; + CB6D8E972C80A9B100D0E772 /* SpecialErrorPages */ = { + isa = XCSwiftPackageProductDependency; + package = 98A16C2928A11BDE00A6C003 /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; + productName = SpecialErrorPages; + }; CB941A6D2B96AB08000F9E7A /* PrivacyDashboard */ = { isa = XCSwiftPackageProductDependency; package = 98A16C2928A11BDE00A6C003 /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8374776a31..598a4cfc0a 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DuckDuckGo/BrowserServicesKit", "state" : { - "revision" : "65bfdadf4150e89f8bac3a53fe949fad711649cb", - "version" : "189.1.0" + "revision" : "ac53011582abcca4aefd66f15308332273eecb49", + "version" : "190.0.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "799aae6d7ee4031a377ad891a549b66abf29e83c", - "version" : "6.11.0" + "revision" : "5876a5d2e2e7f5a2e11f6419c6c3fafb7cafdfca", + "version" : "6.12.0" } }, { diff --git a/DuckDuckGo/OmniBar.swift b/DuckDuckGo/OmniBar.swift index 0272bdec42..09e4935a5f 100644 --- a/DuckDuckGo/OmniBar.swift +++ b/DuckDuckGo/OmniBar.swift @@ -277,7 +277,7 @@ class OmniBar: UIView { } customIconView.isHidden = true - privacyInfoContainer.privacyIcon.isHidden = false + privacyInfoContainer.privacyIcon.isHidden = privacyInfo.isSpecialErrorPageVisible let icon = PrivacyIconLogic.privacyIcon(for: privacyInfo) privacyInfoContainer.privacyIcon.updateIcon(icon) } diff --git a/DuckDuckGo/PrivacyIconLogic.swift b/DuckDuckGo/PrivacyIconLogic.swift index 6046eca18f..9ae915497f 100644 --- a/DuckDuckGo/PrivacyIconLogic.swift +++ b/DuckDuckGo/PrivacyIconLogic.swift @@ -38,8 +38,8 @@ final class PrivacyIconLogic { let config = ContentBlocking.shared.privacyConfigurationManager.privacyConfig let isUserUnprotected = config.isUserUnprotected(domain: privacyInfo.url.host) - let notFullyProtected = !privacyInfo.https || isUserUnprotected - + let notFullyProtected = !privacyInfo.https || isUserUnprotected || privacyInfo.serverTrust == nil + return notFullyProtected ? .shieldWithDot : .shield } } diff --git a/DuckDuckGo/SpecialErrorPageUserScript.swift b/DuckDuckGo/SpecialErrorPageUserScript.swift new file mode 100644 index 0000000000..e510e214f2 --- /dev/null +++ b/DuckDuckGo/SpecialErrorPageUserScript.swift @@ -0,0 +1,35 @@ +// +// SpecialErrorPageUserScript.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 SpecialErrorPages +import ContentScopeScripts + +extension SpecialErrorPageUserScript { + + static func localeStrings(for languageCode: String = Locale.current.languageCode ?? "en") -> String? { + if let localizedFile = ContentScopeScripts.Bundle.path(forResource: "special-error", + ofType: "json", + inDirectory: "pages/special-error/locales/\(languageCode)") { + return try? String(contentsOfFile: localizedFile) + } + return nil + } + +} diff --git a/DuckDuckGo/TabManager.swift b/DuckDuckGo/TabManager.swift index 41f5f695b6..1d7fadf8ca 100644 --- a/DuckDuckGo/TabManager.swift +++ b/DuckDuckGo/TabManager.swift @@ -88,7 +88,8 @@ class TabManager { privacyProDataReporter: privacyProDataReporter, contextualOnboardingPresenter: contextualOnboardingPresenter, contextualOnboardingLogic: contextualOnboardingLogic, - onboardingPixelReporter: onboardingPixelReporter) + onboardingPixelReporter: onboardingPixelReporter, + featureFlagger: AppDependencyProvider().featureFlagger) controller.applyInheritedAttribution(inheritedAttribution) controller.attachWebView(configuration: configuration, andLoadRequest: url == nil ? nil : URLRequest.userInitiated(url!), @@ -165,7 +166,8 @@ class TabManager { privacyProDataReporter: privacyProDataReporter, contextualOnboardingPresenter: contextualOnboardingPresenter, contextualOnboardingLogic: contextualOnboardingLogic, - onboardingPixelReporter: onboardingPixelReporter) + onboardingPixelReporter: onboardingPixelReporter, + featureFlagger: AppDependencyProvider().featureFlagger) controller.attachWebView(configuration: configCopy, andLoadRequest: request, consumeCookies: !model.hasActiveTabs, diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index 340294715f..0dc640db46 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -36,6 +36,7 @@ import Networking import SecureStorage import History import ContentScopeScripts +import SpecialErrorPages import NetworkProtection import os.log @@ -88,7 +89,7 @@ class TabViewController: UIViewController { private var storageCache: StorageCache = AppDependencyProvider.shared.storageCache let appSettings: AppSettings - lazy var featureFlagger = AppDependencyProvider.shared.featureFlagger + var featureFlagger: FeatureFlagger private lazy var internalUserDecider = AppDependencyProvider.shared.internalUserDecider private lazy var autofillNeverPromptWebsitesManager = AppDependencyProvider.shared.autofillNeverPromptWebsitesManager @@ -174,6 +175,13 @@ class TabViewController: UIViewController { private let refreshControl = UIRefreshControl() + private let certificateTrustEvaluator: CertificateTrustEvaluating + private let urlCredentialCreator: URLCredentialCreating + private var shouldBypassSSLError = false + var errorData: SpecialErrorData? + var failedURL: URL? + var storedSpecialErrorPageUserScript: SpecialErrorPageUserScript? + let syncService: DDGSyncing public var url: URL? { @@ -303,7 +311,9 @@ class TabViewController: UIViewController { privacyProDataReporter: PrivacyProDataReporting, contextualOnboardingPresenter: ContextualOnboardingPresenting, contextualOnboardingLogic: ContextualOnboardingLogic, - onboardingPixelReporter: OnboardingCustomInteractionPixelReporting) -> TabViewController { + onboardingPixelReporter: OnboardingCustomInteractionPixelReporting, + urlCredentialCreator: URLCredentialCreating = URLCredentialCreator(), + featureFlagger: FeatureFlagger) -> TabViewController { let storyboard = UIStoryboard(name: "Tab", bundle: nil) let controller = storyboard.instantiateViewController(identifier: "TabViewController", creator: { coder in TabViewController(coder: coder, @@ -316,7 +326,9 @@ class TabViewController: UIViewController { privacyProDataReporter: privacyProDataReporter, contextualOnboardingPresenter: contextualOnboardingPresenter, contextualOnboardingLogic: contextualOnboardingLogic, - onboardingPixelReporter: onboardingPixelReporter + onboardingPixelReporter: onboardingPixelReporter, + urlCredentialCreator: urlCredentialCreator, + featureFlagger: featureFlagger ) }) return controller @@ -341,17 +353,21 @@ class TabViewController: UIViewController { bookmarksDatabase: CoreDataDatabase, historyManager: HistoryManaging, syncService: DDGSyncing, + certificateTrustEvaluator: CertificateTrustEvaluating = CertificateTrustEvaluator(), duckPlayer: DuckPlayerProtocol?, privacyProDataReporter: PrivacyProDataReporting, contextualOnboardingPresenter: ContextualOnboardingPresenting, contextualOnboardingLogic: ContextualOnboardingLogic, - onboardingPixelReporter: OnboardingCustomInteractionPixelReporting) { + onboardingPixelReporter: OnboardingCustomInteractionPixelReporting, + urlCredentialCreator: URLCredentialCreating = URLCredentialCreator(), + featureFlagger: FeatureFlagger) { self.tabModel = tabModel self.appSettings = appSettings self.bookmarksDatabase = bookmarksDatabase self.historyManager = historyManager self.historyCapture = HistoryCapture(historyManager: historyManager) self.syncService = syncService + self.certificateTrustEvaluator = certificateTrustEvaluator self.duckPlayer = duckPlayer if let duckPlayer { self.duckPlayerNavigationHandler = DuckPlayerNavigationHandler(duckPlayer: duckPlayer, @@ -361,6 +377,8 @@ class TabViewController: UIViewController { self.contextualOnboardingPresenter = contextualOnboardingPresenter self.contextualOnboardingLogic = contextualOnboardingLogic self.onboardingPixelReporter = onboardingPixelReporter + self.urlCredentialCreator = urlCredentialCreator + self.featureFlagger = featureFlagger super.init(coder: aDecoder) } @@ -484,14 +502,20 @@ class TabViewController: UIViewController { func attachWebView(configuration: WKWebViewConfiguration, andLoadRequest request: URLRequest?, consumeCookies: Bool, - loadingInitiatedByParentTab: Bool = false) { + loadingInitiatedByParentTab: Bool = false, + customWebView: ((WKWebViewConfiguration) -> WKWebView)? = nil) { instrumentation.willPrepareWebView() let userContentController = UserContentController() configuration.userContentController = userContentController userContentController.delegate = self - webView = WKWebView(frame: view.bounds, configuration: configuration) + if let customWebView { + webView = customWebView(configuration) + view.layoutIfNeeded() + } else { + webView = WKWebView(frame: view.bounds, configuration: configuration) + } webView.allowsLinkPreview = true webView.allowsBackForwardNavigationGestures = true @@ -964,21 +988,22 @@ class TabViewController: UIViewController { } else { privacyInfo = nil } - onPrivacyInfoChanged() } public func makePrivacyInfo(url: URL) -> PrivacyInfo? { guard let host = url.host else { return nil } - + let entity = ContentBlocking.shared.trackerDataManager.trackerData.findEntity(forHost: host) - - let privacyInfo = PrivacyInfo(url: url, parentEntity: entity, protectionStatus: makeProtectionStatus(for: host)) - privacyInfo.serverTrust = webView.serverTrust - + let isValid = certificateTrustEvaluator.evaluateCertificateTrust(trust: webView.serverTrust) + if let isValid { + privacyInfo.serverTrust = isValid ? webView.serverTrust : nil + } + privacyInfo.isSpecialErrorPageVisible = (isValid == nil) + previousPrivacyInfosByURL[url] = privacyInfo return privacyInfo @@ -1177,17 +1202,19 @@ extension TabViewController: LoginFormDetectionDelegate { // MARK: - WKNavigationDelegate extension TabViewController: WKNavigationDelegate { - + func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodHTTPBasic { performBasicHTTPAuthentication(protectionSpace: challenge.protectionSpace, completionHandler: completionHandler) + } else if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust { + handleServerTrustChallenge(challenge, completionHandler: completionHandler) } else { completionHandler(.performDefaultHandling, nil) } } - + func performBasicHTTPAuthentication(protectionSpace: URLProtectionSpace, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { if let urlProvidedBasicAuthCredential, @@ -1206,10 +1233,21 @@ extension TabViewController: WKNavigationDelegate { }, cancelCompletion: { completionHandler(.rejectProtectionSpace, nil) }) - + delegate?.tab(self, didRequestPresentingAlert: alert) } - + + private func handleServerTrustChallenge(_ challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + guard shouldBypassSSLError, + let credential = urlCredentialCreator.urlCredentialFrom(trust: challenge.protectionSpace.serverTrust) else { + completionHandler(.performDefaultHandling, nil) + return + } + shouldBypassSSLError = false + completionHandler(.useCredential, credential) + } + func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { if let url = webView.url { @@ -1223,7 +1261,7 @@ extension TabViewController: WKNavigationDelegate { let httpsForced = tld.domain(lastUpgradedURL?.host) == tld.domain(webView.url?.host) onWebpageDidStartLoading(httpsForced: httpsForced) } - + private func onWebpageDidStartLoading(httpsForced: Bool) { Logger.general.debug("webpageLoading started") @@ -1234,12 +1272,12 @@ extension TabViewController: WKNavigationDelegate { delegate?.showBars() resetDashboardInfo() - + tabModel.link = link delegate?.tabLoadingStateDidChange(tab: self) - + appRatingPrompt.registerUsage() - + if let scene = self.view.window?.windowScene, webView.url?.isDuckDuckGoSearch == true, appRatingPrompt.shouldPrompt() { @@ -1251,7 +1289,7 @@ extension TabViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { - + let mimeType = MIMEType(from: navigationResponse.response.mimeType) let httpResponse = navigationResponse.response as? HTTPURLResponse @@ -1309,7 +1347,7 @@ extension TabViewController: WKNavigationDelegate { decisionHandler(.allow) } } - + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { lastError = nil lastRenderedURL = webView.url @@ -1322,7 +1360,7 @@ extension TabViewController: WKNavigationDelegate { referrerTrimming.onBeginNavigation(to: webView.url) adClickAttributionDetection.onStartNavigation(url: webView.url) } - + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { self.currentlyLoadedURL = webView.url adClickAttributionDetection.onDidFinishNavigation(url: webView.url) @@ -1343,8 +1381,19 @@ extension TabViewController: WKNavigationDelegate { if webView.url?.isDuckDuckGoSearch == true, case .connected = netPConnectionStatus { DailyPixel.fireDailyAndCount(pixel: .networkProtectionEnabledOnSearch, includedParameters: [.appVersion, .atb]) } + + specialErrorPageUserScript?.isEnabled = webView.url == failedURL } - + + var specialErrorPageUserScript: SpecialErrorPageUserScript? { + get { + return storedSpecialErrorPageUserScript ?? userScripts?.specialErrorPageUserScript + } + set { + storedSpecialErrorPageUserScript = newValue + } + } + func preparePreview(completion: @escaping (UIImage?) -> Void) { DispatchQueue.main.async { [weak self] in guard let webView = self?.webView, @@ -1534,6 +1583,31 @@ extension TabViewController: WKNavigationDelegate { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { self.showErrorNow() } + + loadSpecialErrorPageIfNeeded(error: error) + } + + private func loadSpecialErrorPageIfNeeded(error: NSError) { + guard featureFlagger.isFeatureOn(.sslCertificatesBypass), + error.code == NSURLErrorServerCertificateUntrusted, + let errorCode = error.userInfo["_kCFStreamErrorCodeKey"] as? Int32, + let failedURL = error.failedUrl else { + return + } + let tld = storageCache.tld + let errorType = SSLErrorType.forErrorCode(Int(errorCode)) + self.failedURL = failedURL + errorData = SpecialErrorData(kind: .ssl, + errorType: errorType.rawValue, + domain: failedURL.host, + eTldPlus1: tld.eTLDplus1(failedURL.host)) + loadSpecialErrorPage(url: failedURL) + Pixel.fire(pixel: .certificateWarningDisplayed(errorType.rawParameter)) + } + + private func loadSpecialErrorPage(url: URL) { + let html = SpecialErrorPageHTMLTemplate.htmlFromTemplate + webView?.loadSimulatedRequest(URLRequest(url: url), responseHTML: html) } func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) { @@ -2117,6 +2191,7 @@ extension TabViewController { } } + private func registerForDownloadsNotifications() { NotificationCenter.default.addObserver(self, selector: #selector(downloadDidStart), @@ -2346,7 +2421,7 @@ extension TabViewController: UIGestureRecognizerDelegate { // MARK: - UserContentControllerDelegate extension TabViewController: UserContentControllerDelegate { - private var userScripts: UserScripts? { + var userScripts: UserScripts? { userContentController.contentBlockingAssets?.userScripts as? UserScripts } private var findInPageScript: FindInPageUserScript? { @@ -2375,7 +2450,8 @@ extension TabViewController: UserContentControllerDelegate { userScripts.textSizeUserScript.textSizeAdjustmentInPercents = appSettings.textSize userScripts.loginFormDetectionScript?.delegate = self userScripts.autoconsentUserScript.delegate = self - + userScripts.specialErrorPageUserScript?.delegate = self + // Setup DuckPlayer userScripts.duckPlayer = duckPlayerNavigationHandler?.duckPlayer userScripts.youtubeOverlayScript?.webView = webView @@ -2929,3 +3005,26 @@ extension UserContentController { } } + +extension TabViewController: SpecialErrorPageUserScriptDelegate { + + func leaveSite() { + Pixel.fire(pixel: .certificateWarningLeaveClicked) + guard webView?.canGoBack == true else { + delegate?.tabDidRequestClose(self) + return + } + _ = webView?.goBack() + } + + func visitSite() { + Pixel.fire(pixel: .certificateWarningProceedClicked) + shouldBypassSSLError = true + _ = webView.reload() + } + + func advancedInfoPresented() { + Pixel.fire(pixel: .certificateWarningAdvancedClicked) + } + +} diff --git a/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift b/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift index cfaea37efb..938abdacd5 100644 --- a/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift +++ b/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift @@ -110,7 +110,8 @@ extension TabViewController { privacyProDataReporter: privacyProDataReporter, contextualOnboardingPresenter: contextualOnboardingPresenter, contextualOnboardingLogic: contextualOnboardingLogic, - onboardingPixelReporter: onboardingPixelReporter) + onboardingPixelReporter: onboardingPixelReporter, + featureFlagger: AppDependencyProvider().featureFlagger) tabController.isLinkPreview = true let configuration = WKWebViewConfiguration.nonPersistent() tabController.attachWebView(configuration: configuration, andLoadRequest: URLRequest.userInitiated(url), consumeCookies: false) diff --git a/DuckDuckGo/URLCredentialCreator.swift b/DuckDuckGo/URLCredentialCreator.swift new file mode 100644 index 0000000000..b359c0eb1f --- /dev/null +++ b/DuckDuckGo/URLCredentialCreator.swift @@ -0,0 +1,37 @@ +// +// URLCredentialCreator.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 + +protocol URLCredentialCreating { + + func urlCredentialFrom(trust: SecTrust?) -> URLCredential? + +} + +struct URLCredentialCreator: URLCredentialCreating { + + func urlCredentialFrom(trust: SecTrust?) -> URLCredential? { + if let trust { + return URLCredential(trust: trust) + } + return nil + } + +} diff --git a/DuckDuckGo/UserScripts.swift b/DuckDuckGo/UserScripts.swift index f4ccb7c614..4b003e0a57 100644 --- a/DuckDuckGo/UserScripts.swift +++ b/DuckDuckGo/UserScripts.swift @@ -23,6 +23,7 @@ import BrowserServicesKit import TrackerRadarKit import UserScript import WebKit +import SpecialErrorPages final class UserScripts: UserScriptsProvider { @@ -42,6 +43,7 @@ final class UserScripts: UserScriptsProvider { } var youtubeOverlayScript: YoutubeOverlayUserScript? var youtubePlayerUserScript: YoutubePlayerUserScript? + var specialErrorPageUserScript: SpecialErrorPageUserScript? private(set) var faviconScript = FaviconUserScript() private(set) var navigatorPatchScript = NavigatorSharePatchUserScript() @@ -64,15 +66,16 @@ final class UserScripts: UserScriptsProvider { properties: sourceProvider.contentScopeProperties, isIsolated: true) autoconsentUserScript = AutoconsentUserScript(config: sourceProvider.privacyConfigurationManager.privacyConfig) - + // Special pages - Such as Duck Player specialPages = SpecialPagesUserScript() if let specialPages { userScripts.append(specialPages) } - + specialErrorPageUserScript = SpecialErrorPageUserScript(localeStrings: SpecialErrorPageUserScript.localeStrings(), + languageCode: Locale.current.languageCode ?? "en") + specialErrorPageUserScript.map { specialPages?.registerSubfeature(delegate: $0) } } - lazy var userScripts: [UserScript] = [ debugScript, diff --git a/DuckDuckGoTests/MockTabDelegate.swift b/DuckDuckGoTests/MockTabDelegate.swift index 0c87db3e7b..cc821c55a9 100644 --- a/DuckDuckGoTests/MockTabDelegate.swift +++ b/DuckDuckGoTests/MockTabDelegate.swift @@ -118,9 +118,11 @@ final class MockTabDelegate: TabDelegate { extension TabViewController { static func fake( + customWebView: ((WKWebViewConfiguration) -> WKWebView)? = nil, contextualOnboardingPresenter: ContextualOnboardingPresenting = ContextualOnboardingPresenterMock(), contextualOnboardingLogic: ContextualOnboardingLogic = ContextualOnboardingLogicMock(), - contextualOnboardingPixelReporter: OnboardingCustomInteractionPixelReporting = OnboardingPixelReporterMock() + contextualOnboardingPixelReporter: OnboardingCustomInteractionPixelReporting = OnboardingPixelReporterMock(), + featureFlagger: MockFeatureFlagger = MockFeatureFlagger() ) -> TabViewController { let tab = TabViewController.loadFromStoryboard( model: .init(link: Link(title: nil, url: .ddg)), @@ -132,9 +134,11 @@ extension TabViewController { privacyProDataReporter: MockPrivacyProDataReporter(), contextualOnboardingPresenter: contextualOnboardingPresenter, contextualOnboardingLogic: contextualOnboardingLogic, - onboardingPixelReporter: contextualOnboardingPixelReporter + onboardingPixelReporter: contextualOnboardingPixelReporter, + urlCredentialCreator: MockCredentialCreator(), + featureFlagger: featureFlagger ) - tab.attachWebView(configuration: .nonPersistent(), andLoadRequest: nil, consumeCookies: false) + tab.attachWebView(configuration: .nonPersistent(), andLoadRequest: nil, consumeCookies: false, customWebView: customWebView) return tab } diff --git a/DuckDuckGoTests/PrivacyIconLogicTests.swift b/DuckDuckGoTests/PrivacyIconLogicTests.swift index a3245ac9d0..ebecb5d55c 100644 --- a/DuckDuckGoTests/PrivacyIconLogicTests.swift +++ b/DuckDuckGoTests/PrivacyIconLogicTests.swift @@ -68,6 +68,7 @@ class PrivacyIconLogicTests: XCTestCase { let entity = Entity(displayName: "E", domains: [], prevalence: 1.0) let protectionStatus = ProtectionStatus(unprotectedTemporary: false, enabledFeatures: [], allowlisted: false, denylisted: false) let privacyInfo = PrivacyInfo(url: url, parentEntity: entity, protectionStatus: protectionStatus) + privacyInfo.serverTrust = MockSecTrust() let icon = PrivacyIconLogic.privacyIcon(for: privacyInfo) @@ -75,14 +76,15 @@ class PrivacyIconLogicTests: XCTestCase { XCTAssertFalse(privacyInfo.https) XCTAssertEqual(icon, .shieldWithDot) } - + func testPrivacyIconIsShieldWithoutDotForMajorTrackerNetwork() { let url = PrivacyIconLogicTests.pageURL // We don't have constants for major tracker network now now so just use a huge, unlikely prevalence let entity = Entity(displayName: "E", domains: [], prevalence: 100.0) let protectionStatus = ProtectionStatus(unprotectedTemporary: false, enabledFeatures: [], allowlisted: false, denylisted: false) let privacyInfo = PrivacyInfo(url: url, parentEntity: entity, protectionStatus: protectionStatus) - + privacyInfo.serverTrust = MockSecTrust() + let icon = PrivacyIconLogic.privacyIcon(for: privacyInfo) XCTAssertTrue(url.isHttps) @@ -90,4 +92,19 @@ class PrivacyIconLogicTests: XCTestCase { XCTAssertEqual(icon, .shield) } + func testPrivacyIconIsShieldWithDotForNoSecTrust() { + let url = PrivacyIconLogicTests.pageURL + let entity = Entity(displayName: "E", domains: [], prevalence: 100.0) + let protectionStatus = ProtectionStatus(unprotectedTemporary: false, enabledFeatures: [], allowlisted: false, denylisted: false) + let privacyInfo = PrivacyInfo(url: url, parentEntity: entity, protectionStatus: protectionStatus) + + let icon = PrivacyIconLogic.privacyIcon(for: privacyInfo) + + XCTAssertTrue(url.isHttps) + XCTAssertTrue(privacyInfo.https) + XCTAssertEqual(icon, .shieldWithDot) + } + } + +final class MockSecTrust: SecurityTrust {} diff --git a/DuckDuckGoTests/SpecialErrorPageTests.swift b/DuckDuckGoTests/SpecialErrorPageTests.swift new file mode 100644 index 0000000000..febab4cf3e --- /dev/null +++ b/DuckDuckGoTests/SpecialErrorPageTests.swift @@ -0,0 +1,302 @@ +// +// SpecialErrorPageTests.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 XCTest +import WebKit + +@testable import SpecialErrorPages +@testable import DuckDuckGo + +class MockSpecialErrorWebView: WKWebView { + + var loadRequestHandler: ((URLRequest, String) -> Void)? + var currentURL: URL? + + override func loadSimulatedRequest(_ request: URLRequest, responseHTML string: String) -> WKNavigation { + loadRequestHandler?(request, string) + return super.loadSimulatedRequest(request, responseHTML: string) + } + + override var url: URL? { + return currentURL + } + + func setCurrentURL(_ url: URL) { + self.currentURL = url + } + +} + +final class SpecialErrorPageTests: XCTestCase { + + var webView: MockSpecialErrorWebView! + var sut: TabViewController! + + override func setUpWithError() throws { + try super.setUpWithError() + let featureFlagger = MockFeatureFlagger() + featureFlagger.enabledFeatureFlags = [.sslCertificatesBypass] + sut = .fake(customWebView: { [weak self] configuration in + guard let self else { fatalError("It has to exist") } + self.webView = MockSpecialErrorWebView(frame: CGRect(), configuration: configuration) + return self.webView + }, featureFlagger: featureFlagger) + WKNavigation.swizzleDealloc() + } + + override func tearDown() async throws { + try await super.tearDown() + WKNavigation.restoreDealloc() + } + + func testWhenCertificateExpiredThenExpectedErrorPageIsShown() { + // GIVEN + let error = NSError(domain: "test", + code: NSURLErrorServerCertificateUntrusted, + userInfo: ["_kCFStreamErrorCodeKey": errSSLCertExpired, + NSURLErrorFailingURLErrorKey: URL(string: "https://expired.badssl.com")!]) + let expectation = self.expectation(description: "Special error page should be loaded") + var didFulfill = false + webView.loadRequestHandler = { request, html in + if !didFulfill { + XCTAssertTrue(html.contains("Warning: This site may be insecure")) + XCTAssertTrue(html.contains("is expired")) + XCTAssertEqual(request.url!.host, URL(string: "https://expired.badssl.com")!.host) + expectation.fulfill() + didFulfill = true + } + } + + // WHEN + sut.webView(webView, didFailProvisionalNavigation: WKNavigation(), withError: error) + + // THEN + XCTAssertEqual(sut.failedURL, URL(string: "https://expired.badssl.com")!) + XCTAssertEqual(sut.errorData, SpecialErrorData(kind: .ssl, + errorType: "expired", + domain: "expired.badssl.com", + eTldPlus1: "badssl.com")) + waitForExpectations(timeout: 1) { error in + XCTAssertNil(error, "Expectation was not fulfilled in time") + } + } + + func testWhenCertificateWrongHostThenExpectedErrorPageIsShown() { + // GIVEN + let error = NSError(domain: "test", + code: NSURLErrorServerCertificateUntrusted, + userInfo: ["_kCFStreamErrorCodeKey": errSSLHostNameMismatch, + NSURLErrorFailingURLErrorKey: URL(string: "https://wrong.host.badssl.com")!]) + let expectation = self.expectation(description: "Special error page should be loaded") + var didFulfill = false + webView.loadRequestHandler = { request, html in + if !didFulfill { + XCTAssertTrue(html.contains("Warning: This site may be insecure")) + XCTAssertTrue(html.contains("does not match")) + XCTAssertEqual(request.url!.host, URL(string: "https://wrong.host.badssl.com")!.host) + expectation.fulfill() + didFulfill = true + } + } + + // WHEN + sut.webView(webView, didFailProvisionalNavigation: WKNavigation(), withError: error) + + // THEN + XCTAssertEqual(sut.failedURL, URL(string: "https://wrong.host.badssl.com")!) + XCTAssertEqual(sut.errorData, SpecialErrorData(kind: .ssl, + errorType: "wrongHost", + domain: "wrong.host.badssl.com", + eTldPlus1: "badssl.com")) + waitForExpectations(timeout: 1) { error in + XCTAssertNil(error, "Expectation was not fulfilled in time") + } + } + + func testWhenCertificateSelfSignedThenExpectedErrorPageIsShown() { + // GIVEN + let error = NSError(domain: "test", + code: NSURLErrorServerCertificateUntrusted, + userInfo: ["_kCFStreamErrorCodeKey": errSSLXCertChainInvalid, + NSURLErrorFailingURLErrorKey: URL(string: "https://self-signed.badssl.com")!]) + let expectation = self.expectation(description: "Special error page should be loaded") + var didFulfill = false + webView.loadRequestHandler = { request, html in + if !didFulfill { + XCTAssertTrue(html.contains("Warning: This site may be insecure")) + XCTAssertTrue(html.contains("is not trusted")) + XCTAssertEqual(request.url!.host, URL(string: "https://self-signed.badssl.com")!.host) + expectation.fulfill() + didFulfill = true + } + } + + // WHEN + sut.webView(webView, didFailProvisionalNavigation: WKNavigation(), withError: error) + + // THEN + XCTAssertEqual(sut.failedURL, URL(string: "https://self-signed.badssl.com")!) + XCTAssertEqual(sut.errorData, SpecialErrorData(kind: .ssl, + errorType: "selfSigned", + domain: "self-signed.badssl.com", + eTldPlus1: "badssl.com")) + waitForExpectations(timeout: 1) { error in + XCTAssertNil(error, "Expectation was not fulfilled in time") + } + } + + func testWhenOtherCertificateIssueThenExpectedErrorPageIsShown() { + // GIVEN + let error = NSError(domain: "test", + code: NSURLErrorServerCertificateUntrusted, + userInfo: ["_kCFStreamErrorCodeKey": errSSLUnknownRootCert, + NSURLErrorFailingURLErrorKey: URL(string: "https://untrusted-root.badssl.com")!]) + let expectation = self.expectation(description: "Special error page should be loaded") + var didFulfill = false + webView.loadRequestHandler = { request, html in + if !didFulfill { + XCTAssertTrue(html.contains("Warning: This site may be insecure")) + XCTAssertTrue(html.contains("is not trusted")) + XCTAssertEqual(request.url!.host, URL(string: "https://untrusted-root.badssl.com")!.host) + expectation.fulfill() + didFulfill = true + } + } + + // WHEN + sut.webView(webView, didFailProvisionalNavigation: WKNavigation(), withError: error) + + // THEN + XCTAssertEqual(sut.failedURL, URL(string: "https://untrusted-root.badssl.com")!) + XCTAssertEqual(sut.errorData, SpecialErrorData(kind: .ssl, + errorType: "invalid", + domain: "untrusted-root.badssl.com", + eTldPlus1: "badssl.com")) + waitForExpectations(timeout: 1) { error in + XCTAssertNil(error, "Expectation was not fulfilled in time") + } + } + + @MainActor + func testWhenNavigationEndedIfNoSSLFailureSSLUserScriptIsNotEnabled() { + // GIVEN + webView.setCurrentURL(URL(string: "https://self-signed.badssl.com")!) + sut.storedSpecialErrorPageUserScript = SpecialErrorPageUserScript(localeStrings: "", languageCode: "") + + // WHEN + sut.webView(webView, didFinish: WKNavigation()) + + // THEN + XCTAssertFalse(sut.specialErrorPageUserScript?.isEnabled ?? true) + } + + @MainActor + func testWhenNavigationEndedIfSSLFailureButURLIsDifferentFromNavigationURLThenSSLUserScriptIsNotEnabled() { + // GIVEN + webView.setCurrentURL(URL(string: "https://self-signed.badssl.com")!) + sut.failedURL = URL(string: "https://different.url.com")! + sut.storedSpecialErrorPageUserScript = SpecialErrorPageUserScript(localeStrings: "", languageCode: "") + + // WHEN + sut.webView(webView, didFinish: WKNavigation()) + + // THEN + XCTAssertFalse(sut.specialErrorPageUserScript?.isEnabled ?? true) + } + + @MainActor + func testWhenNavigationEndedIfSSLFailureAndNavigationURLIsTheSameAsFailingURLThenSSLUserScriptIsEnabled() { + // GIVEN + webView.setCurrentURL(URL(string: "https://self-signed.badssl.com")!) + sut.failedURL = URL(string: "https://self-signed.badssl.com")! + sut.storedSpecialErrorPageUserScript = SpecialErrorPageUserScript(localeStrings: "", languageCode: "") + + // WHEN + sut.webView(webView, didFinish: WKNavigation()) + + // THEN + XCTAssertTrue(sut.specialErrorPageUserScript?.isEnabled ?? false) + } + + func testWhenDidReceiveChallengeIfChallengeForCertificateValidationAndNoBypassThenShouldNotReturnCredentials() async { + 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()) + await sut.webView(webView, didReceive: challenge) { _, credential in + XCTAssertNil(credential) + } + } + + func testWhenDidReceiveChallengeIfChallengeForCertificateValidationAndUserRequestBypassThenReturnsCredentials() async { + 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()) + await sut.visitSite() + await sut.webView(webView, didReceive: challenge) { _, credential in + XCTAssertNotNil(credential) + } + } + +} + +final class ChallengeSender: URLAuthenticationChallengeSender { + func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {} + func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {} + func cancel(_ challenge: URLAuthenticationChallenge) {} + func isEqual(_ object: Any?) -> Bool { + return false + } + var hash: Int = 0 + var superclass: AnyClass? + func `self`() -> Self { + self + } + func perform(_ aSelector: Selector!) -> Unmanaged! { + return nil + } + func perform(_ aSelector: Selector!, with object: Any!) -> Unmanaged! { + return nil + } + func perform(_ aSelector: Selector!, with object1: Any!, with object2: Any!) -> Unmanaged! { + return nil + } + func isProxy() -> Bool { + return false + } + func isKind(of aClass: AnyClass) -> Bool { + return false + } + func isMember(of aClass: AnyClass) -> Bool { + return false + } + func conforms(to aProtocol: Protocol) -> Bool { + return false + } + func responds(to aSelector: Selector!) -> Bool { + return false + } + var description: String = "" +} + +final class MockCredentialCreator: URLCredentialCreating { + + func urlCredentialFrom(trust: SecTrust?) -> URLCredential? { + return URLCredential(user: "", password: "", persistence: .forSession) + } + +} diff --git a/DuckDuckGoTests/SpecialErrorPageUserScriptTests.swift b/DuckDuckGoTests/SpecialErrorPageUserScriptTests.swift new file mode 100644 index 0000000000..d770f9cc3b --- /dev/null +++ b/DuckDuckGoTests/SpecialErrorPageUserScriptTests.swift @@ -0,0 +1,50 @@ +// +// SpecialErrorPageUserScriptTests.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 XCTest +import SpecialErrorPages +@testable import DuckDuckGo + +final class SpecialErrorPageUserScriptTests: XCTestCase { + + func testLocaleStringsForNotSupportedLanguage() { + // Given + let languageCode = "ko" + + // When + let result = SpecialErrorPageUserScript.localeStrings(for: languageCode) + + // Then + XCTAssertNil(result, "The result should be nil for Korean language") + } + + func testLocaleStringsForPolishLanguage() { + // Given + let languageCode = "pl" + + // When + let result = SpecialErrorPageUserScript.localeStrings(for: languageCode) + + // Then + XCTAssertNotNil(result, "The result should not be nil for the Polish language code.") + let expectedSubstring = "Ostrzeżenie: ta witryna może być niebezpieczna" + XCTAssertTrue(result!.contains(expectedSubstring), "The result should contain the expected Polish string.") + } + +} diff --git a/DuckDuckGoTests/TabViewControllerDaxDialogTests.swift b/DuckDuckGoTests/TabViewControllerDaxDialogTests.swift index 30d9b2662f..ac1a8d22df 100644 --- a/DuckDuckGoTests/TabViewControllerDaxDialogTests.swift +++ b/DuckDuckGoTests/TabViewControllerDaxDialogTests.swift @@ -258,7 +258,7 @@ final class ContextualOnboardingLogicMock: ContextualOnboardingLogic { } -private extension WKNavigation { +extension WKNavigation { private static var isSwizzled = false private static let originalDealloc = { class_getInstanceMethod(WKNavigation.self, NSSelectorFromString("dealloc"))! }() private static let swizzledDealloc = { class_getInstanceMethod(WKNavigation.self, #selector(swizzled_dealloc))! }()