From 10727c520b43ceb57b3f2b88942e3239d010b29c Mon Sep 17 00:00:00 2001 From: Michael Higgins <55243596+Higgs32584@users.noreply.github.com> Date: Sun, 15 Nov 2026 11:27:37 -0500 Subject: [PATCH] fix(web): open document links with noopener and send COOP same-origin (#5091) ## What this changes On the web build, `PdfCraftApp::open_url` now opens links with `window.open(url, "_blank", "noopener,noreferrer")` via `web-sys`, instead of `egui::OpenUrl::new_tab`. eframe opens new tabs without features, which gave the opened page a `window.opener` handle it could use to navigate the PdfCraft tab. Every app-level open goes through `open_url`: document links, button URI actions, `app.launchURL`, help links and the releases page. Desktop builds are unchanged. The web packaging now sends `Cross-Origin-Opener-Policy: same-origin`: `_headers`, `.htaccess`, and the nginx example in `packaging/web/README.md` (`HOSTING.md`), whose old "doesn't need COOP" note is rewritten. Fixes #5084 ## How you verified it `cargo fmt --check` and `cargo clippy --all-targets -- -D warnings` are clean on `pdfcraft-ui-egui`, including the review fixes. Tests not run yet; CI runs them. `cargo check -p pdfcraft-ui-egui --target wasm32-unknown-unknown` passes too, with no warnings in `links.rs`. - [x] `cargo fmt --check` - [x] clippy with `-D warnings` on the crates I changed - [ ] tests pass on the crates I changed ## Anything a reviewer should look at first - COOP `same-origin` has no effect inside an iframe, so the `postMessage` embedding bridge is unaffected. A page on another origin that opens PdfCraft with `window.open` does lose its window handle. Nothing in the repo does that. - Not covered: `dialogs/credits.rs` uses `ui.hyperlink_to` for fixed GitHub URLs, which on the web may open in the PdfCraft tab itself. --- crates/ui-egui/src/app/links.rs | 22 ++++++++++++++++++---- packaging/web/.htaccess | 3 ++- packaging/web/README.md | 19 ++++++++++++++----- packaging/web/_headers | 1 + 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/app/links.rs b/crates/ui-egui/src/app/links.rs index 028560b17b..2785cb1fe5 100644 --- a/crates/ui-egui/src/app/links.rs +++ b/crates/ui-egui/src/app/links.rs @@ -138,11 +138,25 @@ impl PdfCraftApp { } } - /// Open a web link in the system browser (a new tab on the web). Only for PdfCraft's own - /// links; an address that came from a document goes through [`Self::request_document_url`]. + /// Open a web link in the system browser (a new `noopener` tab on the web). Only for + /// PdfCraft's own links; an address that came from a document goes through + /// [`Self::request_document_url`]. pub fn open_url(&mut self, url: &str) { - if let Some(ctx) = &self.ctx { - ctx.open_url(egui::OpenUrl::new_tab(url)); + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(ctx) = &self.ctx { + ctx.open_url(egui::OpenUrl::new_tab(url)); + } + } + // eframe's web runner opens new tabs with `window.open(url, "_blank")` and no features, + // which hands the opened page `window.opener` and lets it navigate this tab (#5084). + #[cfg(target_arch = "wasm32")] + { + if let Some(window) = web_sys::window() { + // With `noopener` the browser answers `null` instead of the new window, and a + // blocked pop-up is `null` or an error: there is nothing to keep or retry. + let _ = window.open_with_url_and_target_and_features(url, "_blank", "noopener,noreferrer"); + } } self.last_opened_url = Some(url.to_string()); } diff --git a/packaging/web/.htaccess b/packaging/web/.htaccess index 7b7e6e185f..b7333bc38a 100644 --- a/packaging/web/.htaccess +++ b/packaging/web/.htaccess @@ -1,4 +1,4 @@ -# Apache (see HOSTING.md). Needs mod_mime; mod_deflate and mod_headers are optional. +# Apache (see HOSTING.md). Needs mod_mime and mod_headers (for COOP); mod_deflate is optional. AddType application/wasm .wasm AddType text/javascript .js @@ -7,6 +7,7 @@ AddType text/javascript .js + Header set Cross-Origin-Opener-Policy "same-origin" Header set Cache-Control "public, max-age=31536000, immutable" diff --git a/packaging/web/README.md b/packaging/web/README.md index f5a51b4259..dfdd3b25a9 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -38,10 +38,17 @@ workspace and Save target stay selected. Click the downloaded file's tab to swit `.js` files, and `no-cache` on `index.html`. - **HTTPS:** WebGPU (and the clipboard) only work in a secure context, which means `https://` or `http://localhost`. Over plain HTTP elsewhere, the app falls back to WebGL2. -- **No special isolation headers:** PdfCraft doesn't use `SharedArrayBuffer`, so it doesn't - need `Cross-Origin-Opener-Policy` or `Cross-Origin-Embedder-Policy`. If your site already sends - COEP `require-corp`, also send `Cross-Origin-Resource-Policy: same-origin` (or `cross-origin` - when the files live on a CDN) on the app's files. +- **Opener isolation:** send `Cross-Origin-Opener-Policy: same-origin` (the sample `_headers` + and `.htaccess` do; Apache needs `mod_headers` for it). Pages opened from a document's links + then can't reach or navigate the PdfCraft tab. The app opens links with `noopener` already; + the header also cuts the opener for pages on another origin opened by any other means. A page + on the same origin that sends the same policy keeps its opener. The header has no effect + inside an iframe, so the embedding bridge below is unaffected, but a page on another origin + that opens PdfCraft with `window.open` loses its handle to that window. +- **No cross-origin isolation:** PdfCraft doesn't use `SharedArrayBuffer`, so it doesn't need + `Cross-Origin-Embedder-Policy`. If your site already sends COEP `require-corp`, also send + `Cross-Origin-Resource-Policy: same-origin` (or `cross-origin` when the files live on a CDN) on + the app's files. nginx example: @@ -50,8 +57,10 @@ location /pdfcraft/ { types { application/wasm wasm; text/javascript js; text/html html; } gzip on; gzip_types application/wasm text/javascript text/html; + add_header Cross-Origin-Opener-Policy "same-origin"; location ~* \.(wasm|js)$ { add_header Cache-Control "public, max-age=31536000, immutable"; } - location ~* index\.html$ { add_header Cache-Control "no-cache"; } + # A location with its own add_header drops the outer ones, so the page repeats COOP. + location ~* index\.html$ { add_header Cache-Control "no-cache"; add_header Cross-Origin-Opener-Policy "same-origin"; } } ``` diff --git a/packaging/web/_headers b/packaging/web/_headers index fac0fbe1fd..9668ed3066 100644 --- a/packaging/web/_headers +++ b/packaging/web/_headers @@ -1,6 +1,7 @@ # Netlify / Cloudflare Pages header rules (see HOSTING.md). Other hosts: set the same headers. /* X-Content-Type-Options: nosniff + Cross-Origin-Opener-Policy: same-origin /*.wasm Content-Type: application/wasm