Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
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 */; };
FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */; };
FA4A2DF41FC434A9401F08CC /* PaywallWebViewValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewValue.swift */; };
11CC2EA83C56933E40D8E5D8 /* WebViewEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */; };
16DA8F1E2E4FB6E200283940 /* ImageComponent.json in Resources */ = {isa = PBXBuildFile; fileRef = 16DA8F1B2E4FB6E200283940 /* ImageComponent.json */; };
Expand Down Expand Up @@ -1673,6 +1674,8 @@
16DA8ECC2E4EE33700283940 /* ImageComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageComponentTests.swift; sourceTree = "<group>"; };
16DA8EDE2E4F6A6600283940 /* PaywallVideoComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallVideoComponent.swift; sourceTree = "<group>"; };
16DA8EF32E4F7A2500283940 /* VideoComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoComponentTests.swift; sourceTree = "<group>"; };
28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewOriginPolicy.swift; sourceTree = "<group>"; };
9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNavigationPolicyTests.swift; sourceTree = "<group>"; };
9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewValue.swift; sourceTree = "<group>"; };
89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewEnvelope.swift; sourceTree = "<group>"; };
C1B939C7DAAFEA1DE2DE2674 /* WebViewEnvelopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewEnvelopeTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3239,6 +3242,7 @@
030890822D2B77DD0069677B /* PaywallsV2 */ = {
isa = PBXGroup;
children = (
9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */,
686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */,
C1B939C7DAAFEA1DE2DE2674 /* WebViewEnvelopeTests.swift */,
802593842FE07057005AF6DF /* PaywallStateStoreTests.swift */,
Expand Down Expand Up @@ -3375,9 +3379,10 @@
path = Video;
sourceTree = "<group>";
};
EB23E44596DD9714E81840CF /* WebView */ = {
2561352F00BE1ECE1BAC4E9A /* WebView */ = {
isa = PBXGroup;
children = (
28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */,
9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewValue.swift */,
89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */,
);
Expand Down Expand Up @@ -3475,7 +3480,7 @@
0387D4AD2EC58AA5008E4A6B /* Countdown */,
E1C0A00BBEEF0001CCDD0001 /* Header */,
164681CD2E6B577600854AA5 /* Video */,
EB23E44596DD9714E81840CF /* WebView */,
2561352F00BE1ECE1BAC4E9A /* WebView */,
2C7457472CEA66AB004ACE52 /* ComponentsView.swift */,
7707A94A2CAD936A006E0313 /* Button */,
03C06FC82D479C6300600693 /* Carousel */,
Expand Down Expand Up @@ -8331,6 +8336,7 @@
164681D02E6B577600854AA5 /* VideoComponentView.swift in Sources */,
164681D12E6B577600854AA5 /* VideoPlayerViewUIView.swift in Sources */,
164681D62E6B577600854AA5 /* VideoPlayerLayerUIView.swift in Sources */,
FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */,
FA4A2DF41FC434A9401F08CC /* PaywallWebViewValue.swift in Sources */,
11CC2EA83C56933E40D8E5D8 /* WebViewEnvelope.swift in Sources */,
DBAA1FC22F8D4ED8000E8C81 /* HeaderNestedHeroZLayerSafeAreaPreview.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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 {

// 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),
origin.hasPrefix("https://") else {
return .cancel
Comment thread
cursor[bot] marked this conversation as resolved.
}
guard isMainFrame else {
return .allow
}
let expected = URL(string: expectedOrigin).flatMap(WebViewOrigin.origin(of:))
return origin == expected ? .allow : .cancel
}

}

#endif
Comment thread
ajpallares marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//
// 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 testSameOriginMainFrameDifferentPathIsAllowed() {
XCTAssertEqual(
WebViewNavigationPolicy.policy(
for: URL(string: "https://example.com/promo/step-two.html")!,
isMainFrame: true,
expectedOrigin: "https://example.com"
),
.allow
)
}

func testCrossOriginMainFrameIsCancelled() {
XCTAssertEqual(
WebViewNavigationPolicy.policy(
for: URL(string: "https://evil.example/phish.html")!,
isMainFrame: true,
expectedOrigin: "https://example.com"
),
.cancel
)
}

func testMainFramePortMismatchIsCancelled() {
XCTAssertEqual(
WebViewNavigationPolicy.policy(
for: URL(string: "https://example.com:8443/next")!,
isMainFrame: true,
expectedOrigin: "https://example.com"
),
.cancel
)
}

func testNonHttpsIsCancelledOnAnyFrame() {
XCTAssertEqual(
WebViewNavigationPolicy.policy(
for: URL(string: "http://example.com/promo/index.html")!,
isMainFrame: false,
expectedOrigin: "https://example.com"
),
.cancel
)
XCTAssertEqual(
WebViewNavigationPolicy.policy(
for: URL(string: "custom://example.com/")!,
isMainFrame: true,
expectedOrigin: "https://example.com"
),
.cancel
)
}

func testCrossOriginHttpsSubFrameIsAllowed() {
// Sub-frame isolation is expected from the server-provided CSP, not this navigation policy.
XCTAssertEqual(
WebViewNavigationPolicy.policy(
for: URL(string: "https://other.example.com/frame.html")!,
isMainFrame: false,
expectedOrigin: "https://example.com"
),
.allow
)
}

func testNilURLMainFrameIsCancelled() {
XCTAssertEqual(
WebViewNavigationPolicy.policy(
for: nil,
isMainFrame: true,
expectedOrigin: "https://example.com"
),
.cancel
)
}

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")!),
"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"
)
XCTAssertNil(WebViewOrigin.origin(of: URL(string: "https:///no-host")!))
}
Comment thread
ajpallares marked this conversation as resolved.

}

#endif