Prevent execution of WKUserScript from beyond main frame and allowed hosts (#4469)

<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->

## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->

## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->
This commit is contained in:
Bruno Pantaleão Gonçalves
2026-04-02 13:52:29 +02:00
committed by GitHub
parent a20ae2f593
commit c8f35e4cff
5 changed files with 74 additions and 4 deletions

View File

@@ -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 = "<group>"; };
42C3737E2BC415AC00898990 /* UIViewController+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Extensions.swift"; sourceTree = "<group>"; };
42C373AF2BC536AA00898990 /* WatchApp-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "WatchApp-Bridging-Header.h"; sourceTree = "<group>"; };
42C5E5AC2F7E74DE004797B5 /* SafeScriptMessageHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeScriptMessageHandlerTests.swift; sourceTree = "<group>"; };
42C60FA52F081DA90071A6F6 /* DeviceClassProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceClassProvider.swift; sourceTree = "<group>"; };
42CA28AD2B101D4D0093B31A /* HACornerRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HACornerRadius.swift; sourceTree = "<group>"; };
42CA28AF2B101D6B0093B31A /* CardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardView.swift; sourceTree = "<group>"; };
@@ -4481,6 +4483,7 @@
42A47A862C452D5400C9B43D /* WebViewExternalMessageHandlerTests.swift */,
429481EA2DA93FA000A8B468 /* WebViewJavascriptCommandsTests.swift */,
4228D0002DB903AA00FC6912 /* WKUserContentControllerMessageTests.swift */,
42C5E5AC2F7E74DE004797B5 /* SafeScriptMessageHandlerTests.swift */,
);
path = WebView;
sourceTree = "<group>";
@@ -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 */,

View File

@@ -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<String> {
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 })
}
}

View File

@@ -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")

View File

@@ -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

View File

@@ -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) {}
}