Files
iOS/Sources/App/Frontend/ExternalMessageBus/SafeScriptMessageHandler.swift
Bruno Pantaleão Gonçalves d77040fa07 Normalize ipv6 URL for host comparison in WKSecurityOrigin (#4602)
<!-- 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 -->
The issue is the new WebView bridge origin check. It compares configured
URL hosts with WKSecurityOrigin.host and IPv6 can appear as fd00::abcd
in one API and [fd00::abcd] in another, so the bridge rejects valid
frontend messages. That blocks external auth and leaves the frontend
loading, while widgets still work.

This PR normalizes the url
## 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. -->
2026-05-04 11:17:51 +02:00

88 lines
2.8 KiB
Swift

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(server: Server, delegate: WKScriptMessageHandler) {
self.server = server
self.delegate = delegate
super.init()
}
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
// Only the top-level document on an allowed server origin may talk to the native bridge.
guard shouldAllowMessage(
isMainFrame: message.frameInfo.isMainFrame,
scheme: message.frameInfo.securityOrigin.protocol,
host: message.frameInfo.securityOrigin.host,
port: message.frameInfo.securityOrigin.port // Security origin port is 0 whenever not specified
) else {
return
}
delegate?.userContentController(
userContentController, didReceive: message
)
}
func shouldAllowMessage(isMainFrame: Bool, scheme: String, host: String, port: Int) -> Bool {
guard isMainFrame, let origin = originKey(scheme: scheme, host: host, port: port) else {
return false
}
return allowedOrigins.contains(origin)
}
private var allowedOrigins: 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(originKey(url:)))
}
private func originKey(url: URL?) -> String? {
guard let url, let scheme = url.scheme?.lowercased(), let host = url.host else {
return nil
}
return originKey(scheme: scheme, host: host, port: url.port)
}
private func originKey(scheme: String, host: String, port: Int?) -> String? {
guard let normalizedPort = normalizedPort(for: scheme, port: port) else {
return nil
}
return "\(scheme.lowercased())://\(normalizedHost(host)):\(normalizedPort)"
}
private func normalizedPort(for scheme: String, port: Int?) -> Int? {
if let port, port != 0 {
return port
}
switch scheme.lowercased() {
case "http": return 80
case "https": return 443
default: return port
}
}
private func normalizedHost(_ host: String) -> String {
let lowercasedHost = host.lowercased()
if lowercasedHost.hasPrefix("["), lowercasedHost.hasSuffix("]") {
return String(lowercasedHost.dropFirst().dropLast())
}
return lowercasedHost
}
}