Fix: PDF preview blocked by X-Frame-Options DENY — allow same-origin framing for non-scriptable inline files

Co-Authored-By: Qwen3.8 27b
AuthorKonata <konata@posteo.jp>
Date
Commitaf1d8905ecd55cf294750943d0789886e1b9d6eb
Parentd0e6141
3 files changed, 46 insertions(+), 9 deletions(-)
▾Mserver/src/api/files.rs
@@ -327,8 +327,14 @@ async fn file_response(
// A file the browser would parse as a document (HTML/SVG/XML) is served
// under the sandboxed policy, so it can render as a page without being
// able to act as the app. Derived from the same `mime` we declare.
// Non-scriptable inline files (PDF, …) are frameable by the app itself,
// for the preview modal.
if crate::api::is_scriptable_mime(&mime) {
res = res.header("content-security-policy", crate::api::FILE_CSP);
} else if inline {
res = res
.header("content-security-policy", crate::api::INLINE_CSP)
.header(header::X_FRAME_OPTIONS, "SAMEORIGIN");
}
res.header(header::CONTENT_TYPE, mime)
.body(body)
▾Mserver/src/api/mod.rs
@@ -43,6 +43,11 @@ const CSP: &str = "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-u
/// access to this server.
pub(crate) const FILE_CSP: &str = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src *; object-src 'none'; frame-ancestors 'none'; sandbox allow-scripts allow-forms allow-modals allow-downloads allow-popups allow-top-navigation-by-user-activation;";
/// Content-Security-Policy for inline (preview) files that are *not*
/// scripting documents (PDF, media, …): the preview modal embeds them in a
/// same-origin `<iframe>`. Scriptable files never get this — see [`FILE_CSP`].
pub(crate) const INLINE_CSP: &str = "frame-ancestors 'self';";
/// True for MIME types the browser parses as a scripting document. These are
/// the responses that need [`FILE_CSP`]; everything else keeps the app policy.
///
@@ -98,8 +103,9 @@ pub fn router(state: Arc<AppState>) -> Router {
.fallback(spa::fallback)
.with_state(state)
// Hard security headers on every response (API and static alike).
// CSP is `if_not_present` so that file responses can substitute the
// sandboxed [`FILE_CSP`]; everything else gets the app policy.
// CSP/XFO are `if_not_present` so file responses can substitute
// [`FILE_CSP`] or the same-origin-framable [`INLINE_CSP`]; everything
// else gets the app policy.
.layer(SetResponseHeaderLayer::if_not_present(
"content-security-policy".parse().unwrap(),
HeaderValue::from_static(CSP),
@@ -108,7 +114,7 @@ pub fn router(state: Arc<AppState>) -> Router {
"x-content-type-options".parse().unwrap(),
HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::overriding(
.layer(SetResponseHeaderLayer::if_not_present(
"x-frame-options".parse().unwrap(),
HeaderValue::from_static("DENY"),
))
▾Mserver/tests/api_files.rs
@@ -767,9 +767,10 @@ async fn listing_reports_sniffed_kinds() {
assert_eq!(kind("config.json"), "text");
}
/// A file the browser would parse as a document is served sandboxed, so it can
/// render as a page without being able to act as the app. Everything else
/// keeps the app policy.
/// A file the browser would parse as a document is served sandboxed, so it
/// can render as a page without being able to act as the app. Scriptable
/// files are never frameable; non-scriptable previews are frameable by the
/// app itself only.
#[tokio::test]
async fn scriptable_files_are_served_sandboxed() {
let env = Env::new().await;
@@ -804,18 +805,41 @@ async fn scriptable_files_are_served_sandboxed() {
.unwrap()
.starts_with("inline")
);
// Never frameable: same-origin framing would give its JS access to
// the app.
assert!(
csp.contains("frame-ancestors 'none'"),
"{name} must never be frameable: {csp}"
);
assert_eq!(
r.header("x-frame-options").as_deref(),
Some("DENY"),
"{name}"
);
}
// A non-scriptable file keeps the app policy (no sandbox at all).
// A non-scriptable preview is frameable by the app itself only.
let r = admin
.get(&format!("{}?action=preview", root_path("blob.bin")))
.await;
let csp = r.header("content-security-policy").unwrap();
assert!(!csp.contains("sandbox"), "{csp}");
assert!(
csp.contains("wasm-unsafe-eval"),
"expected app policy: {csp}"
csp.contains("frame-ancestors 'self'"),
"preview must be frameable same-origin: {csp}"
);
assert_eq!(r.header("x-frame-options").as_deref(), Some("SAMEORIGIN"));
// The same file as a *download* keeps the app policy (unframeable).
let r = admin
.get(&format!("{}?action=download", root_path("blob.bin")))
.await;
let csp = r.header("content-security-policy").unwrap();
assert!(
csp.contains("frame-ancestors 'none'"),
"download must keep the app policy: {csp}"
);
assert_eq!(r.header("x-frame-options").as_deref(), Some("DENY"));
// And the app's own pages are untouched by the `if_not_present` switch.
let r = admin.get("/").await;
@@ -824,6 +848,7 @@ async fn scriptable_files_are_served_sandboxed() {
csp.contains("wasm-unsafe-eval") && !csp.contains("sandbox"),
"{csp}"
);
assert_eq!(r.header("x-frame-options").as_deref(), Some("DENY"));
}
#[tokio::test]