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.
This commit is contained in:
Michael Higgins
2026-11-15 17:27:37 +01:00
committed by GitHub
parent e098c5c85e
commit 10727c520b
4 changed files with 35 additions and 10 deletions
+18 -4
View File
@@ -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());
}
+2 -1
View File
@@ -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
</IfModule>
<IfModule mod_headers.c>
Header set Cross-Origin-Opener-Policy "same-origin"
<FilesMatch "\.(wasm|js)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
+14 -5
View File
@@ -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"; }
}
```
+1
View File
@@ -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