From d331b344028ab0568aabf3117b21adc0172efdb7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 15 Jul 2026 20:30:22 +0000 Subject: [PATCH 1/8] feat(paywalls): web_view navigation policy + content-rule isolation (3/7) Extract origin normalization, main-frame navigation policy, and WK content rule isolation as inert helpers used by later session/view PRs. Co-authored-by: Jacob Rakidzich --- RevenueCat.xcodeproj/project.pbxproj | 20 ++++++ RevenueCatUI/Data/Strings.swift | 6 ++ .../Components/WebView/WebViewIsolation.swift | 67 +++++++++++++++++++ .../WebView/WebViewOriginPolicy.swift | 51 ++++++++++++++ .../PaywallsV2/WebViewIsolationTests.swift | 53 +++++++++++++++ .../WebViewNavigationPolicyTests.swift | 55 +++++++++++++++ 6 files changed, 252 insertions(+) create mode 100644 RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift create mode 100644 RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift create mode 100644 Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift create mode 100644 Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 996c1ac30c..c732d57493 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -107,6 +107,8 @@ 16DA8ECD2E4EE33700283940 /* ImageComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */; }; 16DA8EDF2E4F6A6600283940 /* PaywallVideoComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */; }; 16DA8EF42E4F7A2500283940 /* VideoComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */; }; + 1401E951ACE24C7B84F94B7F /* WebViewIsolation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */; }; + FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */; }; 16DA8F1E2E4FB6E200283940 /* ImageComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */; }; 16DA8F1F2E4FB6E200283940 /* VideoComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */; }; 16E146AD2E99F3480089B609 /* TransactionNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */; }; @@ -1636,6 +1638,10 @@ 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageComponentTests.swift; sourceTree = ""; }; 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallVideoComponent.swift; sourceTree = ""; }; 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoComponentTests.swift; sourceTree = ""; }; + 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewIsolation.swift; sourceTree = ""; }; + 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewOriginPolicy.swift; sourceTree = ""; }; + 19B79CAD4C423F576E7DFBA9 /* WebViewIsolationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewIsolationTests.swift; sourceTree = ""; }; + 9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNavigationPolicyTests.swift; sourceTree = ""; }; 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = ImageComponent.json; sourceTree = ""; }; 16DA8F1C2E4FB6E200283940 /* VideoComponent.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = VideoComponent.json; sourceTree = ""; }; 16E146AB2E99F1E20089B609 /* TransactionNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionNotifications.swift; sourceTree = ""; }; @@ -3165,6 +3171,8 @@ 030890822D2B77DD0069677B /* PaywallsV2 */ = { isa = PBXGroup; children = ( + 19B79CAD4C423F576E7DFBA9 /* WebViewIsolationTests.swift */, + 9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */, 802593842FE07057005AF6DF /* PaywallStateStoreTests.swift */, 16A9F7B62FAA22D0008E8A4D /* VideoComponentViewTests.swift */, A9D45E4EAFAD4C0385BEDFB3 /* VideoPlayerViewTests.swift */, @@ -3297,6 +3305,15 @@ path = Video; sourceTree = ""; }; + 2561352F00BE1ECE1BAC4E9A /* WebView */ = { + isa = PBXGroup; + children = ( + 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */, + 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */, + ); + path = WebView; + sourceTree = ""; + }; 1699BD5C2EEFBEEC002001FB /* DefaultPaywall */ = { isa = PBXGroup; children = ( @@ -3387,6 +3404,7 @@ 0387D4AD2EC58AA5008E4A6B /* Countdown */, E1C0A00BBEEF0001CCDD0001 /* Header */, 164681CD2E6B577600854AA5 /* Video */, + 2561352F00BE1ECE1BAC4E9A /* WebView */, 2C7457472CEA66AB004ACE52 /* ComponentsView.swift */, 7707A94A2CAD936A006E0313 /* Button */, 03C06FC82D479C6300600693 /* Carousel */, @@ -8233,6 +8251,8 @@ 164681D02E6B577600854AA5 /* VideoComponentView.swift in Sources */, 164681D12E6B577600854AA5 /* VideoPlayerViewUIView.swift in Sources */, 164681D62E6B577600854AA5 /* VideoPlayerLayerUIView.swift in Sources */, + 1401E951ACE24C7B84F94B7F /* WebViewIsolation.swift in Sources */, + FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */, DBAA1FC22F8D4ED8000E8C81 /* HeaderNestedHeroZLayerSafeAreaPreview.swift in Sources */, 164681D22E6B577600854AA5 /* VideoComponentViewModel.swift in Sources */, 2C7457422CE81107004ACE52 /* IntroOfferEligibilityContext.swift in Sources */, diff --git a/RevenueCatUI/Data/Strings.swift b/RevenueCatUI/Data/Strings.swift index 64ecf81dbf..856d89fc92 100644 --- a/RevenueCatUI/Data/Strings.swift +++ b/RevenueCatUI/Data/Strings.swift @@ -109,6 +109,9 @@ enum Strings { case video_failed_to_set_audio_session_category(Error) case video_failed_to_cache(URL, Error) + // WebView + case paywall_web_view_content_rules_failed(String) + // Exit Offers case errorFetchingOfferings(Error) case exitOfferNotFound(String) @@ -364,6 +367,9 @@ extension Strings: CustomStringConvertible { case .video_failed_to_cache(let url, let error): return "Failed to cache video at \(url): \(error)" + case .paywall_web_view_content_rules_failed(let error): + return "Paywalls V2 web_view content rules failed: \(error)" + case .errorFetchingOfferings(let error): return "Error fetching offerings: \(error)" case .exitOfferNotFound(let offeringId): diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift new file mode 100644 index 0000000000..a455af7471 --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift @@ -0,0 +1,67 @@ +import Foundation +@_spi(Internal) import RevenueCat + +#if canImport(WebKit) +import WebKit +#endif + +#if !os(tvOS) && canImport(WebKit) // For Paywalls V2 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum WebViewIsolation { + + static let contentRuleListIdentifier = "rc-webview-v2-isolation" + + static var contentBlockingRules: String? { + """ + [ + {"trigger": {"url-filter": ".*", "resource-type": ["image", "script", "font"], "load-type": ["third-party"]}, + "action": {"type": "block"}}, + {"trigger": {"url-filter": ".*", "resource-type": ["raw"], "load-type": ["third-party"]}, + "action": {"type": "block"}} + ] + """ + } + + @MainActor + static var compileRuleList: @MainActor (String, String) async -> WKContentRuleList? = { identifier, rules in + await withCheckedContinuation { continuation in + WKContentRuleListStore.default().compileContentRuleList( + forIdentifier: identifier, + encodedContentRuleList: rules + ) { ruleList, error in + if let error { + Logger.debug(Strings.paywall_web_view_content_rules_failed(String(describing: error))) + } + continuation.resume(returning: ruleList) + } + } + } + + @MainActor + private static var ruleListTask: Task? + + @MainActor + static func ruleList() async -> WKContentRuleList? { + if let ruleListTask { + return await ruleListTask.value + } + + let task = Task { @MainActor in + guard let rules = Self.contentBlockingRules else { + return nil + } + return await Self.compileRuleList(Self.contentRuleListIdentifier, rules) + } + self.ruleListTask = task + return await task.value + } + + @MainActor + static func resetRuleListCacheForTests() { + self.ruleListTask = nil + } + +} + +#endif diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift new file mode 100644 index 0000000000..806303b9a3 --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift @@ -0,0 +1,51 @@ +import Foundation + +#if canImport(WebKit) +import WebKit +#endif + +#if !os(tvOS) && canImport(WebKit) // For Paywalls V2 + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum WebViewOrigin { + + nonisolated static func origin(of url: URL) -> String? { + guard let scheme = url.scheme?.lowercased(), + let host = url.host?.lowercased(), + !host.isEmpty else { + return nil + } + + let port = url.port + let suffix: String + if let port, !Self.isDefaultPort(port, scheme: scheme) { + suffix = ":\(port)" + } else { + suffix = "" + } + return "\(scheme)://\(host)\(suffix)" + } + + nonisolated private static func isDefaultPort(_ port: Int, scheme: String) -> Bool { + (scheme == "https" && port == 443) || (scheme == "http" && port == 80) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum WebViewNavigationPolicy { + + static func policy(for url: URL?, isMainFrame: Bool, expectedOrigin: String) -> WKNavigationActionPolicy { + guard isMainFrame else { + return .allow + } + guard let url, + WebViewOrigin.origin(of: url) == expectedOrigin else { + return .cancel + } + return .allow + } + +} + +#endif diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift new file mode 100644 index 0000000000..cf0764f36d --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift @@ -0,0 +1,53 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + +@testable import RevenueCatUI +import XCTest + +#if !os(tvOS) && canImport(WebKit) + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@MainActor +final class WebViewIsolationTests: TestCase { + + func testContentRulesShape() throws { + XCTAssertEqual(WebViewIsolation.contentRuleListIdentifier, "rc-webview-v2-isolation") + let rules = try XCTUnwrap(WebViewIsolation.contentBlockingRules) + let data = try XCTUnwrap(rules.data(using: .utf8)) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [[String: Any]]) + + XCTAssertEqual(object.count, 2) + let firstTrigger = try XCTUnwrap(object[0]["trigger"] as? [String: Any]) + let secondTrigger = try XCTUnwrap(object[1]["trigger"] as? [String: Any]) + XCTAssertEqual(firstTrigger["resource-type"] as? [String], ["image", "script", "font"]) + XCTAssertEqual(firstTrigger["load-type"] as? [String], ["third-party"]) + XCTAssertEqual(secondTrigger["resource-type"] as? [String], ["raw"]) + XCTAssertEqual(secondTrigger["load-type"] as? [String], ["third-party"]) + XCTAssertFalse(rules.contains(#""url-filter": "data:"#)) + } + + func testCompileFailureReturnsNilAndCaches() async { + WebViewIsolation.resetRuleListCacheForTests() + var calls = 0 + let original = WebViewIsolation.compileRuleList + WebViewIsolation.compileRuleList = { _, _ in + calls += 1 + return nil + } + defer { + WebViewIsolation.compileRuleList = original + WebViewIsolation.resetRuleListCacheForTests() + } + + let first = await WebViewIsolation.ruleList() + let second = await WebViewIsolation.ruleList() + + XCTAssertNil(first) + XCTAssertNil(second) + XCTAssertEqual(calls, 1) + } + +} + +#endif diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift new file mode 100644 index 0000000000..843beeff35 --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift @@ -0,0 +1,55 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + +@testable import RevenueCatUI +import XCTest + +#if !os(tvOS) && canImport(WebKit) +import WebKit + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +final class WebViewNavigationPolicyTests: TestCase { + + func testMainFrameSameOriginAllowedCrossOriginCancelledSubFrameAllowed() { + XCTAssertEqual( + WebViewNavigationPolicy.policy( + for: URL(string: "https://example.com/next")!, + isMainFrame: true, + expectedOrigin: "https://example.com" + ), + .allow + ) + XCTAssertEqual( + WebViewNavigationPolicy.policy( + for: URL(string: "https://evil.example/next")!, + isMainFrame: true, + expectedOrigin: "https://example.com" + ), + .cancel + ) + XCTAssertEqual( + WebViewNavigationPolicy.policy( + for: URL(string: "https://evil.example/next")!, + isMainFrame: false, + expectedOrigin: "https://example.com" + ), + .allow + ) + } + + func testOriginStripsDefaultPortKeepsNonDefaultAndNormalizesCase() { + XCTAssertEqual( + WebViewOrigin.origin(of: URL(string: "https://Example.COM:443/path")!), + "https://example.com" + ) + XCTAssertEqual( + WebViewOrigin.origin(of: URL(string: "HTTPS://Example.COM:8443/path")!), + "https://example.com:8443" + ) + XCTAssertNil(WebViewOrigin.origin(of: URL(string: "https:///no-host")!)) + } + +} + +#endif From a8496687ff258d94b389560b7afe6933b0ad8477 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Mon, 20 Jul 2026 13:38:43 +0200 Subject: [PATCH 2/8] Address review: tighten web_view content rules and expand tests Merge the content-blocking rule into a single item and extend the third-party block list (style-sheet, media, document). Make contentBlockingRules a non-optional constant, move import WebKit inside the platform guard, and add coverage: real WKContentRuleListStore compilation, http:80 default-port stripping, and adversarial navigation cases (nil URL, scheme downgrade, port mismatch). Co-authored-by: Cursor --- .../Components/WebView/WebViewIsolation.swift | 32 +++++++-------- .../PaywallsV2/WebViewIsolationTests.swift | 40 +++++++++++++++---- .../WebViewNavigationPolicyTests.swift | 31 ++++++++++++++ 3 files changed, 77 insertions(+), 26 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift index a455af7471..e63cbcdc1f 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift @@ -1,27 +1,26 @@ import Foundation @_spi(Internal) import RevenueCat -#if canImport(WebKit) -import WebKit -#endif - #if !os(tvOS) && canImport(WebKit) // For Paywalls V2 +import WebKit @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) enum WebViewIsolation { static let contentRuleListIdentifier = "rc-webview-v2-isolation" - static var contentBlockingRules: String? { - """ - [ - {"trigger": {"url-filter": ".*", "resource-type": ["image", "script", "font"], "load-type": ["third-party"]}, - "action": {"type": "block"}}, - {"trigger": {"url-filter": ".*", "resource-type": ["raw"], "load-type": ["third-party"]}, - "action": {"type": "block"}} - ] - """ - } + static let contentBlockingRules = """ + [ + { + "trigger": { + "url-filter": ".*", + "resource-type": ["image", "script", "font", "raw", "style-sheet", "media", "document"], + "load-type": ["third-party"] + }, + "action": {"type": "block"} + } + ] + """ @MainActor static var compileRuleList: @MainActor (String, String) async -> WKContentRuleList? = { identifier, rules in @@ -48,10 +47,7 @@ enum WebViewIsolation { } let task = Task { @MainActor in - guard let rules = Self.contentBlockingRules else { - return nil - } - return await Self.compileRuleList(Self.contentRuleListIdentifier, rules) + await Self.compileRuleList(Self.contentRuleListIdentifier, Self.contentBlockingRules) } self.ruleListTask = task return await task.value diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift index cf0764f36d..560eefbf0a 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift @@ -6,6 +6,7 @@ import XCTest #if !os(tvOS) && canImport(WebKit) +import WebKit @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) @MainActor @@ -13,20 +14,43 @@ final class WebViewIsolationTests: TestCase { func testContentRulesShape() throws { XCTAssertEqual(WebViewIsolation.contentRuleListIdentifier, "rc-webview-v2-isolation") - let rules = try XCTUnwrap(WebViewIsolation.contentBlockingRules) + let rules = WebViewIsolation.contentBlockingRules let data = try XCTUnwrap(rules.data(using: .utf8)) let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [[String: Any]]) - XCTAssertEqual(object.count, 2) - let firstTrigger = try XCTUnwrap(object[0]["trigger"] as? [String: Any]) - let secondTrigger = try XCTUnwrap(object[1]["trigger"] as? [String: Any]) - XCTAssertEqual(firstTrigger["resource-type"] as? [String], ["image", "script", "font"]) - XCTAssertEqual(firstTrigger["load-type"] as? [String], ["third-party"]) - XCTAssertEqual(secondTrigger["resource-type"] as? [String], ["raw"]) - XCTAssertEqual(secondTrigger["load-type"] as? [String], ["third-party"]) + XCTAssertEqual(object.count, 1) + let trigger = try XCTUnwrap(object[0]["trigger"] as? [String: Any]) + XCTAssertEqual( + trigger["resource-type"] as? [String], + ["image", "script", "font", "raw", "style-sheet", "media", "document"] + ) + XCTAssertEqual(trigger["load-type"] as? [String], ["third-party"]) + let action = try XCTUnwrap(object[0]["action"] as? [String: Any]) + XCTAssertEqual(action["type"] as? String, "block") XCTAssertFalse(rules.contains(#""url-filter": "data:"#)) } + func testContentBlockingRulesCompileThroughWebKit() async throws { + let identifier = "\(WebViewIsolation.contentRuleListIdentifier)-integration-test" + + let ruleList: WKContentRuleList? = await withCheckedContinuation { continuation in + WKContentRuleListStore.default().compileContentRuleList( + forIdentifier: identifier, + encodedContentRuleList: WebViewIsolation.contentBlockingRules + ) { compiled, _ in + continuation.resume(returning: compiled) + } + } + + XCTAssertNotNil(ruleList) + + await withCheckedContinuation { (continuation: CheckedContinuation) in + WKContentRuleListStore.default().removeContentRuleList(forIdentifier: identifier) { _ in + continuation.resume() + } + } + } + func testCompileFailureReturnsNilAndCaches() async { WebViewIsolation.resetRuleListCacheForTests() var calls = 0 diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift index 843beeff35..d4e2a421fa 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift @@ -38,11 +38,42 @@ final class WebViewNavigationPolicyTests: TestCase { ) } + func testMainFrameCancelsNilURLSchemeDowngradeAndPortMismatch() { + XCTAssertEqual( + WebViewNavigationPolicy.policy( + for: nil, + isMainFrame: true, + expectedOrigin: "https://example.com" + ), + .cancel + ) + XCTAssertEqual( + WebViewNavigationPolicy.policy( + for: URL(string: "http://example.com/next")!, + isMainFrame: true, + expectedOrigin: "https://example.com" + ), + .cancel + ) + XCTAssertEqual( + WebViewNavigationPolicy.policy( + for: URL(string: "https://example.com:8443/next")!, + isMainFrame: true, + expectedOrigin: "https://example.com" + ), + .cancel + ) + } + func testOriginStripsDefaultPortKeepsNonDefaultAndNormalizesCase() { XCTAssertEqual( WebViewOrigin.origin(of: URL(string: "https://Example.COM:443/path")!), "https://example.com" ) + XCTAssertEqual( + WebViewOrigin.origin(of: URL(string: "http://Example.COM:80/path")!), + "http://example.com" + ) XCTAssertEqual( WebViewOrigin.origin(of: URL(string: "HTTPS://Example.COM:8443/path")!), "https://example.com:8443" From b3e0f35b15fdd0387c786420d0ac21a7165ab066 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Mon, 20 Jul 2026 13:51:22 +0200 Subject: [PATCH 3/8] Enforce origin check on sub-frames and split navigation policy tests Apply the origin check to every frame so cross-origin sub-frames are cancelled (defense-in-depth alongside the content rules), and split the two combined navigation-policy tests into one clearly-named test per case. Co-authored-by: Cursor --- .../WebView/WebViewOriginPolicy.swift | 6 ++-- .../WebViewNavigationPolicyTests.swift | 29 +++++++++++++++++-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift index 806303b9a3..41dcf23204 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift @@ -35,10 +35,10 @@ enum WebViewOrigin { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) enum WebViewNavigationPolicy { + // The origin check is enforced on every frame, including sub-frames, so cross-origin iframes + // cannot navigate freely even if a third-party document load slips past the content rules. + // `isMainFrame` is kept for call-site context and potential future differentiation. static func policy(for url: URL?, isMainFrame: Bool, expectedOrigin: String) -> WKNavigationActionPolicy { - guard isMainFrame else { - return .allow - } guard let url, WebViewOrigin.origin(of: url) == expectedOrigin else { return .cancel diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift index d4e2a421fa..9345f2d83b 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift @@ -11,7 +11,7 @@ import WebKit @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) final class WebViewNavigationPolicyTests: TestCase { - func testMainFrameSameOriginAllowedCrossOriginCancelledSubFrameAllowed() { + func testMainFrameSameOriginIsAllowed() { XCTAssertEqual( WebViewNavigationPolicy.policy( for: URL(string: "https://example.com/next")!, @@ -20,6 +20,9 @@ final class WebViewNavigationPolicyTests: TestCase { ), .allow ) + } + + func testMainFrameCrossOriginIsCancelled() { XCTAssertEqual( WebViewNavigationPolicy.policy( for: URL(string: "https://evil.example/next")!, @@ -28,9 +31,12 @@ final class WebViewNavigationPolicyTests: TestCase { ), .cancel ) + } + + func testSubFrameSameOriginIsAllowed() { XCTAssertEqual( WebViewNavigationPolicy.policy( - for: URL(string: "https://evil.example/next")!, + for: URL(string: "https://example.com/next")!, isMainFrame: false, expectedOrigin: "https://example.com" ), @@ -38,7 +44,18 @@ final class WebViewNavigationPolicyTests: TestCase { ) } - func testMainFrameCancelsNilURLSchemeDowngradeAndPortMismatch() { + func testSubFrameCrossOriginIsCancelled() { + XCTAssertEqual( + WebViewNavigationPolicy.policy( + for: URL(string: "https://evil.example/next")!, + isMainFrame: false, + expectedOrigin: "https://example.com" + ), + .cancel + ) + } + + func testMainFrameNilURLIsCancelled() { XCTAssertEqual( WebViewNavigationPolicy.policy( for: nil, @@ -47,6 +64,9 @@ final class WebViewNavigationPolicyTests: TestCase { ), .cancel ) + } + + func testMainFrameSchemeDowngradeIsCancelled() { XCTAssertEqual( WebViewNavigationPolicy.policy( for: URL(string: "http://example.com/next")!, @@ -55,6 +75,9 @@ final class WebViewNavigationPolicyTests: TestCase { ), .cancel ) + } + + func testMainFramePortMismatchIsCancelled() { XCTAssertEqual( WebViewNavigationPolicy.policy( for: URL(string: "https://example.com:8443/next")!, From e981bf165d517a4b4ab85419379a4f1334eb3d1b Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Mon, 20 Jul 2026 14:13:12 +0200 Subject: [PATCH 4/8] Log web_view content-rule compile failures at error level A compile failure means the WebView runs without third-party isolation, so surface it at error level instead of debug where it would be invisible in production. Co-authored-by: Cursor --- .../Templates/V2/Components/WebView/WebViewIsolation.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift index e63cbcdc1f..823598c6f2 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift @@ -30,7 +30,7 @@ enum WebViewIsolation { encodedContentRuleList: rules ) { ruleList, error in if let error { - Logger.debug(Strings.paywall_web_view_content_rules_failed(String(describing: error))) + Logger.error(Strings.paywall_web_view_content_rules_failed(String(describing: error))) } continuation.resume(returning: ruleList) } From a004956da2516ce3f988a8a15486a93c8f971143 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Mon, 20 Jul 2026 14:17:40 +0200 Subject: [PATCH 5/8] Canonicalize expectedOrigin in navigation policy Normalize both the URL origin and expectedOrigin before comparing so a non-canonical expectedOrigin (mixed case or explicit default port) no longer cancels legitimate same-origin navigations. An unparseable or host-less expectedOrigin still fails closed. Co-authored-by: Cursor --- .../WebView/WebViewOriginPolicy.swift | 6 +++- .../WebViewNavigationPolicyTests.swift | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift index 41dcf23204..81c185fbb1 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift @@ -38,9 +38,13 @@ enum WebViewNavigationPolicy { // The origin check is enforced on every frame, including sub-frames, so cross-origin iframes // cannot navigate freely even if a third-party document load slips past the content rules. // `isMainFrame` is kept for call-site context and potential future differentiation. + // Both sides are canonicalized so a non-canonical `expectedOrigin` (mixed case, explicit + // default port) doesn't cause legitimate same-origin navigations to be cancelled. static func policy(for url: URL?, isMainFrame: Bool, expectedOrigin: String) -> WKNavigationActionPolicy { guard let url, - WebViewOrigin.origin(of: url) == expectedOrigin else { + let origin = WebViewOrigin.origin(of: url), + let expected = URL(string: expectedOrigin).flatMap(WebViewOrigin.origin(of:)), + origin == expected else { return .cancel } return .allow diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift index 9345f2d83b..206f4a8757 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift @@ -88,6 +88,39 @@ final class WebViewNavigationPolicyTests: TestCase { ) } + func testMainFrameNonCanonicalExpectedOriginCaseIsAllowed() { + XCTAssertEqual( + WebViewNavigationPolicy.policy( + for: URL(string: "https://example.com/next")!, + isMainFrame: true, + expectedOrigin: "https://Example.COM" + ), + .allow + ) + } + + func testMainFrameNonCanonicalExpectedOriginDefaultPortIsAllowed() { + XCTAssertEqual( + WebViewNavigationPolicy.policy( + for: URL(string: "https://example.com/next")!, + isMainFrame: true, + expectedOrigin: "https://example.com:443" + ), + .allow + ) + } + + func testMainFrameHostlessExpectedOriginIsCancelled() { + XCTAssertEqual( + WebViewNavigationPolicy.policy( + for: URL(string: "https://example.com/next")!, + isMainFrame: true, + expectedOrigin: "https:///no-host" + ), + .cancel + ) + } + func testOriginStripsDefaultPortKeepsNonDefaultAndNormalizesCase() { XCTAssertEqual( WebViewOrigin.origin(of: URL(string: "https://Example.COM:443/path")!), From 2af9e9a12f82b507790e80c256a9d37bc5a6b6d4 Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Mon, 20 Jul 2026 18:22:11 +0200 Subject: [PATCH 6/8] other(paywalls): drop web_view content-blocking rules in favor of CSP The bundle's served CSP (default-src 'self', per-bundle origin) already blocks cross-origin resource and document loads more completely than the WKContentRuleList did (it also covers stylesheets, media, WebSockets and workers), so the SDK-side content rules were redundant. The navigation origin-lock is kept: CSP has no directive that gates top-level navigation. Co-authored-by: Cursor --- RevenueCat.xcodeproj/project.pbxproj | 6 -- RevenueCatUI/Data/Strings.swift | 6 -- .../Components/WebView/WebViewIsolation.swift | 63 --------------- .../WebView/WebViewOriginPolicy.swift | 3 +- .../PaywallsV2/WebViewIsolationTests.swift | 77 ------------------- 5 files changed, 2 insertions(+), 153 deletions(-) delete mode 100644 RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift delete mode 100644 Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 713c5fe4c9..46dffac121 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -109,7 +109,6 @@ 16DA8ECD2E4EE33700283940 /* ImageComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */; }; 16DA8EDF2E4F6A6600283940 /* PaywallVideoComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */; }; 16DA8EF42E4F7A2500283940 /* VideoComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */; }; - 1401E951ACE24C7B84F94B7F /* WebViewIsolation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */; }; FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */; }; FA4A2DF41FC434A9401F08CC /* PaywallWebViewAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */; }; 16DA8F1E2E4FB6E200283940 /* ImageComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */; }; @@ -1674,9 +1673,7 @@ 16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageComponentTests.swift; sourceTree = ""; }; 16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallVideoComponent.swift; sourceTree = ""; }; 16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoComponentTests.swift; sourceTree = ""; }; - 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewIsolation.swift; sourceTree = ""; }; 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewOriginPolicy.swift; sourceTree = ""; }; - 19B79CAD4C423F576E7DFBA9 /* WebViewIsolationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewIsolationTests.swift; sourceTree = ""; }; 9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNavigationPolicyTests.swift; sourceTree = ""; }; 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewAPI.swift; sourceTree = ""; }; 686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewValueTests.swift; sourceTree = ""; }; @@ -3242,7 +3239,6 @@ 030890822D2B77DD0069677B /* PaywallsV2 */ = { isa = PBXGroup; children = ( - 19B79CAD4C423F576E7DFBA9 /* WebViewIsolationTests.swift */, 9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */, 686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */, 802593842FE07057005AF6DF /* PaywallStateStoreTests.swift */, @@ -3382,7 +3378,6 @@ 2561352F00BE1ECE1BAC4E9A /* WebView */ = { isa = PBXGroup; children = ( - 833251CB0C6FE0BA16C2AC41 /* WebViewIsolation.swift */, 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */, 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewAPI.swift */, ); @@ -8336,7 +8331,6 @@ 164681D02E6B577600854AA5 /* VideoComponentView.swift in Sources */, 164681D12E6B577600854AA5 /* VideoPlayerViewUIView.swift in Sources */, 164681D62E6B577600854AA5 /* VideoPlayerLayerUIView.swift in Sources */, - 1401E951ACE24C7B84F94B7F /* WebViewIsolation.swift in Sources */, FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */, FA4A2DF41FC434A9401F08CC /* PaywallWebViewAPI.swift in Sources */, DBAA1FC22F8D4ED8000E8C81 /* HeaderNestedHeroZLayerSafeAreaPreview.swift in Sources */, diff --git a/RevenueCatUI/Data/Strings.swift b/RevenueCatUI/Data/Strings.swift index 856d89fc92..64ecf81dbf 100644 --- a/RevenueCatUI/Data/Strings.swift +++ b/RevenueCatUI/Data/Strings.swift @@ -109,9 +109,6 @@ enum Strings { case video_failed_to_set_audio_session_category(Error) case video_failed_to_cache(URL, Error) - // WebView - case paywall_web_view_content_rules_failed(String) - // Exit Offers case errorFetchingOfferings(Error) case exitOfferNotFound(String) @@ -367,9 +364,6 @@ extension Strings: CustomStringConvertible { case .video_failed_to_cache(let url, let error): return "Failed to cache video at \(url): \(error)" - case .paywall_web_view_content_rules_failed(let error): - return "Paywalls V2 web_view content rules failed: \(error)" - case .errorFetchingOfferings(let error): return "Error fetching offerings: \(error)" case .exitOfferNotFound(let offeringId): diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift deleted file mode 100644 index 823598c6f2..0000000000 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewIsolation.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation -@_spi(Internal) import RevenueCat - -#if !os(tvOS) && canImport(WebKit) // For Paywalls V2 -import WebKit - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -enum WebViewIsolation { - - static let contentRuleListIdentifier = "rc-webview-v2-isolation" - - static let contentBlockingRules = """ - [ - { - "trigger": { - "url-filter": ".*", - "resource-type": ["image", "script", "font", "raw", "style-sheet", "media", "document"], - "load-type": ["third-party"] - }, - "action": {"type": "block"} - } - ] - """ - - @MainActor - static var compileRuleList: @MainActor (String, String) async -> WKContentRuleList? = { identifier, rules in - await withCheckedContinuation { continuation in - WKContentRuleListStore.default().compileContentRuleList( - forIdentifier: identifier, - encodedContentRuleList: rules - ) { ruleList, error in - if let error { - Logger.error(Strings.paywall_web_view_content_rules_failed(String(describing: error))) - } - continuation.resume(returning: ruleList) - } - } - } - - @MainActor - private static var ruleListTask: Task? - - @MainActor - static func ruleList() async -> WKContentRuleList? { - if let ruleListTask { - return await ruleListTask.value - } - - let task = Task { @MainActor in - await Self.compileRuleList(Self.contentRuleListIdentifier, Self.contentBlockingRules) - } - self.ruleListTask = task - return await task.value - } - - @MainActor - static func resetRuleListCacheForTests() { - self.ruleListTask = nil - } - -} - -#endif diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift index 81c185fbb1..8fd2d36d5e 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift @@ -36,7 +36,8 @@ enum WebViewOrigin { enum WebViewNavigationPolicy { // The origin check is enforced on every frame, including sub-frames, so cross-origin iframes - // cannot navigate freely even if a third-party document load slips past the content rules. + // cannot navigate freely. The bundle's CSP blocks cross-origin resource and document loads; + // this is the navigation-level counterpart, which CSP has no directive to cover. // `isMainFrame` is kept for call-site context and potential future differentiation. // Both sides are canonicalized so a non-canonical `expectedOrigin` (mixed case, explicit // default port) doesn't cause legitimate same-origin navigations to be cancelled. diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift deleted file mode 100644 index 560eefbf0a..0000000000 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewIsolationTests.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// Copyright RevenueCat Inc. All Rights Reserved. -// - -@testable import RevenueCatUI -import XCTest - -#if !os(tvOS) && canImport(WebKit) -import WebKit - -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -@MainActor -final class WebViewIsolationTests: TestCase { - - func testContentRulesShape() throws { - XCTAssertEqual(WebViewIsolation.contentRuleListIdentifier, "rc-webview-v2-isolation") - let rules = WebViewIsolation.contentBlockingRules - let data = try XCTUnwrap(rules.data(using: .utf8)) - let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [[String: Any]]) - - XCTAssertEqual(object.count, 1) - let trigger = try XCTUnwrap(object[0]["trigger"] as? [String: Any]) - XCTAssertEqual( - trigger["resource-type"] as? [String], - ["image", "script", "font", "raw", "style-sheet", "media", "document"] - ) - XCTAssertEqual(trigger["load-type"] as? [String], ["third-party"]) - let action = try XCTUnwrap(object[0]["action"] as? [String: Any]) - XCTAssertEqual(action["type"] as? String, "block") - XCTAssertFalse(rules.contains(#""url-filter": "data:"#)) - } - - func testContentBlockingRulesCompileThroughWebKit() async throws { - let identifier = "\(WebViewIsolation.contentRuleListIdentifier)-integration-test" - - let ruleList: WKContentRuleList? = await withCheckedContinuation { continuation in - WKContentRuleListStore.default().compileContentRuleList( - forIdentifier: identifier, - encodedContentRuleList: WebViewIsolation.contentBlockingRules - ) { compiled, _ in - continuation.resume(returning: compiled) - } - } - - XCTAssertNotNil(ruleList) - - await withCheckedContinuation { (continuation: CheckedContinuation) in - WKContentRuleListStore.default().removeContentRuleList(forIdentifier: identifier) { _ in - continuation.resume() - } - } - } - - func testCompileFailureReturnsNilAndCaches() async { - WebViewIsolation.resetRuleListCacheForTests() - var calls = 0 - let original = WebViewIsolation.compileRuleList - WebViewIsolation.compileRuleList = { _, _ in - calls += 1 - return nil - } - defer { - WebViewIsolation.compileRuleList = original - WebViewIsolation.resetRuleListCacheForTests() - } - - let first = await WebViewIsolation.ruleList() - let second = await WebViewIsolation.ruleList() - - XCTAssertNil(first) - XCTAssertNil(second) - XCTAssertEqual(calls, 1) - } - -} - -#endif From 93c577009aee5da002ebb7244c880464e1b1b9fc Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Mon, 20 Jul 2026 20:24:07 +0200 Subject: [PATCH 7/8] other(paywalls): drop unused isMainFrame param from navigation policy The origin check runs on every frame identically, so `isMainFrame` was never read. Remove it, drop the now-redundant sub-frame tests, and trim comments to current behavior. Co-authored-by: Cursor --- .../WebView/WebViewOriginPolicy.swift | 8 +--- .../WebViewNavigationPolicyTests.swift | 46 ++++--------------- 2 files changed, 10 insertions(+), 44 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift index 8fd2d36d5e..e1d831119e 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift @@ -36,12 +36,8 @@ enum WebViewOrigin { enum WebViewNavigationPolicy { // The origin check is enforced on every frame, including sub-frames, so cross-origin iframes - // cannot navigate freely. The bundle's CSP blocks cross-origin resource and document loads; - // this is the navigation-level counterpart, which CSP has no directive to cover. - // `isMainFrame` is kept for call-site context and potential future differentiation. - // Both sides are canonicalized so a non-canonical `expectedOrigin` (mixed case, explicit - // default port) doesn't cause legitimate same-origin navigations to be cancelled. - static func policy(for url: URL?, isMainFrame: Bool, expectedOrigin: String) -> WKNavigationActionPolicy { + // cannot navigate freely. + static func policy(for url: URL?, expectedOrigin: String) -> WKNavigationActionPolicy { guard let url, let origin = WebViewOrigin.origin(of: url), let expected = URL(string: expectedOrigin).flatMap(WebViewOrigin.origin(of:)), diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift index 206f4a8757..b0389a2b3c 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift @@ -11,110 +11,80 @@ import WebKit @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) final class WebViewNavigationPolicyTests: TestCase { - func testMainFrameSameOriginIsAllowed() { + func testSameOriginIsAllowed() { XCTAssertEqual( WebViewNavigationPolicy.policy( for: URL(string: "https://example.com/next")!, - isMainFrame: true, expectedOrigin: "https://example.com" ), .allow ) } - func testMainFrameCrossOriginIsCancelled() { + func testCrossOriginIsCancelled() { XCTAssertEqual( WebViewNavigationPolicy.policy( for: URL(string: "https://evil.example/next")!, - isMainFrame: true, expectedOrigin: "https://example.com" ), .cancel ) } - func testSubFrameSameOriginIsAllowed() { - XCTAssertEqual( - WebViewNavigationPolicy.policy( - for: URL(string: "https://example.com/next")!, - isMainFrame: false, - expectedOrigin: "https://example.com" - ), - .allow - ) - } - - func testSubFrameCrossOriginIsCancelled() { - XCTAssertEqual( - WebViewNavigationPolicy.policy( - for: URL(string: "https://evil.example/next")!, - isMainFrame: false, - expectedOrigin: "https://example.com" - ), - .cancel - ) - } - - func testMainFrameNilURLIsCancelled() { + func testNilURLIsCancelled() { XCTAssertEqual( WebViewNavigationPolicy.policy( for: nil, - isMainFrame: true, expectedOrigin: "https://example.com" ), .cancel ) } - func testMainFrameSchemeDowngradeIsCancelled() { + func testSchemeDowngradeIsCancelled() { XCTAssertEqual( WebViewNavigationPolicy.policy( for: URL(string: "http://example.com/next")!, - isMainFrame: true, expectedOrigin: "https://example.com" ), .cancel ) } - func testMainFramePortMismatchIsCancelled() { + func testPortMismatchIsCancelled() { XCTAssertEqual( WebViewNavigationPolicy.policy( for: URL(string: "https://example.com:8443/next")!, - isMainFrame: true, expectedOrigin: "https://example.com" ), .cancel ) } - func testMainFrameNonCanonicalExpectedOriginCaseIsAllowed() { + func testNonCanonicalExpectedOriginCaseIsAllowed() { XCTAssertEqual( WebViewNavigationPolicy.policy( for: URL(string: "https://example.com/next")!, - isMainFrame: true, expectedOrigin: "https://Example.COM" ), .allow ) } - func testMainFrameNonCanonicalExpectedOriginDefaultPortIsAllowed() { + func testNonCanonicalExpectedOriginDefaultPortIsAllowed() { XCTAssertEqual( WebViewNavigationPolicy.policy( for: URL(string: "https://example.com/next")!, - isMainFrame: true, expectedOrigin: "https://example.com:443" ), .allow ) } - func testMainFrameHostlessExpectedOriginIsCancelled() { + func testHostlessExpectedOriginIsCancelled() { XCTAssertEqual( WebViewNavigationPolicy.policy( for: URL(string: "https://example.com/next")!, - isMainFrame: true, expectedOrigin: "https:///no-host" ), .cancel From 255cff962796f5339ae30e681c525f6ce93d384f Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Mon, 20 Jul 2026 21:53:15 +0200 Subject: [PATCH 8/8] other(paywalls): match Android web_view navigation policy Restore `isMainFrame` and align the origin policy with purchases-android (#3783): block non-https on any frame, block cross-origin only on the main frame, and leave cross-origin sub-frame isolation to the server-provided CSP (`frame-src` falls back to `default-src 'self'`). Co-authored-by: Cursor --- .../WebView/WebViewOriginPolicy.swift | 18 ++++-- .../WebViewNavigationPolicyTests.swift | 57 ++++++++++++------- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift index e1d831119e..9612cd9eca 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift @@ -35,16 +35,22 @@ enum WebViewOrigin { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) enum WebViewNavigationPolicy { - // The origin check is enforced on every frame, including sub-frames, so cross-origin iframes - // cannot navigate freely. - static func policy(for url: URL?, expectedOrigin: String) -> WKNavigationActionPolicy { + // Non-https navigation is blocked on any frame. Cross-origin navigation is additionally blocked + // on the main frame (same-origin different-path navigation stays allowed), which makes + // cross-origin message races structurally impossible. Cross-origin sub-frame loads are not + // blocked here; isolation for those is left to the server-provided CSP (`frame-src` falls back + // to `default-src 'self'`). + static func policy(for url: URL?, isMainFrame: Bool, expectedOrigin: String) -> WKNavigationActionPolicy { guard let url, let origin = WebViewOrigin.origin(of: url), - let expected = URL(string: expectedOrigin).flatMap(WebViewOrigin.origin(of:)), - origin == expected else { + origin.hasPrefix("https://") else { return .cancel } - return .allow + guard isMainFrame else { + return .allow + } + let expected = URL(string: expectedOrigin).flatMap(WebViewOrigin.origin(of:)) + return origin == expected ? .allow : .cancel } } diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift index b0389a2b3c..355732c2c4 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift @@ -11,86 +11,103 @@ import WebKit @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) final class WebViewNavigationPolicyTests: TestCase { - func testSameOriginIsAllowed() { + func testSameOriginMainFrameDifferentPathIsAllowed() { XCTAssertEqual( WebViewNavigationPolicy.policy( - for: URL(string: "https://example.com/next")!, + for: URL(string: "https://example.com/promo/step-two.html")!, + isMainFrame: true, expectedOrigin: "https://example.com" ), .allow ) } - func testCrossOriginIsCancelled() { + func testCrossOriginMainFrameIsCancelled() { XCTAssertEqual( WebViewNavigationPolicy.policy( - for: URL(string: "https://evil.example/next")!, + for: URL(string: "https://evil.example/phish.html")!, + isMainFrame: true, expectedOrigin: "https://example.com" ), .cancel ) } - func testNilURLIsCancelled() { + func testMainFramePortMismatchIsCancelled() { XCTAssertEqual( WebViewNavigationPolicy.policy( - for: nil, + for: URL(string: "https://example.com:8443/next")!, + isMainFrame: true, expectedOrigin: "https://example.com" ), .cancel ) } - func testSchemeDowngradeIsCancelled() { + func testNonHttpsIsCancelledOnAnyFrame() { XCTAssertEqual( WebViewNavigationPolicy.policy( - for: URL(string: "http://example.com/next")!, + for: URL(string: "http://example.com/promo/index.html")!, + isMainFrame: false, expectedOrigin: "https://example.com" ), .cancel ) - } - - func testPortMismatchIsCancelled() { XCTAssertEqual( WebViewNavigationPolicy.policy( - for: URL(string: "https://example.com:8443/next")!, + for: URL(string: "custom://example.com/")!, + isMainFrame: true, expectedOrigin: "https://example.com" ), .cancel ) } - func testNonCanonicalExpectedOriginCaseIsAllowed() { + func testCrossOriginHttpsSubFrameIsAllowed() { + // Sub-frame isolation is expected from the server-provided CSP, not this navigation policy. XCTAssertEqual( WebViewNavigationPolicy.policy( - for: URL(string: "https://example.com/next")!, - expectedOrigin: "https://Example.COM" + for: URL(string: "https://other.example.com/frame.html")!, + isMainFrame: false, + expectedOrigin: "https://example.com" ), .allow ) } - func testNonCanonicalExpectedOriginDefaultPortIsAllowed() { + func testNilURLMainFrameIsCancelled() { XCTAssertEqual( WebViewNavigationPolicy.policy( - for: URL(string: "https://example.com/next")!, - expectedOrigin: "https://example.com:443" + for: nil, + isMainFrame: true, + expectedOrigin: "https://example.com" ), - .allow + .cancel ) } - func testHostlessExpectedOriginIsCancelled() { + func testHostlessExpectedOriginMainFrameIsCancelled() { XCTAssertEqual( WebViewNavigationPolicy.policy( for: URL(string: "https://example.com/next")!, + isMainFrame: true, expectedOrigin: "https:///no-host" ), .cancel ) } + func testNonCanonicalExpectedOriginCaseAndDefaultPortIsAllowed() { + XCTAssertEqual( + WebViewNavigationPolicy.policy( + for: URL(string: "https://example.com/promo/index.html")!, + isMainFrame: true, + expectedOrigin: "https://Example.COM:443" + ), + .allow + ) + } + func testOriginStripsDefaultPortKeepsNonDefaultAndNormalizesCase() { XCTAssertEqual( WebViewOrigin.origin(of: URL(string: "https://Example.COM:443/path")!),