use crate::ipc::{DocumentActivation, Hello, IpcError, IpcErrorCode};
use serde::Deserialize;
pub(super) use super::protocol::app_url;
pub(super) const MAX_CONTROL_UNITS: usize = 4096;
pub(super) const MAX_TASKS: usize = 128;
pub(super) fn reserved_path(uri: &str) -> Option<&str> {
if !app_url(uri) {
return None;
}
let path = uri.strip_prefix(super::APP_ORIGIN)?;
let route = path.split(['?', '#']).next()?;
if matches!(route, "/_webui/ipc/bootstrap.js" | "/_webui/ipc/runtime.js") {
return None;
}
(route == "/_webui/ipc" || route.starts_with("/_webui/ipc/")).then_some(path)
}
pub(super) fn decimal(value: &str) -> Option<u64> {
if value.is_empty()
|| value.len() > 20
|| value.starts_with('0')
|| !value.bytes().all(|c| c.is_ascii_digit())
{
return None;
}
value.parse().ok()
}
pub(super) fn nonce(value: &str) -> Option<[u8; 16]> {
if value.len() != 32 {
return None;
}
let mut out = [0; 16];
for (byte, pair) in out.iter_mut().zip(value.as_bytes().as_chunks::<2>().0) {
let digit = |c| match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
_ => None,
};
*byte = digit(pair[0])? * 16 + digit(pair[1])?;
}
Some(out)
}
#[derive(Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
pub(super) enum Control {
#[serde(rename_all = "camelCase")]
Hello {
call_id: String,
navigation: String,
document_nonce: String,
challenge: String,
wire_version: u32,
contract_name: String,
contract_major: u32,
schema_hash: String,
#[serde(default)]
native_carrier_version: Option<u8>,
},
Disconnect {
generation: String,
token: String,
},
}
pub(super) use crate::native_ipc::NativeHello as HelloCall;
impl Control {
pub fn into_hello(self, carrier: bool) -> Option<HelloCall> {
let Self::Hello {
call_id,
navigation,
document_nonce,
challenge,
wire_version,
contract_name,
contract_major,
schema_hash,
native_carrier_version,
} = self
else {
return None;
};
if native_carrier_version != carrier.then_some(1)
|| call_id.is_empty()
|| call_id.len() > 32
|| contract_name.len() > 256
|| schema_hash.len() != 64
{
return None;
}
Some(HelloCall {
call_id,
proof: DocumentActivation {
navigation: decimal(&navigation)?,
document_nonce: nonce(&document_nonce)?,
challenge: nonce(&challenge)?,
},
hello: Hello {
wire_version,
contract_name,
contract_major,
schema_hash,
},
})
}
}
#[derive(Default)]
pub(super) struct Document {
pub epoch: crate::document::DocumentEpoch,
pub native_id: Option<u64>,
pub hello_pending: bool,
pub generation: Option<u64>,
pub token: Option<String>,
pub max_frame_bytes: Option<usize>,
pub limits: Option<crate::ipc::IpcLimits>,
pub proof: Option<DocumentActivation>,
pub unavailable_code: Option<IpcErrorCode>,
}
impl Document {
pub fn start(&mut self, native_id: u64) -> Option<u64> {
if self.epoch.closed || self.native_id == Some(native_id) {
return None;
}
let next = self.epoch.advance()?;
*self = Self {
epoch: self.epoch,
native_id: Some(native_id),
unavailable_code: self.native_id.map(|_| IpcErrorCode::Navigated),
..Self::default()
};
Some(next)
}
pub fn current(&self, navigation: u64) -> bool {
self.epoch.current(navigation)
}
pub fn request_error(&self) -> Option<IpcErrorCode> {
if self.epoch.closed {
Some(IpcErrorCode::Closed)
} else if self.current(self.epoch.navigation) {
None
} else {
Some(self.unavailable_code.unwrap_or(IpcErrorCode::NotReady))
}
}
pub fn accepts(&self, proof: &DocumentActivation) -> bool {
self.epoch.accepts(self.proof.as_ref(), proof)
}
pub fn begin_hello(&mut self, proof: &DocumentActivation) -> bool {
if !self.accepts(proof) || self.hello_pending || self.generation.is_some() {
return false;
}
self.hello_pending = true;
true
}
}
#[cfg(test)]
pub(super) fn activation_script(proof: &DocumentActivation) -> serde_json::Result<String> {
activation_script_for(proof, super::APP_ORIGIN, false)
}
pub(super) fn activation_script_for(
proof: &DocumentActivation,
origin: &str,
carrier: bool,
) -> serde_json::Result<String> {
let activate = if carrier {
crate::native_ipc::activation_script_local(proof)?
} else {
crate::native_ipc::activation_script(proof)?
};
let origin = serde_json::to_string(origin)?;
Ok(format!(
"(()=>{{'use strict';if(window!==window.top||location.origin!=={origin})return false;return {activate}===true;}})()"
))
}
#[cold]
#[inline(never)]
pub(super) fn error(code: IpcErrorCode) -> IpcError {
IpcError::new(
code,
code.as_str(),
"reload the trusted app document or reduce concurrent IPC work",
)
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
#[test]
fn local_hello_requires_carrier_v1_before_proof_can_be_admitted() {
let hello = r#"{"kind":"hello","callId":"1","navigation":"1","documentNonce":"01010101010101010101010101010101","challenge":"02020202020202020202020202020202","wireVersion":3,"contractName":"test","contractMajor":1,"schemaHash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#;
let bundled: Control = serde_json::from_str(hello).unwrap();
assert!(bundled.into_hello(false).is_some());
let missing: Control = serde_json::from_str(hello).unwrap();
assert!(missing.into_hello(true).is_none());
let local = hello.replacen(
"\"kind\":\"hello\"",
"\"kind\":\"hello\",\"nativeCarrierVersion\":1",
1,
);
let valid: Control = serde_json::from_str(&local).unwrap();
assert!(valid.into_hello(true).is_some());
let forbidden: Control = serde_json::from_str(&local).unwrap();
assert!(forbidden.into_hello(false).is_none());
let wrong: Control = serde_json::from_str(
&local.replace("\"nativeCarrierVersion\":1", "\"nativeCarrierVersion\":2"),
)
.unwrap();
assert!(wrong.into_hello(true).is_none());
}
#[test]
fn disconnect_without_credential_is_not_a_control_message() {
assert!(
serde_json::from_str::<Control>(r#"{"kind":"disconnect","generation":"1"}"#).is_err()
);
let message = serde_json::from_str::<Control>(
r#"{"kind":"disconnect","generation":"1","token":"01010101010101010101010101010101"}"#,
)
.unwrap();
assert!(
matches!(message, Control::Disconnect { generation, token } if generation == "1" && nonce(&token).is_some())
);
}
#[test]
fn duplicate_hello_cannot_replace_the_pending_call() {
let mut document = Document::default();
let navigation = document.start(1).unwrap();
document.epoch.commit();
let proof = DocumentActivation {
navigation,
document_nonce: [1; 16],
challenge: [2; 16],
};
document.proof = Some(proof.clone());
assert!(document.begin_hello(&proof));
assert!(!document.begin_hello(&proof));
assert!(document.accepts(&proof));
let mut forged = proof.clone();
forged.challenge = [3; 16];
assert!(!document.begin_hello(&forged));
assert!(!Document::default().begin_hello(&proof));
document.start(2);
assert!(!document.begin_hello(&proof));
}
#[test]
fn evaluated_wrapper_never_hands_old_proof_to_replacement_bootstrap() {
let proof = DocumentActivation {
navigation: 1,
document_nonce: [1; 16],
challenge: [2; 16],
};
let script = serde_json::to_string(&activation_script(&proof).unwrap()).unwrap();
let harness = format!(
r#"
const vm = require('node:vm');
const assert = require('node:assert/strict');
function run(nonce, main = true, origin = 'https://app.webui.localhost') {{
let lookedUp = 0;
const calls = [];
const window = {{}};
window.top = main ? window : {{}};
window.__webuiDesktopIpcV2 = {{
documentNonce: nonce,
get activate() {{ lookedUp++; return proof => {{ calls.push(proof); return true; }}; }}
}};
const result = vm.runInNewContext({script}, {{window, location: {{origin}}}});
return {{result, lookedUp, calls}};
}}
for (const result of [
run('03'.repeat(16)), // Same URL, replacement document and fake bootstrap.
run('01'.repeat(16), false),
run('01'.repeat(16), true, 'https://remote.example')
]) {{
assert.equal(result.result, false);
assert.equal(result.lookedUp, 0);
assert.equal(result.calls.length, 0);
}}
const current = run('01'.repeat(16));
assert.equal(current.result, true);
assert.equal(current.calls.length, 1);
assert.equal(current.calls[0].navigation, '1');
assert.equal(current.calls[0].challenge, '02'.repeat(16));
"#
);
let output = std::process::Command::new("node")
.args(["-e", &harness])
.output()
.expect("Node.js from the repository test toolchain must be installed");
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn authority_is_exact_and_reserved_routes_cannot_escape() {
assert!(app_url("https://app.webui.localhost/a"));
assert!(app_url("https://app.webui.localhost#fragment"));
for url in [
"https://app.webui.localhost.evil/a",
"https://app.webui.localhost@evil/a",
"https://app.webui.localhost:444/a",
"https://evil/a",
"http://app.webui.localhost/a",
] {
assert!(!app_url(url));
}
assert_eq!(
reserved_path("https://app.webui.localhost/_webui/ipc?token=x"),
Some("/_webui/ipc?token=x")
);
assert!(reserved_path("https://app.webui.localhost/_webui/ipc-other").is_none());
assert!(reserved_path("https://app.webui.localhost/_webui/ipc/runtime.js").is_none());
assert!(reserved_path("https://app.webui.localhost/_webui/ipc/bootstrap.js?v=2").is_none());
}
#[test]
fn late_resource_requests_preserve_native_retirement_reason() {
let mut document = Document::default();
assert_eq!(document.request_error(), Some(IpcErrorCode::NotReady));
document.start(10).unwrap();
assert_eq!(document.request_error(), Some(IpcErrorCode::NotReady));
document.epoch.commit();
assert_eq!(document.request_error(), None);
assert_eq!(document.start(10), None);
assert_eq!(document.request_error(), None);
document.start(11).unwrap();
assert_eq!(document.request_error(), Some(IpcErrorCode::Navigated));
assert_eq!(document.start(11), None);
assert_eq!(document.request_error(), Some(IpcErrorCode::Navigated));
document.epoch.commit();
assert_eq!(document.request_error(), None);
for code in [IpcErrorCode::Closed, IpcErrorCode::Transport] {
document.epoch.committed = false;
document.unavailable_code = Some(code);
assert_eq!(document.request_error(), Some(code));
}
document.epoch.closed = true;
assert_eq!(document.request_error(), Some(IpcErrorCode::Closed));
}
#[test]
fn old_same_url_proof_cannot_match_new_document_or_reply() {
let mut document = Document::default();
let old = document.start(10).unwrap();
document.epoch.commit();
let proof = DocumentActivation {
navigation: old,
document_nonce: [1; 16],
challenge: [2; 16],
};
document.proof = Some(proof.clone());
assert!(document.accepts(&proof));
assert_eq!(document.start(10), None); assert!(document.accepts(&proof));
let new = document.start(11).unwrap();
document.epoch.commit();
document.proof = Some(DocumentActivation {
navigation: new,
document_nonce: [3; 16],
challenge: [4; 16],
});
assert!(!document.current(old));
assert!(!document.accepts(&proof));
let mut forged = proof;
forged.navigation = new;
assert!(!document.accepts(&forged));
document.epoch.closed = true;
assert!(!document.current(new));
}
#[test]
fn correlation_and_proof_fields_are_bounded_canonical_values() {
assert_eq!(decimal("18446744073709551615"), Some(u64::MAX));
for bad in ["0", "01", "-1", "1.0", "18446744073709551616"] {
assert_eq!(decimal(bad), None);
}
assert_eq!(nonce("0123456789abcdef0123456789abcdef").unwrap()[0], 1);
assert!(nonce("0123456789ABCDEF0123456789ABCDEF").is_none());
assert!(nonce("🦀0000000000000000000000000000").is_none());
}
}