diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index 65d8371bb2..3b66ef46b4 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -114,6 +114,7 @@ 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 */; }; + 0F4E490A741F00F18C4985D8 /* WebViewSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72CC1CD0385BF18D1392EB43 /* WebViewSession.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 */; }; @@ -1684,6 +1685,8 @@ 89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewEnvelope.swift; sourceTree = ""; }; C1B939C7DAAFEA1DE2DE2674 /* WebViewEnvelopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewEnvelopeTests.swift; sourceTree = ""; }; 686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallWebViewValueTests.swift; sourceTree = ""; }; + 72CC1CD0385BF18D1392EB43 /* WebViewSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSession.swift; sourceTree = ""; }; + F75819E258F47D1DB058500D /* WebViewSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewSessionTests.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 = ""; }; @@ -3250,6 +3253,7 @@ 9E7AD021B4580265DFDF6A02 /* WebViewNavigationPolicyTests.swift */, 686562C08D953A27986DB276 /* PaywallWebViewValueTests.swift */, C1B939C7DAAFEA1DE2DE2674 /* WebViewEnvelopeTests.swift */, + F75819E258F47D1DB058500D /* WebViewSessionTests.swift */, 802593842FE07057005AF6DF /* PaywallStateStoreTests.swift */, 16A9F7B62FAA22D0008E8A4D /* VideoComponentViewTests.swift */, A9D45E4EAFAD4C0385BEDFB3 /* VideoPlayerViewTests.swift */, @@ -3390,6 +3394,7 @@ 28661C6CE3F64078A0138080 /* WebViewOriginPolicy.swift */, 9D0C57ECDF0E7F1A5B4418FF /* PaywallWebViewValue.swift */, 89DD761C9E00E61032AAEB44 /* WebViewEnvelope.swift */, + 72CC1CD0385BF18D1392EB43 /* WebViewSession.swift */, ); path = WebView; sourceTree = ""; @@ -8347,6 +8352,7 @@ FC5AE63E8F81C3EC2FFDA408 /* WebViewOriginPolicy.swift in Sources */, FA4A2DF41FC434A9401F08CC /* PaywallWebViewValue.swift in Sources */, 11CC2EA83C56933E40D8E5D8 /* WebViewEnvelope.swift in Sources */, + 0F4E490A741F00F18C4985D8 /* WebViewSession.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..5a04c0d1fc 100644 --- a/RevenueCatUI/Data/Strings.swift +++ b/RevenueCatUI/Data/Strings.swift @@ -109,6 +109,12 @@ enum Strings { case video_failed_to_set_audio_session_category(Error) case video_failed_to_cache(URL, Error) + // WebView + case paywall_web_view_message_rejected(reason: String) + case paywall_web_view_post_message_failed(String) + case paywall_web_view_post_message_skipped(reason: String) + case paywall_web_view_invalid_expected_origin(String) + // Exit Offers case errorFetchingOfferings(Error) case exitOfferNotFound(String) @@ -364,6 +370,16 @@ extension Strings: CustomStringConvertible { case .video_failed_to_cache(let url, let error): return "Failed to cache video at \(url): \(error)" + case .paywall_web_view_message_rejected(let reason): + return "Paywalls V2 web_view message rejected: \(reason)" + case .paywall_web_view_post_message_failed(let error): + return "Paywalls V2 web_view postMessage failed: \(error)" + case .paywall_web_view_post_message_skipped(let reason): + return "Paywalls V2 web_view postMessage skipped: \(reason)" + case .paywall_web_view_invalid_expected_origin(let origin): + return "Paywalls V2 web_view expected origin '\(origin)' is not a valid origin; " + + "the bridge cannot verify message provenance and will reject all messages." + case .errorFetchingOfferings(let error): return "Error fetching offerings: \(error)" case .exitOfferNotFound(let offeringId): diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift index 58d972b9ee..504cec1a2d 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewEnvelope.swift @@ -1,3 +1,7 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + import Foundation #if !os(tvOS) // For Paywalls V2 @@ -7,6 +11,8 @@ enum WebViewEnvelope { static let channel = "rc-web-components" static let messageHandlerName = "rcWebComponents" + /// Name of the JS function injected into the web view that receives host-to-content frames. + static let receiveFunction = "__rcWebComponentsReceive" static let defaultProtocolVersion = 1 static let messageTypeResize = "resize" diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift index 9612cd9eca..7eb342ce85 100644 --- a/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewOriginPolicy.swift @@ -1,24 +1,47 @@ -import Foundation - -#if canImport(WebKit) -import WebKit -#endif +// +// Copyright RevenueCat Inc. All Rights Reserved. +// #if !os(tvOS) && canImport(WebKit) // For Paywalls V2 -@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) -enum WebViewOrigin { +import Foundation +import WebKit + +extension URL { - nonisolated static func origin(of url: URL) -> String? { - guard let scheme = url.scheme?.lowercased(), - let host = url.host?.lowercased(), + /// Canonical origin (`scheme://host[:port]`) of the URL, or `nil` if it has no scheme or host. + nonisolated var webViewOrigin: String? { + guard let scheme = self.scheme?.lowercased(), + let host = self.host?.lowercased(), !host.isEmpty else { return nil } + return WebViewOrigin.canonicalOrigin(scheme: scheme, host: host, port: self.port) + } + +} + +extension WKSecurityOrigin { + + /// Canonical origin of the frame that posted a script message. Uses the frame's security origin + /// (the authoritative sender) rather than the WebView's top-level URL. + var webViewOrigin: String? { + let scheme = self.`protocol`.lowercased() + let host = self.host.lowercased() + guard !scheme.isEmpty, !host.isEmpty else { + return nil + } + // `WKSecurityOrigin` reports `0` for the scheme's default port. + return WebViewOrigin.canonicalOrigin(scheme: scheme, host: host, port: self.port == 0 ? nil : self.port) + } + +} + +private enum WebViewOrigin { - let port = url.port + nonisolated static func canonicalOrigin(scheme: String, host: String, port: Int?) -> String { let suffix: String - if let port, !Self.isDefaultPort(port, scheme: scheme) { + if let port, !isDefaultPort(port, scheme: scheme) { suffix = ":\(port)" } else { suffix = "" @@ -42,14 +65,14 @@ enum WebViewNavigationPolicy { // 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 origin = url.webViewOrigin, origin.hasPrefix("https://") else { return .cancel } guard isMainFrame else { return .allow } - let expected = URL(string: expectedOrigin).flatMap(WebViewOrigin.origin(of:)) + let expected = URL(string: expectedOrigin)?.webViewOrigin return origin == expected ? .allow : .cancel } diff --git a/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift new file mode 100644 index 0000000000..eee5590a2f --- /dev/null +++ b/RevenueCatUI/Templates/V2/Components/WebView/WebViewSession.swift @@ -0,0 +1,297 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + +#if !os(tvOS) && canImport(WebKit) // For Paywalls V2 + +import Foundation +@_spi(Internal) import RevenueCat +import WebKit + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@MainActor +final class WebViewSession: NSObject, ObservableObject, WKScriptMessageHandler { + + let componentID: String + /// The canonical origin every message is gated against, or `nil` when the caller's origin + /// could not be normalized. A `nil` origin makes the bridge inert (all traffic is rejected). + let expectedOrigin: String? + var onContentResize: (@MainActor (CGFloat?, CGFloat?) -> Void)? + /// Invoked from ``resetForNewDocument()`` so the SwiftUI host can clear measured fit sizes. + var onDocumentReset: (@MainActor () -> Void)? + private(set) var channelOpen = false + + var evaluateJavaScript: (String) -> Void + var currentURL: () -> URL? + + let fitAxes: (width: Bool, height: Bool) + + /// The single protocol version this SDK build implements. Deliberately not the schema's + /// `protocol_version`: the host must never accept a handshake for a version it cannot service, + /// even if a future schema declares one. + private let protocolVersion = WebViewEnvelope.defaultProtocolVersion + + private var lastAppliedWidth: CGFloat? + private var lastAppliedHeight: CGFloat? + + init( + componentID: String, + expectedOrigin: String, + fitAxes: (width: Bool, height: Bool), + evaluateJavaScript: @escaping (String) -> Void, + currentURL: @escaping () -> URL? + ) { + self.componentID = componentID + // Normalize to a canonical origin so comparisons match the navigation policy, whether the + // caller passes a bare origin or a full URL. A value that cannot be normalized is kept `nil` + // so the bridge stays inert and the misconfiguration is diagnosable. + let normalizedOrigin = URL(string: expectedOrigin)?.webViewOrigin + if normalizedOrigin == nil { + Logger.warning(Strings.paywall_web_view_invalid_expected_origin(expectedOrigin)) + } + self.expectedOrigin = normalizedOrigin + self.fitAxes = fitAxes + self.evaluateJavaScript = evaluateJavaScript + self.currentURL = currentURL + } + + /// Resets handshake and resize thresholds for a new main-frame document. + /// + /// Each committed main-frame navigation creates a new JS document that must re-handshake. + /// Without this, `connect` after a reload is dropped by the `channelOpen` guard forever. + func resetForNewDocument() { + self.channelOpen = false + self.lastAppliedWidth = nil + self.lastAppliedHeight = nil + self.onDocumentReset?() + } + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + // Validate against the origin of the frame that actually posted the message, not the + // WebView's top-level URL (which can lag behind navigations). + let sourceOrigin = message.frameInfo.securityOrigin.webViewOrigin + let isMainFrame = message.frameInfo.isMainFrame + let body = message.body + self.handle(rawMessage: body, isMainFrame: isMainFrame, sourceOrigin: sourceOrigin) + } + + func handle(rawMessage: Any, isMainFrame: Bool, sourceOrigin: String?) { + guard self.isSourceTrusted(sourceOrigin: sourceOrigin, isMainFrame: isMainFrame) else { + self.logRejected("untrusted-source") + return + } + guard let envelope = WebViewEnvelope.decode(rawMessage: rawMessage) else { + self.logRejected("malformed-envelope") + return + } + + if envelope.kind == .connect { + self.handleConnect(protocolVersion: envelope.protocolVersion) + return + } + + guard let type = self.validatedAppFrameType(envelope) else { + return + } + + switch type { + case WebViewEnvelope.messageTypeResize: + self.handleResize(envelope.payload) + default: + self.logRejected("unknown-message-type") + } + } + + /// Validates a post-handshake frame and returns its app message type, or `nil` (logged) when + /// the frame must be dropped. + private func validatedAppFrameType(_ envelope: WebViewEnvelope.Envelope) -> String? { + guard self.channelOpen else { + self.logRejected("channel-closed") + return nil + } + // App frames ride only `message`/`request`; other kinds (init/reject/response/error) + // are host-to-content or reply framing and must be dropped. + guard envelope.kind == .message || envelope.kind == .request else { + self.logRejected("unsupported-kind") + return nil + } + if envelope.kind == .request, envelope.id == nil { + self.logRejected("request-without-id") + return nil + } + guard envelope.componentID == self.componentID else { + self.logRejected("component-id-mismatch") + return nil + } + guard let type = envelope.type else { + self.logRejected("missing-type") + return nil + } + return type + } + + private func handleConnect(protocolVersion: Int) { + guard !self.channelOpen else { + return + } + + if protocolVersion == self.protocolVersion { + self.channelOpen = true + // Handshake replies (`init` and the follow-up fit message) use `allowBeforeNavigation: + // true`: the `connect` that triggered them was already gated against the authoritative + // sender-frame origin, but the WebView's top-level `url` may not be populated yet this + // early, and dropping these would leave the bridge half-open. The stricter current-URL + // check still applies to every later send. + self.send(.init(kind: .`init`, componentID: self.componentID), allowBeforeNavigation: true) + self.sendFitMessageIfNeeded() + } else { + let error = "Unsupported protocol_version \(protocolVersion); " + + "native host supports \(self.protocolVersion)" + self.send(.init(kind: .reject, componentID: "", error: error), allowBeforeNavigation: true) + } + } + + private func handleResize(_ payload: [String: PaywallWebViewValue]?) { + guard let payload else { + return + } + + let width = self.validResizeValue(payload["width"]?.numberValue, axisIsFit: self.fitAxes.width) + let height = self.validResizeValue(payload["height"]?.numberValue, axisIsFit: self.fitAxes.height) + let appliedWidth = self.resizeValue(width, lastApplied: &self.lastAppliedWidth) + let appliedHeight = self.resizeValue(height, lastApplied: &self.lastAppliedHeight) + + if appliedWidth != nil || appliedHeight != nil { + self.onContentResize?(appliedWidth, appliedHeight) + } + } + + private func sendFitMessageIfNeeded() { + var payload: [String: PaywallWebViewValue] = [:] + if self.fitAxes.width { + payload["width"] = .bool(true) + } + if self.fitAxes.height { + payload["height"] = .bool(true) + } + guard !payload.isEmpty else { + return + } + self.send( + .init( + kind: .message, + componentID: self.componentID, + type: WebViewEnvelope.messageTypeFit, + payload: payload + ), + allowBeforeNavigation: true + ) + } + + private func send(_ envelope: WebViewEnvelope.Envelope, allowBeforeNavigation: Bool) { + guard self.channelOpen || envelope.kind == .reject else { + Logger.warning(Strings.paywall_web_view_post_message_skipped(reason: "channel-not-open")) + return + } + // Defense in depth: drop outbound frames if the top-level URL left the expected origin. + guard self.isCurrentURLTrusted(allowBeforeNavigation: allowBeforeNavigation) else { + Logger.warning(Strings.paywall_web_view_post_message_skipped(reason: "untrusted-current-url")) + return + } + guard let script = Self.receiveScript(for: envelope) else { + Logger.debug(Strings.paywall_web_view_post_message_failed("encoding failed")) + return + } + + self.evaluateJavaScript(script) + } + + /// Serializes `envelope` into the JS snippet injected into the web view to deliver a + /// host-to-content frame. This is bridge/transport behavior, so it lives here rather than + /// on the envelope data model. + nonisolated static func receiveScript(for envelope: WebViewEnvelope.Envelope) -> String? { + guard let data = try? JSONEncoder().encode(envelope), + let json = String(data: data, encoding: .utf8) else { + return nil + } + // Escape JS line terminators that are legal in JSON but not in JS string literals. + let escaped = json + .replacingOccurrences(of: "\u{2028}", with: "\\u2028") // line separator + .replacingOccurrences(of: "\u{2029}", with: "\\u2029") // paragraph separator + + let receiveFunction = WebViewEnvelope.receiveFunction + return """ + (function(){var m=\(escaped);if(typeof window.\(receiveFunction)==='function'){\ + window.\(receiveFunction)(m);}})(); + """ + } + + private func validResizeValue(_ value: Double?, axisIsFit: Bool) -> CGFloat? { + guard axisIsFit, + let value, + value.isFinite, + value > 0 else { + return nil + } + + return min(CGFloat(value), WebViewEnvelope.maxResizePoints) + } + + private func resizeValue(_ value: CGFloat?, lastApplied: inout CGFloat?) -> CGFloat? { + guard let value else { + return nil + } + if let lastApplied, abs(value - lastApplied) < WebViewEnvelope.resizeThreshold { + return nil + } + lastApplied = value + return value + } + + /// Whether the message came from the expected origin on the main frame. Uses the sender frame's + /// origin (the authoritative source); subframe messages are always rejected — isolation for + /// those is expected from the server CSP. + private func isSourceTrusted(sourceOrigin: String?, isMainFrame: Bool) -> Bool { + guard isMainFrame, let expectedOrigin = self.expectedOrigin, let sourceOrigin else { + return false + } + return sourceOrigin == expectedOrigin + } + + /// Whether the WebView's current top-level URL still has the expected origin. Used only as an + /// outbound defense-in-depth check; inbound traffic is gated by ``isSourceTrusted(sourceOrigin:isMainFrame:)``. + private func isCurrentURLTrusted(allowBeforeNavigation: Bool) -> Bool { + guard let expectedOrigin = self.expectedOrigin else { + return false + } + guard let currentURL = self.currentURL() else { + return allowBeforeNavigation + } + return currentURL.webViewOrigin == expectedOrigin + } + + private func logRejected(_ reason: String) { + Logger.warning(Strings.paywall_web_view_message_rejected(reason: reason)) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { + + weak var target: WKScriptMessageHandler? + + init(_ target: WKScriptMessageHandler) { + self.target = target + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + self.target?.userContentController(userContentController, didReceive: message) + } + +} + +#endif diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift index 355732c2c4..7574dc355d 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewNavigationPolicyTests.swift @@ -110,18 +110,18 @@ final class WebViewNavigationPolicyTests: TestCase { func testOriginStripsDefaultPortKeepsNonDefaultAndNormalizesCase() { XCTAssertEqual( - WebViewOrigin.origin(of: URL(string: "https://Example.COM:443/path")!), + URL(string: "https://Example.COM:443/path")!.webViewOrigin, "https://example.com" ) XCTAssertEqual( - WebViewOrigin.origin(of: URL(string: "http://Example.COM:80/path")!), + URL(string: "http://Example.COM:80/path")!.webViewOrigin, "http://example.com" ) XCTAssertEqual( - WebViewOrigin.origin(of: URL(string: "HTTPS://Example.COM:8443/path")!), + URL(string: "HTTPS://Example.COM:8443/path")!.webViewOrigin, "https://example.com:8443" ) - XCTAssertNil(WebViewOrigin.origin(of: URL(string: "https:///no-host")!)) + XCTAssertNil(URL(string: "https:///no-host")!.webViewOrigin) } } diff --git a/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift b/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift new file mode 100644 index 0000000000..4ee3ff7f99 --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallsV2/WebViewSessionTests.swift @@ -0,0 +1,591 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// + +@testable import RevenueCatUI +import XCTest +// swiftlint:disable force_try + +#if canImport(WebKit) +import WebKit +#endif + +#if !os(tvOS) && canImport(WebKit) + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@MainActor +final class WebViewSessionTests: TestCase { + + func testConnectV1SendsInitAndFit() throws { + let harness = Harness(size: (width: false, height: true)) + + harness.handle(.init(kind: .connect, componentID: "", protocolVersion: 1)) + + XCTAssertTrue(harness.session.channelOpen) + let envelopes = try harness.outboundEnvelopes() + XCTAssertEqual(envelopes.map(\.kind), [.`init`, .message]) + XCTAssertEqual(envelopes[0].componentID, "web") + XCTAssertEqual(envelopes[1].type, WebViewEnvelope.messageTypeFit) + XCTAssertNil(envelopes[1].payload?["width"]) + XCTAssertEqual(envelopes[1].payload?["height"]?.boolValue, true) + } + + func testConnectV2Rejects() throws { + let harness = Harness() + + harness.handle(.init(kind: .connect, componentID: "", protocolVersion: 2)) + + XCTAssertFalse(harness.session.channelOpen) + let envelope = try XCTUnwrap(harness.outboundEnvelopes().first) + XCTAssertEqual(envelope.kind, .reject) + XCTAssertEqual(envelope.componentID, "") + XCTAssertEqual(envelope.error, "Unsupported protocol_version 2; native host supports 1") + } + + func testDropsBeforeConnectDuplicateConnectAndNonMainFrame() throws { + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + + // A resize before the handshake is dropped (channel closed). + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["height": .number(200)] + )) + XCTAssertTrue(resizes.isEmpty) + + harness.handle(.init(kind: .connect, componentID: "")) + harness.handle(.init(kind: .connect, componentID: "")) + XCTAssertEqual(try harness.outboundEnvelopes().filter { $0.kind == .`init` }.count, 1) + + // A resize from a subframe is dropped even after the channel is open. + harness.handle( + .init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["height": .number(200)] + ), + isMainFrame: false + ) + XCTAssertTrue(resizes.isEmpty) + } + + func testFitMessageDeclaresExactlyTheFitAxes() throws { + let widthOnly = Harness(size: (width: true, height: false)) + widthOnly.handle(.init(kind: .connect, componentID: "")) + let widthFit = try XCTUnwrap(widthOnly.outboundEnvelopes().last) + XCTAssertEqual(widthFit.type, WebViewEnvelope.messageTypeFit) + XCTAssertEqual(widthFit.payload?["width"]?.boolValue, true) + XCTAssertNil(widthFit.payload?["height"]) + + let both = Harness(size: (width: true, height: true)) + both.handle(.init(kind: .connect, componentID: "")) + let bothFit = try XCTUnwrap(both.outboundEnvelopes().last) + XCTAssertEqual(bothFit.type, WebViewEnvelope.messageTypeFit) + XCTAssertEqual(bothFit.payload?["width"]?.boolValue, true) + XCTAssertEqual(bothFit.payload?["height"]?.boolValue, true) + + let neither = Harness(size: (width: false, height: false)) + neither.handle(.init(kind: .connect, componentID: "")) + XCTAssertEqual(try neither.outboundEnvelopes().map(\.kind), [.`init`]) + } + + func testDropsAppFramesWithNonAppKinds() { + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + harness.connect() + + // Each carries a payload that would apply if the kind gate let it through. + for kind: WebViewEnvelope.Kind in [.`init`, .reject, .response, .error] { + harness.handle(.init( + kind: kind, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + id: "id-1", + payload: ["height": .number(200)] + )) + } + + XCTAssertTrue(resizes.isEmpty) + } + + func testDropsAnyRequestWithoutID() { + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + harness.connect() + + harness.handle(.init( + kind: .request, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["height": .number(200)] + )) + + XCTAssertTrue(resizes.isEmpty) + } + + func testDropsAppFrameFromDifferentComponent() { + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + harness.connect() + + // Same shape as an applied resize, but addressed to another component on the same page. + harness.handle(.init( + kind: .message, + componentID: "other", + type: WebViewEnvelope.messageTypeResize, + payload: ["height": .number(200)] + )) + + XCTAssertTrue(resizes.isEmpty) + } + + func testDropsUnknownMessageType() { + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + harness.connect() + + harness.handle(.init( + kind: .message, + componentID: "web", + type: "rc:not-a-real-type", + payload: ["height": .number(200)] + )) + + XCTAssertTrue(resizes.isEmpty) + } + + func testDropsMessageWithoutType() { + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + harness.connect() + + harness.handle(.init(kind: .message, componentID: "web", payload: ["height": .number(200)])) + + XCTAssertTrue(resizes.isEmpty) + } + + func testDropsEnvelopeOnUnexpectedChannel() { + let harness = Harness() + + // Well-formed connect frame, but riding another SDK's channel: it must never open ours. + harness.session.handle( + rawMessage: #"{"channel":"someone-elses-channel","protocol_version":1,"kind":"connect","component_id":""}"#, + isMainFrame: true, + sourceOrigin: Harness.expectedOrigin + ) + + XCTAssertFalse(harness.session.channelOpen) + XCTAssertTrue(harness.capturedScripts.isEmpty) + } + + func testResizeAppliesOnlyFitAxesAndThreshold() { + let harness = Harness(size: (width: true, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + harness.connect() + + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["width": .number(200), "height": .number(99_999)] + )) + harness.handle(.init( + kind: .request, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + id: "resize-1", + payload: ["width": .number(200.5), "height": .number(10_000.5)] + )) + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["width": .number(201), "height": .number(-1)] + )) + + XCTAssertEqual(resizes.count, 2) + XCTAssertEqual(resizes[0].0, 200) + XCTAssertEqual(resizes[0].1, 10_000) + XCTAssertEqual(resizes[1].0, 201) + XCTAssertNil(resizes[1].1) + } + + func testResizeIgnoresWidthWhenWidthIsNotFit() { + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + harness.connect() + + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["width": .number(400), "height": .number(500)] + )) + + XCTAssertEqual(resizes.count, 1) + XCTAssertNil(resizes[0].0) + XCTAssertEqual(resizes[0].1, 500) + } + + func testReconnectAfterDocumentReset() throws { + let harness = Harness() + harness.connect() + XCTAssertTrue(harness.session.channelOpen) + + harness.session.resetForNewDocument() + XCTAssertFalse(harness.session.channelOpen) + + harness.capturedScripts.removeAll() + harness.handle(.init(kind: .message, componentID: "web", type: WebViewEnvelope.messageTypeResize)) + XCTAssertTrue(harness.capturedScripts.isEmpty) + + harness.handle(.init(kind: .connect, componentID: "")) + XCTAssertTrue(harness.session.channelOpen) + XCTAssertEqual(try harness.outboundEnvelopes().filter { $0.kind == .`init` }.count, 1) + } + + func testDocumentResetClearsResizeThresholds() { + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + harness.connect() + + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["height": .number(200)] + )) + XCTAssertEqual(resizes.count, 1) + XCTAssertEqual(resizes[0].1, 200) + + harness.session.resetForNewDocument() + harness.connect() + harness.handle(.init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["height": .number(200)] + )) + + XCTAssertEqual(resizes.count, 2) + XCTAssertEqual(resizes[1].1, 200) + } + + func testDocumentResetInvokesOnDocumentReset() { + let harness = Harness() + var resets = 0 + harness.session.onDocumentReset = { resets += 1 } + harness.connect() + harness.session.resetForNewDocument() + XCTAssertEqual(resets, 1) + XCTAssertFalse(harness.session.channelOpen) + } + + // MARK: - Origin gating + + func testDropsInboundFromUntrustedOrigin() { + let harness = Harness(size: (width: false, height: true)) + var resizes: [(CGFloat?, CGFloat?)] = [] + harness.session.onContentResize = { resizes.append(($0, $1)) } + harness.connect() + + harness.handle( + .init( + kind: .message, + componentID: "web", + type: WebViewEnvelope.messageTypeResize, + payload: ["height": .number(200)] + ), + sourceOrigin: "https://evil.example.org" + ) + + XCTAssertTrue(resizes.isEmpty) + } + + func testDropsConnectFromUntrustedOrigin() { + let harness = Harness() + + harness.handle(.init(kind: .connect, componentID: ""), sourceOrigin: "https://evil.example.org") + + XCTAssertFalse(harness.session.channelOpen) + XCTAssertTrue(harness.capturedScripts.isEmpty) + } + + func testDropsInboundWithoutSourceOrigin() { + let harness = Harness() + + harness.handle(.init(kind: .connect, componentID: ""), sourceOrigin: nil) + + XCTAssertFalse(harness.session.channelOpen) + XCTAssertTrue(harness.capturedScripts.isEmpty) + } + + func testConnectBeforeNavigationURLIsAvailable() throws { + let harness = Harness() + harness.currentURL = nil + + harness.handle(.init(kind: .connect, componentID: "")) + + XCTAssertTrue(harness.session.channelOpen) + XCTAssertEqual(try harness.outboundEnvelopes().map(\.kind), [.`init`]) + } + + func testNormalizesExpectedOriginFromFullURL() { + // Caller passes a full URL (path, uppercase host, explicit default port). It must normalize + // to a canonical origin so trusted same-origin traffic still matches. + let harness = Harness(expectedOrigin: "https://Example.com:443/paywall/index.html") + + harness.handle(.init(kind: .connect, componentID: ""), sourceOrigin: "https://example.com") + + XCTAssertTrue(harness.session.channelOpen) + } + + func testDeliversOutboundOnSameOriginDifferentPath() throws { + let harness = Harness(size: (width: false, height: true)) + harness.currentURL = URL(string: "https://example.com/promo/step-two.html")! + + harness.handle(.init(kind: .connect, componentID: "")) + + XCTAssertEqual(try harness.outboundEnvelopes().map(\.kind), [.`init`, .message]) + } + + func testInvalidExpectedOriginRejectsAllTraffic() { + // A caller origin that cannot be normalized leaves the bridge inert: even a `connect` that + // looks like it comes from the configured origin is rejected, so the channel never opens. + let harness = Harness(expectedOrigin: "not a valid origin") + + harness.handle(.init(kind: .connect, componentID: ""), sourceOrigin: "not a valid origin") + + XCTAssertFalse(harness.session.channelOpen) + XCTAssertTrue(harness.capturedScripts.isEmpty) + } + + func testDropsOutboundAfterNavigationToUnexpectedOrigin() { + let harness = Harness() + // Inbound source is still the trusted origin, but the top-level URL has left it: every + // outbound frame (even `init`) must be dropped. + harness.currentURL = URL(string: "https://evil.example.org/phish.html")! + + harness.handle(.init(kind: .connect, componentID: "")) + + XCTAssertTrue(harness.session.channelOpen) + XCTAssertTrue(harness.capturedScripts.isEmpty) + } + + // MARK: - Round trip through a real WKWebView + + func testRoundTripThroughRealWebView() throws { + let expectedURL = URL(string: "https://example.com/index.html")! + let session = WebViewSession( + componentID: "web", + expectedOrigin: "https://example.com", + fitAxes: (width: false, height: false), + evaluateJavaScript: { _ in }, + currentURL: { nil } + ) + + let configuration = WKWebViewConfiguration() + configuration.userContentController.add( + WeakScriptMessageHandler(session), + name: WebViewEnvelope.messageHandlerName + ) + let webView = WKWebView(frame: .zero, configuration: configuration) + session.evaluateJavaScript = { [weak webView] script in + webView?.evaluateJavaScript(script) + } + session.currentURL = { [weak webView] in + webView?.url + } + + let loaded = self.expectation(description: "web view finished loading") + let delegate = RoundTripNavigationDelegate { loaded.fulfill() } + webView.navigationDelegate = delegate + webView.loadHTMLString("hi", baseURL: expectedURL) + self.wait(for: [loaded], timeout: 10) + + webView.evaluateJavaScript( + """ + window.webkit.messageHandlers.\(WebViewEnvelope.messageHandlerName).postMessage( + '{"channel":"rc-web-components","protocol_version":1,"kind":"connect","component_id":""}' + ); true + """ + ) + + let connected = self.expectation(description: "handshake completed through the real web view") + func poll() { + if session.channelOpen { + connected.fulfill() + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { poll() } + } + } + poll() + self.wait(for: [connected], timeout: 10) + + XCTAssertTrue(session.channelOpen) + withExtendedLifetime(delegate) {} + configuration.userContentController.removeScriptMessageHandler( + forName: WebViewEnvelope.messageHandlerName + ) + } + + func testRenderOnlyWebViewExposesNoBridgeSurface() throws { + // A web view configured without the bridge (no script message handler registered) must + // expose no native bridge surface to page JavaScript at all. + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + let loaded = self.expectation(description: "web view finished loading") + loaded.assertForOverFulfill = false + let delegate = RoundTripNavigationDelegate( + onFinish: { loaded.fulfill() }, + onFail: { loaded.fulfill() } + ) + webView.navigationDelegate = delegate + // about:blank avoids network/ATS flakiness seen with https base URLs in CI. + webView.load(URLRequest(url: URL(string: "about:blank")!)) + self.wait(for: [loaded], timeout: 10) + + let probed = self.expectation(description: "probed bridge surface") + var bridgeExposed: Bool? + webView.evaluateJavaScript( + """ + (typeof window.webkit !== 'undefined' \ + && typeof window.webkit.messageHandlers !== 'undefined' \ + && typeof window.webkit.messageHandlers.\(WebViewEnvelope.messageHandlerName) !== 'undefined') + """ + ) { result, _ in + bridgeExposed = result as? Bool + probed.fulfill() + } + self.wait(for: [probed], timeout: 10) + + XCTAssertEqual(bridgeExposed, false) + withExtendedLifetime(delegate) {} + } + + // MARK: - receiveScript + + func testReceiveScriptEscapesLineSeparatorsAndRoundTrips() throws { + let hostile = "annual\" }); alert('xss'); //\n\\ end\u{2028}\u{2029}" + let envelope = WebViewEnvelope.Envelope( + kind: .message, + componentID: "web", + type: "rc:variables", + payload: ["value": .string(hostile)] + ) + + let script = try XCTUnwrap(WebViewSession.receiveScript(for: envelope)) + XCTAssertFalse(script.contains("\u{2028}")) + XCTAssertFalse(script.contains("\u{2029}")) + + let decoded = try Self.decodeEnvelope(fromScript: script) + XCTAssertEqual(decoded, envelope) + XCTAssertEqual(decoded.payload?["value"]?.stringValue, hostile) + } + + static func decodeEnvelope(fromScript script: String) throws -> WebViewEnvelope.Envelope { + let start = try XCTUnwrap(script.range(of: "var m=")?.upperBound) + let end = try XCTUnwrap(script.range(of: ";if", range: start.. Void + private let onFail: (() -> Void)? + + init(onFinish: @escaping () -> Void, onFail: (() -> Void)? = nil) { + self.onFinish = onFinish + self.onFail = onFail + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + self.onFinish() + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + self.onFail?() + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + self.onFail?() + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@MainActor +private final class Harness { + + static let expectedOrigin = "https://example.com" + + let session: WebViewSession + var capturedScripts: [String] = [] + var currentURL: URL? = URL(string: "https://example.com/path")! + + init( + size: (width: Bool, height: Bool) = (false, false), + expectedOrigin: String = Harness.expectedOrigin + ) { + self.session = WebViewSession( + componentID: "web", + expectedOrigin: expectedOrigin, + fitAxes: size, + evaluateJavaScript: { _ in }, + currentURL: { nil } + ) + self.session.evaluateJavaScript = { [weak self] script in + self?.capturedScripts.append(script) + } + self.session.currentURL = { [weak self] in + self?.currentURL + } + } + + func connect() { + self.handle(.init(kind: .connect, componentID: "")) + self.capturedScripts.removeAll() + } + + func handle( + _ envelope: WebViewEnvelope.Envelope, + isMainFrame: Bool = true, + sourceOrigin: String? = Harness.expectedOrigin + ) { + let data = try! JSONEncoder().encode(envelope) + self.session.handle( + rawMessage: String(data: data, encoding: .utf8)!, + isMainFrame: isMainFrame, + sourceOrigin: sourceOrigin + ) + } + + func outboundEnvelopes() throws -> [WebViewEnvelope.Envelope] { + try self.capturedScripts.map { + try WebViewSessionTests.decodeEnvelope(fromScript: $0) + } + } + +} + +#endif