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