collective

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

diff --git a/apps/wordcraft-web/src/bridge.rs b/apps/wordcraft-web/src/bridge.rsnew file mode 100644--- /dev/null+++ b/apps/wordcraft-web/src/bridge.rs@@ -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());+    }+}diff --git a/apps/wordcraft-web/src/host.rs b/apps/wordcraft-web/src/host.rs--- a/apps/wordcraft-web/src/host.rs+++ b/apps/wordcraft-web/src/host.rs@@ -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 };diff --git a/apps/wordcraft-web/src/main.rs b/apps/wordcraft-web/src/main.rs--- a/apps/wordcraft-web/src/main.rs+++ b/apps/wordcraft-web/src/main.rs@@ -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")]diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs--- a/apps/wordcraft-web/src/web.rs+++ b/apps/wordcraft-web/src/web.rs@@ -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) {