diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index c02dc07cf..f8afef882 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -1043,6 +1043,7 @@ 42C131D02D66084C00AF48E6 /* PillView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C131CF2D66084C00AF48E6 /* PillView.swift */; }; 42C3737F2BC415AC00898990 /* UIViewController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C3737E2BC415AC00898990 /* UIViewController+Extensions.swift */; }; 42C5E5AB2F7C20EA004797B5 /* EntityColorAttributesParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42145A652F7F4CC000891E04 /* EntityColorAttributesParser.swift */; }; + 42C5E5AD2F7E74DE004797B5 /* SafeScriptMessageHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C5E5AC2F7E74DE004797B5 /* SafeScriptMessageHandlerTests.swift */; }; 42C60FA62F081DA90071A6F6 /* DeviceClassProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C60FA52F081DA90071A6F6 /* DeviceClassProvider.swift */; }; 42C60FA72F081DA90071A6F6 /* DeviceClassProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42C60FA52F081DA90071A6F6 /* DeviceClassProvider.swift */; }; 42CB330D2DAE4FD800491DCE /* ServerSelectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42CB330C2DAE4FD800491DCE /* ServerSelectView.swift */; }; @@ -2877,6 +2878,7 @@ 42C131CF2D66084C00AF48E6 /* PillView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillView.swift; sourceTree = ""; }; 42C3737E2BC415AC00898990 /* UIViewController+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Extensions.swift"; sourceTree = ""; }; 42C373AF2BC536AA00898990 /* WatchApp-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "WatchApp-Bridging-Header.h"; sourceTree = ""; }; + 42C5E5AC2F7E74DE004797B5 /* SafeScriptMessageHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeScriptMessageHandlerTests.swift; sourceTree = ""; }; 42C60FA52F081DA90071A6F6 /* DeviceClassProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceClassProvider.swift; sourceTree = ""; }; 42CA28AD2B101D4D0093B31A /* HACornerRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HACornerRadius.swift; sourceTree = ""; }; 42CA28AF2B101D6B0093B31A /* CardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardView.swift; sourceTree = ""; }; @@ -4481,6 +4483,7 @@ 42A47A862C452D5400C9B43D /* WebViewExternalMessageHandlerTests.swift */, 429481EA2DA93FA000A8B468 /* WebViewJavascriptCommandsTests.swift */, 4228D0002DB903AA00FC6912 /* WKUserContentControllerMessageTests.swift */, + 42C5E5AC2F7E74DE004797B5 /* SafeScriptMessageHandlerTests.swift */, ); path = WebView; sourceTree = ""; @@ -9617,6 +9620,7 @@ 422E626C2CDCF00A00987BD0 /* AreasService.test.swift in Sources */, 11A71C8D24A593A800D9565F /* ZoneManagerCollector.test.swift in Sources */, 42B980DC2DC256A300BC5C08 /* SensorRow.test.swift in Sources */, + 42C5E5AD2F7E74DE004797B5 /* SafeScriptMessageHandlerTests.swift in Sources */, 116D3A3D2724D83300EF5D21 /* OnboardingAuth.test.swift in Sources */, BB77559927344584B2C0E987 /* OnboardingAuthError.test.swift in Sources */, 420F53EE2C4EA025003C8415 /* WidgetsKindTests.swift in Sources */, diff --git a/Sources/App/Frontend/ExternalMessageBus/SafeScriptMessageHandler.swift b/Sources/App/Frontend/ExternalMessageBus/SafeScriptMessageHandler.swift index 09f209d4f..1a181d058 100644 --- a/Sources/App/Frontend/ExternalMessageBus/SafeScriptMessageHandler.swift +++ b/Sources/App/Frontend/ExternalMessageBus/SafeScriptMessageHandler.swift @@ -1,10 +1,13 @@ import Foundation +import Shared import WebKit /// Use to avoid holding webview alive when adding WKScriptMessageHandler final class SafeScriptMessageHandler: NSObject, WKScriptMessageHandler { + let server: Server weak var delegate: WKScriptMessageHandler? - init(delegate: WKScriptMessageHandler) { + init(server: Server, delegate: WKScriptMessageHandler) { + self.server = server self.delegate = delegate super.init() } @@ -13,8 +16,29 @@ final class SafeScriptMessageHandler: NSObject, WKScriptMessageHandler { _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { + // Only the top-level document on an allowed server host may talk to the native bridge. + guard shouldAllowMessage( + isMainFrame: message.frameInfo.isMainFrame, + host: message.frameInfo.securityOrigin.host + ) else { + return + } delegate?.userContentController( userContentController, didReceive: message ) } + + func shouldAllowMessage(isMainFrame: Bool, host: String) -> Bool { + isMainFrame && allowedHosts.contains(host) + } + + private var allowedHosts: Set { + let urls = [ + server.info.connection.address(for: .internal), + server.info.connection.address(for: .external), + server.info.connection.address(for: .remoteUI), + ] + + return Set(urls.compactMap { $0?.host }) + } } diff --git a/Sources/App/Frontend/WebView/WebViewController+WebViewSetup.swift b/Sources/App/Frontend/WebView/WebViewController+WebViewSetup.swift index f22865a03..314261301 100644 --- a/Sources/App/Frontend/WebView/WebViewController+WebViewSetup.swift +++ b/Sources/App/Frontend/WebView/WebViewController+WebViewSetup.swift @@ -7,7 +7,7 @@ import UIKit extension WebViewController { func setupUserContentController() -> WKUserContentController { let userContentController = WKUserContentController() - let safeScriptMessageHandler = SafeScriptMessageHandler(delegate: webViewScriptMessageHandler) + let safeScriptMessageHandler = SafeScriptMessageHandler(server: server, delegate: webViewScriptMessageHandler) userContentController.add(safeScriptMessageHandler, name: "getExternalAuth") userContentController.add(safeScriptMessageHandler, name: "revokeExternalAuth") userContentController.add(safeScriptMessageHandler, name: "externalBus") diff --git a/Sources/App/Frontend/WebView/WebViewController.swift b/Sources/App/Frontend/WebView/WebViewController.swift index 29f41225d..8126fe486 100644 --- a/Sources/App/Frontend/WebView/WebViewController.swift +++ b/Sources/App/Frontend/WebView/WebViewController.swift @@ -204,7 +204,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg userContentController.addUserScript(WKUserScript( source: wsBridgeJS, injectionTime: .atDocumentEnd, - forMainFrameOnly: false + forMainFrameOnly: true )) userContentController.addUserScript(.init( @@ -219,7 +219,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg }); """, injectionTime: .atDocumentStart, - forMainFrameOnly: false + forMainFrameOnly: true )) config.userContentController = userContentController diff --git a/Tests/App/WebView/SafeScriptMessageHandlerTests.swift b/Tests/App/WebView/SafeScriptMessageHandlerTests.swift new file mode 100644 index 000000000..c525e3efb --- /dev/null +++ b/Tests/App/WebView/SafeScriptMessageHandlerTests.swift @@ -0,0 +1,42 @@ +@testable import HomeAssistant +import Shared +import Testing +import WebKit + +struct SafeScriptMessageHandlerTests { + @Test func allowsMainFrameMessageFromConfiguredServerHost() { + ServerFixture.reset() + let handler = SafeScriptMessageHandler( + server: ServerFixture.withRemoteConnection, + delegate: NoOpScriptMessageHandler() + ) + + #expect(handler.shouldAllowMessage(isMainFrame: true, host: "external.example.com")) + #expect(handler.shouldAllowMessage(isMainFrame: true, host: "internal.example.com")) + #expect(handler.shouldAllowMessage(isMainFrame: true, host: "ui.nabu.casa")) + } + + @Test func rejectsMessageFromHostOutsideConfiguredServerHosts() { + ServerFixture.reset() + let handler = SafeScriptMessageHandler( + server: ServerFixture.withRemoteConnection, + delegate: NoOpScriptMessageHandler() + ) + + #expect(!handler.shouldAllowMessage(isMainFrame: true, host: "evil.example.com")) + } + + @Test func rejectsIframeMessageEvenWhenHostIsAllowed() { + ServerFixture.reset() + let handler = SafeScriptMessageHandler( + server: ServerFixture.withRemoteConnection, + delegate: NoOpScriptMessageHandler() + ) + + #expect(!handler.shouldAllowMessage(isMainFrame: false, host: "external.example.com")) + } +} + +private final class NoOpScriptMessageHandler: NSObject, WKScriptMessageHandler { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {} +}