Browse / github.com/storytold/wordcraft / patch
Host bridge: trusted parent origin only, confirmed saves
Host bridge: trusted parent origin only, confirmed saves A follow-up to #3822. It ports the two hardening points from #3554, which will be closed. **1. Install the bridge only for a trusted parent.** With `?host=parent`, the bridge is installed only when the parent is on the page's own origin, or on the exact origin named by `?host_origin=`. That value must be a bare `http(s)://host[:port]`: no path, query, fragment, credentials or wildcards. Any other parent gets no bridge, so Save downloads normally and the page keeps its own unsaved-changes guard. Messages go only to that origin, and only that parent window is heard. **2. Saves are confirmed by
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- halldave (imported from pull request #4490, open)
- Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750- Size
- +310 −38 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
7324b54673712713552996dc
How it went
No other operator has reported using it yet.
Diff
raw · apps/wordcraft-web/src/bridge.rs apps/wordcraft-web/src/host.rs apps/wordcraft-web/src/main.rs apps/wordcraft-web/src/web.rs
new file mode 100644@@ -0,0 +1,201 @@+//! The host bridge's decisions, kept free of `web_sys` so they are tested natively: which parent+//! origin the bridge may talk to, and which saves the parent has confirmed.++use std::collections::BTreeMap;++/// How long the parent has to answer `wordcraft:save` with `wordcraft:saved` (milliseconds).+pub const SAVE_ACK_TIMEOUT_MS: f64 = 30_000.0;++/// The origin the bridge posts to and hears from, or `None` for no bridge (saves download).+///+/// - `own`: this page's origin.+/// - `parent`: the parent window's origin, `None` when reading it threw (a cross-origin parent).+/// - `allowed`: the `?host_origin=` query value, an origin the embedding page vouches for.+///+/// A same-origin parent is accepted. A cross-origin parent is accepted only when `allowed` is a+/// valid URL origin (`scheme://host[:port]`, nothing else); messages then go to that exact origin,+/// so the browser drops them if the parent is anything else. Opaque origins (`"null"`) never match.+pub fn target_origin(own: &str, parent: Option<&str>, allowed: Option<&str>) -> Option<String> {+ if let Some(parent) = parent+ && parent == own+ && is_origin(own)+ {+ return Some(own.to_string());+ }+ canonical_origin(allowed?.trim())+}++/// `s` as a browser serialises an origin (lowercase scheme and host, no default port), or `None`+/// when it isn't one [`is_origin`] accepts. `MessageEvent.origin` is compared with this string+/// exactly, so `https://Files.Example:443` must become `https://files.example` to ever match.+pub fn canonical_origin(s: &str) -> Option<String> {+ if !is_origin(s) {+ return None;+ }+ let (scheme, rest) = s.split_once("://")?;+ let scheme = scheme.to_ascii_lowercase();+ let (host, port) = match rest.split_once(':') {+ Some((h, p)) => (h, Some(p.parse::<u16>().ok()?)),+ None => (rest, None),+ };+ let host = host.to_ascii_lowercase();+ match (scheme.as_str(), port) {+ ("https", Some(443)) | ("http", Some(80)) | (_, None) => Some(format!("{scheme}://{host}")),+ (_, Some(port)) => Some(format!("{scheme}://{host}:{port}")),+ }+}++/// True for a URL origin: `http(s)://host[:port]` with no path, query, fragment or credentials,+/// and only in forms whose browser serialisation [`canonical_origin`] can produce: an ASCII host+/// (an IDN must be given in punycode, `xn--…`), and a numeric host only as a dotted-decimal IPv4.+pub fn is_origin(s: &str) -> bool {+ let Some((scheme, rest)) = s.split_once("://") else { return false };+ if !(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) || rest.is_empty() {+ return false;+ }+ // IPv6 literals aren't accepted; name the host instead.+ if rest.contains(['/', '?', '#', '@', ' ', '\\', '[', ']']) {+ return false;+ }+ let (host, port) = match rest.split_once(':') {+ Some((h, p)) => (h, Some(p)),+ None => (rest, None),+ };+ if host.is_empty() || !host.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_')) {+ return false;+ }+ // Browsers rewrite numeric hosts (`0x7f.1`, `127.1`, `010.0.0.1`) to dotted decimal; accept+ // only the form they would produce.+ let last = host.trim_end_matches('.').rsplit('.').next().unwrap_or("");+ if last.bytes().all(|b| b.is_ascii_digit()) || last.to_ascii_lowercase().starts_with("0x") {+ let parts: Vec<&str> = host.split('.').collect();+ let decimal = |p: &&str| !p.is_empty() && (*p == "0" || !p.starts_with('0')) && p.parse::<u8>().is_ok();+ if parts.len() != 4 || !parts.iter().all(decimal) {+ return false;+ }+ }+ match port {+ Some(p) => p.bytes().all(|b| b.is_ascii_digit()) && p.parse::<u16>().is_ok(),+ None => true,+ }+}++/// Saves posted to the parent and not yet confirmed, keyed by the app's save ticket.+#[derive(Debug, Default)]+pub struct SaveAcks {+ /// Request id → (file name, deadline in ms).+ pending: BTreeMap<u64, (String, f64)>,+}++impl SaveAcks {+ /// Record save `id` posted at `now`.+ pub fn posted_as(&mut self, id: u64, name: &str, now: f64) {+ self.pending.insert(id, (name.to_string(), now + SAVE_ACK_TIMEOUT_MS));+ }++ /// The parent confirmed `id`. False when no such save is waiting (an unknown id, or one that+ /// already timed out and was reported failed).+ pub fn saved(&mut self, id: u64) -> bool {+ self.pending.remove(&id).is_some()+ }++ /// Saves past their deadline at `now`, removed and returned as `(id, name)`.+ pub fn expire(&mut self, now: f64) -> Vec<(u64, String)> {+ let late: Vec<u64> = self.pending.iter().filter(|(_, (_, deadline))| now >= *deadline).map(|(id, _)| *id).collect();+ late.into_iter().filter_map(|id| self.pending.remove(&id).map(|(name, _)| (id, name))).collect()+ }++ /// Whether any save is still waiting for the parent (the document is reported unsaved).+ pub fn waiting(&self) -> bool {+ !self.pending.is_empty()+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ const OWN: &str = "https://app.example";++ #[test]+ fn same_origin_parent_enables_the_bridge() {+ assert_eq!(target_origin(OWN, Some(OWN), None).as_deref(), Some(OWN));+ }++ #[test]+ fn cross_origin_parent_disables_the_bridge() {+ // Reading a cross-origin parent's origin throws (None) or names another origin.+ assert_eq!(target_origin(OWN, None, None), None);+ assert_eq!(target_origin(OWN, Some("https://evil.example"), None), None);+ assert_eq!(target_origin(OWN, Some("null"), None), None);+ assert_eq!(target_origin("null", Some("null"), None), None);+ }++ #[test]+ fn host_origin_parameter_must_be_an_exact_origin() {+ let files = "https://files.example:8443";+ assert_eq!(target_origin(OWN, None, Some(files)).as_deref(), Some(files));+ for bad in+ ["*", "null", "files.example", "https://files.example/", "https://a@files.example", "javascript://x", "https://", "https://x:99999"]+ {+ assert_eq!(target_origin(OWN, None, Some(bad)), None, "{bad}");+ }+ }++ #[test]+ fn host_origin_is_stored_as_the_browser_serialises_it() {+ // `MessageEvent.origin` is lowercase with no default port; the stored origin must match it.+ for (given, stored) in [+ ("https://Files.Example", "https://files.example"),+ ("HTTPS://FILES.EXAMPLE", "https://files.example"),+ ("https://files.example:443", "https://files.example"),+ ("http://files.example:80", "http://files.example"),+ ("http://files.example:443", "http://files.example:443"),+ ("https://files.example:80", "https://files.example:80"),+ ("https://files.example:08443", "https://files.example:8443"),+ ("https://xn--bcher-kva.example", "https://xn--bcher-kva.example"),+ ("http://127.0.0.1:8080", "http://127.0.0.1:8080"),+ (" https://Files.Example:443 ", "https://files.example"),+ ] {+ assert_eq!(target_origin(OWN, None, Some(given)).as_deref(), Some(stored), "{given}");+ }+ }++ #[test]+ fn host_origin_a_browser_would_serialise_differently_is_refused() {+ for bad in [+ "https://b\u{fc}cher.example",+ "https://files.example:",+ "https://0x7f.0.0.1",+ "https://127.1",+ "https://010.0.0.1",+ "https://256.0.0.1",+ "https://files%2eexample",+ "https://[::1]",+ ] {+ assert_eq!(target_origin(OWN, None, Some(bad)), None, "{bad}");+ }+ }++ #[test]+ fn a_save_stays_unsaved_until_its_own_confirmation() {+ let mut acks = SaveAcks::default();+ acks.posted_as(7, "Document.docx", 0.0);+ assert!(acks.waiting(), "the document is reported unsaved until the parent answers");+ assert!(!acks.saved(6), "an unknown id confirms nothing");+ assert!(acks.waiting());+ assert!(acks.saved(7));+ assert!(!acks.waiting());+ assert!(!acks.saved(7), "a confirmation counts once");+ }++ #[test]+ fn an_unanswered_save_times_out() {+ let mut acks = SaveAcks::default();+ acks.posted_as(3, "Document.docx", 1_000.0);+ assert!(acks.expire(1_000.0 + SAVE_ACK_TIMEOUT_MS - 1.0).is_empty());+ assert_eq!(acks.expire(1_000.0 + SAVE_ACK_TIMEOUT_MS), vec![(3, "Document.docx".to_string())]);+ assert!(!acks.saved(3), "a late confirmation is ignored: the save was reported failed");+ assert!(!acks.waiting());+ }+}@@ -1,4 +1,4 @@-//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed+//! The `postMessage` bridge to the parent page (`?host=parent`), for pages that embed //! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The //! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`: //!@@ -6,9 +6,11 @@ //! listening; send a file now. //! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or //! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.-//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for-//! every file WordCraft would otherwise download. The parent stores it and reports failures-//! itself. `saveAs` says whether the file becomes the document's file:+//! - `wordcraft:save` (to the parent): `{ id, name, saveAs, bytes }` (a transferred `ArrayBuffer`)+//! for every file WordCraft would otherwise download. The parent stores it and answers+//! `wordcraft:saved { id }`; until then the document counts as unsaved. With no answer in 30 s+//! WordCraft posts `wordcraft:failed { id, error }` and the document stays unsaved.+//! `saveAs` says whether the file becomes the document's file: //! - Save: `false`. It writes the opened file under its own name and format, as the desktop //! app does. A name the parent never sent (an untitled document, an imported .doc saved as //! .docx) is a new file: `true`, as for Save As.@@ -18,19 +20,27 @@ //! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and //! Export Page as PNG: `false`. They are copies; the document's file stays the same. //! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.-//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.+//! - `wordcraft:saved` (from the parent): `{ id }`, the file from that `wordcraft:save` is stored.+//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start, or+//! `{ id, error }` when a save wasn't confirmed in time. //!-//! Only the parent window on the page's own origin is heard or answered. In host mode the parent+//! The bridge is installed only for a parent on the page's own origin, or for the exact origin+//! `?host_origin=` names (a bare `scheme://host[:port]`). Messages go to that origin, and only the+//! parent window on it is heard. Any other parent gets no bridge: Save downloads and this page+//! guards leaving it. In host mode the parent //! guards leaving the page, and `?author=` names the user for comments and tracked changes. -use std::cell::Cell;+use std::cell::{Cell, RefCell}; use std::rc::Rc; use wasm_bindgen::JsCast as _; use wasm_bindgen::JsValue; use wasm_bindgen::closure::Closure;+use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome}; use wordcraft_ui_egui::{Inbox, Services, WordApp}; +use crate::bridge::SaveAcks;+ /// The longest user name taken from `?author=`. const MAX_AUTHOR_CHARS: usize = 200; @@ -39,28 +49,30 @@ pub struct Bridge { window: web_sys::Window, parent: web_sys::Window, origin: String,+ /// The unsaved-changes state last reported to the parent.+ reported: Rc<Cell<Option<bool>>>,+ /// The document's own unsaved-changes state (from `on_dirty`).+ dirty: Rc<Cell<bool>>,+ /// The app's saves in flight; each `wordcraft:save` is a ticket.+ saves: AsyncSaves,+ /// Tickets waiting for `wordcraft:saved`, with their deadlines.+ acks: Rc<RefCell<SaveAcks>>, } impl Bridge { /// `None` when the page isn't framed (there is no parent to talk to), or when the parent is- /// on another origin: messages only go to this origin, so the browser would drop every save- /// without an error while the document showed as saved.- pub fn new() -> Option<Self> {+ /// on another origin that `host_origin` doesn't name exactly ([`crate::bridge::target_origin`]).+ pub fn new(host_origin: Option<&str>, saves: AsyncSaves) -> Option<Self> { let window = web_sys::window()?; let parent = window.parent().ok().flatten()?; if js_sys::Object::is(&parent, &window) { return None; }- let origin = window.location().origin().ok()?;- // An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.- if origin == "null" {- return None;- }- // Reading another origin's location throws, which is `None` too.- if parent.location().origin().ok()? != origin {- return None;- }- Some(Self { window, parent, origin })+ let own = window.location().origin().ok()?;+ // Reading a cross-origin parent's location throws: that counts as cross-origin.+ let parent_origin = parent.location().origin().ok();+ let origin = crate::bridge::target_origin(&own, parent_origin.as_deref(), host_origin)?;+ Some(Self { window, parent, origin, reported: Rc::default(), dirty: Rc::default(), saves, acks: Rc::default() }) } /// Hand saved and exported files to the parent instead of downloading them, report unsaved@@ -72,17 +84,30 @@ impl Bridge { // Anything else that would download goes to the parent too. let bridge = self.clone(); services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));- // The browser's save picker is never used, so no save finishes later.- services.async_saves = None;+ // A save finishes when the parent confirms it (`wordcraft:saved`), like the save picker's.+ services.async_saves = Some(self.saves.clone()); let bridge = self.clone();- let reported: Rc<Cell<Option<bool>>> = Rc::default(); services.on_dirty = Some(Box::new(move |dirty: bool| {- if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {- reported.set(Some(dirty));- }+ bridge.dirty.set(dirty);+ bridge.report_dirty(); })); } + /// Once a frame: fail saves the parent didn't confirm in time, and report unsaved changes+ /// (a save still waiting for the parent counts as unsaved).+ pub fn tick(&self, ctx: &egui::Context) {+ let expired = self.acks.borrow_mut().expire(js_sys::Date::now());+ for (id, name) in expired {+ let error = format!("{name}: the host page didn't confirm the save");+ self.saves.finish(id, SaveOutcome::Failed(error.clone()));+ self.send(&[("type", "wordcraft:failed".into()), ("id", JsValue::from_f64(id as f64)), ("error", error.into())]);+ }+ if self.acks.borrow().waiting() {+ ctx.request_repaint_after(std::time::Duration::from_secs(1));+ }+ self.report_dirty();+ }+ /// `?author=`: the host's name for the user signs comments and tracked changes. pub fn set_author(app: &mut WordApp, author: &str) { let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();@@ -100,32 +125,67 @@ impl Bridge { self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]); } + fn report_dirty(&self) {+ let dirty = self.dirty.get() || self.acks.try_borrow().map_or(true, |a| a.waiting());+ if self.reported.get() != Some(dirty) && self.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {+ self.reported.set(Some(dirty));+ }+ }+ fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> { let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); let buffer = js_sys::Uint8Array::from(bytes).buffer();- let message = message(&[+ // The app's save ticket is the id the parent answers with.+ let id = self.saves.start();+ let sent = message(&[ ("type", "wordcraft:save".into()),+ ("id", JsValue::from_f64(id as f64)), ("name", name.as_str().into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into()), ])- .ok_or_else(|| "couldn't build the message".to_string())?;- self.parent- .post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))- .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))+ .ok_or_else(|| "couldn't build the message".to_string())+ .and_then(|message| {+ self.parent+ .post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))+ .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))+ });+ match sent {+ Ok(()) => {+ self.acks.borrow_mut().posted_as(id, &name, js_sys::Date::now());+ self.report_dirty();+ Ok(())+ }+ Err(e) => {+ self.saves.finish(id, SaveOutcome::Failed(e.clone()));+ Err(e)+ }+ } } /// Hear `wordcraft:open` from the parent and queue the file like a dropped one. fn listen(&self, inbox: Inbox, ctx: egui::Context) { let parent = self.parent.clone(); let origin = self.origin.clone();+ let bridge = self.clone(); let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| { if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) { return; } let data = event.data();- if string(&data, "type").as_deref() != Some("wordcraft:open") {- return;+ match string(&data, "type").as_deref() {+ Some("wordcraft:open") => {}+ Some("wordcraft:saved") => {+ let id = js_sys::Reflect::get(&data, &JsValue::from_str("id")).ok().and_then(|v| v.as_f64());+ if let Some(id) = id.filter(|id| id.is_finite() && *id >= 0.0 && id.fract() == 0.0)+ && bridge.acks.borrow_mut().saved(id as u64)+ {+ bridge.saves.finish(id as u64, SaveOutcome::Saved);+ ctx.request_repaint();+ }+ return;+ }+ _ => return, } let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string()); let Some(bytes) = bytes(&data) else { return };@@ -8,7 +8,8 @@ //! browser file picker (bytes arrive through `Services::inbox`); Save and Export download. //! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`). //! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.-//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the+//! `?host=parent` (inside an `<iframe>`) talks to the parent page instead (same-origin, or the exact+//! origin `&host_origin=` names): it sends the //! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for //! comments and tracked changes. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]@@ -18,6 +19,8 @@ mod prefs; #[cfg(any(target_arch = "wasm32", test))] mod save_fallback; +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]+mod bridge; #[cfg(target_arch = "wasm32")] mod host; #[cfg(target_arch = "wasm32")]@@ -27,12 +27,16 @@ pub fn start() { log::error!("missing <canvas id=\"{CANVAS_ID}\">"); return; };- // `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores+ // `?host=parent`: a same-origin page (or the one `?host_origin=` names) framing WordCraft opens documents in it and stores // what it saves (`host`). That page guards leaving it, so this one doesn't.- let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };- let app_bridge = bridge.clone(); let dirty = Rc::new(Cell::new(false)); let saves = AsyncSaves::default();+ let bridge = if query_param("host").as_deref() == Some("parent") {+ crate::host::Bridge::new(query_param("host_origin").as_deref(), saves.clone())+ } else {+ None+ };+ let app_bridge = bridge.clone(); if bridge.is_none() && let Err(e) = guard_unload(dirty.clone(), saves.clone()) {@@ -99,7 +103,7 @@ pub fn start() { crate::host::Bridge::set_author(&mut app, &author); prefs.keep_own_author(&app.session.author, &own); }- Ok(Box::new(WebShell { app, inbox, messages, prefs }))+ Ok(Box::new(WebShell { app, inbox, messages, prefs, bridge: app_bridge })) }), ) .await;@@ -134,6 +138,7 @@ struct WebShell { inbox: Inbox, messages: Rc<RefCell<Vec<String>>>, prefs: crate::prefs::PrefsSync,+ bridge: Option<crate::host::Bridge>, } fn local_storage() -> Option<web_sys::Storage> {@@ -154,6 +159,9 @@ impl eframe::App for WebShell { for msg in msgs { self.app.status(msg); }+ if let Some(bridge) = &self.bridge {+ bridge.tick(ctx);+ } self.app.logic(ctx); // Store changed preferences straight away: a reload or closed tab runs no exit code (#2132). if let Some(json) = self.prefs.changed(&self.app) {