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
15 changes: 14 additions & 1 deletion android/truapi-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,20 @@ core.notifyChainClosed(chainConnectionId)
// following `loadUrl` replaces, so the product would lose the endpoint. Scope
// it to the product origin. The page reads `window.__truapi_localhost.url` and
// passes it to `@parity/truapi`'s `createWebSocketProvider`.
val bootstrap = LocalhostBridgeBootstrap.script(endpoint.port, endpoint.token)
// A peek, never a prompt — see LocalhostBridgeBootstrap.script. Baked in as a
// literal because the container enforces it inside the product's own realm,
// where an async permission request would be forgeable. A fresh grant therefore
// only takes effect once the web view reloads.
//
// Read this as a policy value, not a gate: Android injects no lockdown
// container, so nothing consumes the decision and WebRTC is reachable on
// Android whatever the status says. Pass what the core returns anyway — a
// literal `true` compiles and would silently keep that open once the container
// does land (#334 scopes the gate to iOS).
val webRtcAllowed = core.permissionAuthorizationStatus(
PermissionAuthorizationRequest.Remote(RemotePermissionRequest(RemotePermission.WebRtc))
) == PermissionAuthorizationStatus.AUTHORIZED
val bootstrap = LocalhostBridgeBootstrap.script(endpoint.port, endpoint.token, webRtcAllowed)
main.post {
val productUrl = "https://your-product.example/"
if (WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) {
Expand Down
31 changes: 29 additions & 2 deletions android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -629,15 +629,41 @@ private class ChatCallbackAdapter(private val bridge: ChatHostBridge) : NativeCh
object LocalhostBridgeBootstrap {
/**
* Returns a `<script>`-injectable snippet that publishes the endpoint
* metadata on `window.__truapi_localhost`, exposes the legacy
* metadata on `window.__truapi_localhost`, the pre-resolved permission
* decisions on `window.__truapi_policy__`, exposes the legacy
* `window.__HOST_API_PORT__` webview transport shape, and fires a
* `truapi-native-ready` event. Inject at document start (before the product
* page scripts run) so the page can dial the bridge immediately.
*
* [webRtcAllowed] must come from `permissionAuthorizationStatus` for
* `RemotePermission.Remote.WebRtc` — a peek, never a prompt. It is baked in
* as a literal because the container enforces it inside the product's own
* realm, where an asynchronous permission request would be forgeable:
* product script can hook the primitives such a request's bookkeeping
* relies on and resolve it itself. A settled value has nothing to steal.
* The consequence is that a fresh grant only takes effect once the web view
* reloads.
*
* The parameter is required so that every host has to answer, but a `Boolean`
* cannot force the answer to be a real one: passing a literal `true`
* compiles and grants WebRTC unconditionally, which is the pre-gate
* behaviour. Nothing downstream can detect that, so read the status from the
* core and pass what it returns. A type that only a
* [PermissionAuthorizationStatus] could produce would make the mistake
* unrepresentable; it is deliberately deferred until Android enforces the
* decision at all (see the container note where the policy is published).
*/
fun script(port: UShort, token: String): String {
fun script(port: UShort, token: String, webRtcAllowed: Boolean): String {
Comment thread
filvecchiato marked this conversation as resolved.
val url = "ws://127.0.0.1:$port/?t=$token"
val safeUrl = jsStringLiteral(url)
val safeToken = jsStringLiteral(token)
// Published for the lockdown container to read, but Android does not
// inject the container, so on Android nothing reads it and WebRTC stays
// reachable regardless of the decision. This is a policy value, not an
// enforcement point: it is here so the bootstrap contract matches iOS,
// where the container is injected and does enforce it. Android
// enforcement is tracked separately (#334 scopes the gate to iOS).
val safeWebRtc = if (webRtcAllowed) "true" else "false"
return """
(function() {
var endpoint = { url: $safeUrl, token: $safeToken };
Expand Down Expand Up @@ -705,6 +731,7 @@ object LocalhostBridgeBootstrap {
}

window.__truapi_localhost = endpoint;
window.__truapi_policy__ = { webRtcAllowed: $safeWebRtc };
Comment thread
filvecchiato marked this conversation as resolved.
window.__HOST_WEBVIEW_MARK__ = true;
window.__HOST_API_PORT__ = createWebSocketMessagePort(endpoint.url);
window.dispatchEvent(new Event('truapi-native-ready'));
Expand Down
29 changes: 14 additions & 15 deletions ios/truapi-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The package lives in the truapi repo next to the Rust core it wraps. `Package.sw
The `TrUAPIHost` SPM package an iOS host app imports directly. It carries:

- [`Sources/TrUAPIHost/TrUAPIHost.swift`](Sources/TrUAPIHost/TrUAPIHost.swift) — the hand-written shell: `TrUAPIHostCore` (owning wrapper around the UniFFI-generated `NativeTrUApiCore`, with the localhost WS bridge, session controls, and native change notifications), `TrUAPIHostCoreProtocol`, `RuntimeConfig`, and `LocalhostBridgeBootstrap`.
- [`Sources/TrUAPIHost/ProductScripts.swift`](Sources/TrUAPIHost/ProductScripts.swift) — `TrUAPIHost.installProductScripts(into:core:endpoint:)`, which registers the bootstrap and the lockdown container with the frame scopes the lockdown depends on and peeks the WebRTC decision. The supported way to wire a product web view.
- the Rust core as a binary target — a GitHub release asset by default (`publishedBinaryURL` in the root `Package.swift`), or the locally built `Binaries/truapi_server.xcframework` when `useLocalBinary` is flipped to true.
- `Sources/TrUAPIHost/truapi_server.swift` and `Sources/truapi_serverFFI/include/` — the generated UniFFI bindings.
- [`js/container/`](../../js/container) — the TS lockdown container; built into `Sources/TrUAPIHost/Resources/truapi-container.js` and exposed via `ContainerScriptBundle.load()`.
Expand Down Expand Up @@ -346,22 +347,20 @@ core.notifyPreimageChanged(key: preimageKey, value: preimageBytesOrNil)
core.notifyChainResponse(connectionId: chainConnectionId, json: jsonRpcResponse)
core.notifyChainClosed(connectionId: chainConnectionId)

// Both scripts must be registered before the web view loads the product page,
// and in this order: the bootstrap publishes the bridge endpoint on
// `window.__truapi_localhost`; the container script then locks down the
// page's platform APIs and reads that endpoint at eval time.
// Register the bootstrap + lockdown container before the web view loads the
// product page. `installProductScripts` owns the two properties that are easy to
// get wrong and silently fatal: the container goes into EVERY frame (a frame
// without it has pristine fetch/WebSocket/RTCPeerConnection, and a product
// reaches one through an `<iframe>` in its own HTML), while the bootstrap stays
// main-frame-only so a subframe has no bridge and no policy and fails closed. It
// also resolves the WebRTC decision by peeking the core rather than prompting.
// Do not register these scripts by hand.
let contentController = WKUserContentController()
let bootstrapScript = LocalhostBridgeBootstrap.script(port: endpoint.port, token: endpoint.token)
contentController.addUserScript(WKUserScript(
source: bootstrapScript,
injectionTime: .atDocumentStart,
forMainFrameOnly: true
))
contentController.addUserScript(WKUserScript(
source: try ContainerScriptBundle.load(),
injectionTime: .atDocumentStart,
forMainFrameOnly: true
))
try TrUAPIHost.installProductScripts(
into: contentController,
core: core,
endpoint: endpoint
)

let configuration = WKWebViewConfiguration()
configuration.userContentController = contentController
Expand Down
57 changes: 57 additions & 0 deletions ios/truapi-host/Sources/TrUAPIHost/ProductScripts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// User-script installation for a product web view.
//
// The lockdown container's guarantees depend on two things a host would
// otherwise have to get right by hand, so the package owns both.

#if canImport(WebKit)

import WebKit

public extension TrUAPIHost {
/// Registers the bootstrap and lockdown container on `controller` with the
/// frame scopes the lockdown depends on, resolving the container's policy
/// from `core`.
///
/// Prefer this over registering the scripts by hand. Two properties are
/// easy to get wrong and silently fatal:
///
/// - **The container must run in every frame.** A frame without it has
/// pristine `fetch`, `WebSocket`, `XMLHttpRequest` and
/// `RTCPeerConnection`, and a product reaches one through an `<iframe>` in
/// its own HTML — parsed before any script runs — so intercepting DOM
/// creation APIs is no substitute. The bootstrap stays main-frame-only:
/// a subframe then has no bridge endpoint and no policy, and every gate in
/// the container fails closed there.
/// - **The WebRTC decision is a peek, never a prompt**, and it is baked in
/// as a literal because the container enforces it inside the product's own
/// realm, where an asynchronous permission request would be forgeable. A
/// fresh grant therefore only applies once the web view reloads.
///
/// Call before the web view loads the product page.
static func installProductScripts(
into controller: WKUserContentController,
core: any TrUAPIHostCoreProtocol,
endpoint: WsBridgeEndpoint
) throws {
let webRtcAllowed = try core.permissionAuthorizationStatus(
request: .remote(RemotePermissionRequest(permission: .webRtc))
) == .authorized

controller.addUserScript(WKUserScript(
source: LocalhostBridgeBootstrap.script(
port: endpoint.port,
token: endpoint.token,
webRtcAllowed: webRtcAllowed
),
injectionTime: .atDocumentStart,
forMainFrameOnly: true
))
controller.addUserScript(WKUserScript(
source: try ContainerScriptBundle.load(),
injectionTime: .atDocumentStart,
forMainFrameOnly: false
))
}
}

#endif
115 changes: 85 additions & 30 deletions ios/truapi-host/Sources/TrUAPIHost/Resources/truapi-container.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
"use strict";
(() => {
// src/index.ts
// src/freeze.ts
var failures = [];
function describe(obj) {
if (obj === globalThis) return "window";
const name = obj?.constructor?.name;
return typeof name === "string" && name.length > 0 ? name : "object";
}
function recordFailure(obj, prop) {
failures.push(`${describe(obj)}.${prop}`);
}
function freezeAndDelete(obj, prop) {
try {
Object.defineProperty(obj, prop, {
Expand All @@ -15,6 +24,9 @@
} catch {
}
}
if (obj?.[prop] !== void 0) {
recordFailure(obj, prop);
}
}
function freezeValue(obj, prop, value) {
try {
Expand All @@ -26,7 +38,53 @@
});
} catch {
}
if (obj?.[prop] !== value) {
recordFailure(obj, prop);
}
}
function freezeCustom(obj, prop, descriptor, verify) {
try {
Object.defineProperty(obj, prop, { configurable: false, ...descriptor });
} catch {
}
let locked = false;
try {
locked = verify(obj?.[prop]);
} catch {
}
if (!locked) {
recordFailure(obj, prop);
}
}
function reportLockdownFailures() {
if (failures.length === 0) {
return;
}
const message = `TrUAPI container lockdown failed for: ${failures.join(", ")}`;
try {
console.error(message);
} catch {
}
throw new Error(message);
}

// src/webrtc.ts
var POLICY_GLOBAL = "__truapi_policy__";
function installWebRtcPolicy(win, allowed) {
if (allowed === true) {
return;
}
freezeAndDelete(win, "RTCPeerConnection");
freezeAndDelete(win, "webkitRTCPeerConnection");
freezeAndDelete(win, "mozRTCPeerConnection");
}
function consumeWebRtcPolicy(win) {
const allowed = win?.[POLICY_GLOBAL]?.webRtcAllowed;
freezeAndDelete(win, POLICY_GLOBAL);
return allowed;
}

// src/index.ts
var _nativeFetch = window.fetch.bind(window);
var _NativeWebSocket = window.WebSocket;
var _bridgeUrl = window.__truapi_localhost?.url;
Expand All @@ -39,14 +97,12 @@
}
});
freezeValue(window, "WebSocket", _GatedWebSocket);
try {
Object.defineProperty(_NativeWebSocket.prototype, "constructor", {
value: _GatedWebSocket,
writable: false,
configurable: false
});
} catch {
}
freezeCustom(
_NativeWebSocket.prototype,
"constructor",
{ value: _GatedWebSocket, writable: false },
(current) => current === _GatedWebSocket
);
freezeValue(window, "fetch", (input, init) => {
try {
const raw = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
Expand All @@ -63,29 +119,26 @@
freezeValue(navigator, "sendBeacon", () => false);
freezeAndDelete(window, "indexedDB");
freezeAndDelete(window, "caches");
try {
Object.defineProperty(document, "cookie", {
get: () => "",
set: () => {
},
configurable: false
});
} catch {
}
freezeCustom(
document,
"cookie",
{ get: () => "", set: () => {
} },
(current) => current === ""
);
freezeAndDelete(window, "SharedWorker");
if (navigator.serviceWorker) {
try {
Object.defineProperty(navigator, "serviceWorker", {
value: Object.freeze({
register: () => {
throw new Error("ServiceWorker is not available");
}
}),
writable: false,
configurable: false
});
} catch {
}
const _stubServiceWorker = Object.freeze({
register: () => {
throw new Error("ServiceWorker is not available");
}
});
freezeCustom(
navigator,
"serviceWorker",
{ value: _stubServiceWorker, writable: false },
(current) => current === _stubServiceWorker
);
}
var _createElement = document.createElement.bind(document);
freezeValue(document, "createElement", (tagName, options) => {
Expand All @@ -94,4 +147,6 @@
}
return _createElement(tagName, options);
});
installWebRtcPolicy(window, consumeWebRtcPolicy(window));
reportLockdownFailures();
})();
16 changes: 14 additions & 2 deletions ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,24 @@ public struct ProductExecutionConfig: Sendable, Equatable {
/// cdylib is built with the `ws-bridge` feature.
public enum LocalhostBridgeBootstrap {
/// Returns a `<script>`-injectable snippet that publishes the endpoint
/// metadata on `window.__truapi_localhost`, exposes the legacy
/// metadata on `window.__truapi_localhost`, the pre-resolved permission
/// decisions on `window.__truapi_policy__`, exposes the legacy
/// `window.__HOST_API_PORT__` webview transport shape, and fires a
/// `truapi-native-ready` event.
public static func script(port: UInt16, token: String) -> String {
///
/// `webRtcAllowed` must come from `permissionAuthorizationStatus` for
/// `RemotePermission.remote(.webRtc)` — a peek, never a prompt. It is baked
/// in as a literal because the container enforces it inside the product's
/// own realm, where an asynchronous permission request would be forgeable:
/// product script can hook the primitives such a request's bookkeeping
/// relies on and resolve it itself. A settled value has nothing to steal.
/// The consequence is that a fresh grant only takes effect once the web
/// view reloads.
public static func script(port: UInt16, token: String, webRtcAllowed: Bool) -> String {
let url = "ws://127.0.0.1:\(port)/?t=\(token)"
let safeUrl = jsStringLiteral(url)
let safeToken = jsStringLiteral(token)
let safeWebRtc = webRtcAllowed ? "true" : "false"
return """
(function() {
var endpoint = { url: \(safeUrl), token: \(safeToken) };
Expand Down Expand Up @@ -256,6 +267,7 @@ public enum LocalhostBridgeBootstrap {
}

window.__truapi_localhost = endpoint;
window.__truapi_policy__ = { webRtcAllowed: \(safeWebRtc) };
window.__HOST_WEBVIEW_MARK__ = true;
window.__HOST_API_PORT__ = createWebSocketMessagePort(endpoint.url);
window.dispatchEvent(new Event('truapi-native-ready'));
Expand Down
16 changes: 12 additions & 4 deletions ios/truapi-host/Sources/TrUAPIHost/truapi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5806,10 +5806,18 @@ public enum RemotePermission: Equatable, Hashable {
*/domains: [String]
)
/**
* WebRTC access. Advertised and persistable, but host enforcement is not
* yet implemented: the lockdown container leaves `RTCPeerConnection`
* available to products, and camera/microphone capture is gated by the OS
* permission prompts rather than by this permission.
* WebRTC access.
*
* Enforced inside the product's own realm rather than at a network layer:
* ICE reaches an arbitrary host over UDP, so no content rule list, request
* interceptor, or CSP directive observes it. A host peeks this decision
* before the product realm exists and the lockdown container removes
* `RTCPeerConnection` — and its vendor-prefixed aliases — unless the answer
* was an explicit grant. Resolving it up front is what makes the gate
* unforgeable, and it means a fresh grant applies from the next load.
*
* Camera and microphone capture is gated by the OS permission prompts and
* [`HostDevicePermissionRequest`], not by this permission.
*/
case webRtc
/**
Expand Down
Loading