From 84cbc247dd0a82221703beff3d4dcef42ae24c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20G=C3=B6ttsche?= Date: Thu, 22 Oct 2026 21:47:25 +0200 Subject: [PATCH] html: keep an encrypted paste's title back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the content is encrypted; the title is a plain column. Both the QR page and the burn interstitial render metadata before any password is asked for, so either one handed the title to whoever held the id — while `/{id}` itself withholds it. That inconsistency is the whole bug: people put in the title what they believe the password covers. Metadata now carries whether the row has a nonce, and both views leave the title out when it does. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + crates/wastebin_core/src/db.rs | 8 ++- .../src/handlers/html/paste.rs | 43 ++++++++++++- .../wastebin_server/src/handlers/html/qr.rs | 64 +++++++++++++++++++ .../src/handlers/html/rendered.rs | 6 +- 5 files changed, 120 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d352978..23207733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- Do not show an encrypted paste's title on the QR page or the burn + confirmation, which are reachable without the password. - Reject off-origin and scheme-relative `Referer` values when redirecting back after a theme change, closing an open-redirect bypass via `//`-prefixed paths. - Unlink Unix domain socket after shutdown. diff --git a/crates/wastebin_core/src/db.rs b/crates/wastebin_core/src/db.rs index dee81566..303dcf53 100644 --- a/crates/wastebin_core/src/db.rs +++ b/crates/wastebin_core/src/db.rs @@ -255,6 +255,11 @@ pub mod read { pub expiration: Option, /// Entry will be deleted the next time it is fetched via [`Database::get`]. pub must_be_deleted: bool, + /// Entry's content is encrypted, so reading it needs a password. + /// + /// The title is not encrypted along with it, so a view that shows one before the password + /// is supplied has to consult this. + pub is_encrypted: bool, } /// Potentially deleted or non-existent expired entry. @@ -488,7 +493,7 @@ impl Handler { fn get_metadata(&self, id: Id) -> Result { let metadata = self.conn.query_row( - "SELECT uid, title, CAST(ROUND((julianday(expires) - julianday('now')) * 86400) AS INTEGER), burn_after_reading FROM entries WHERE id=?1", + "SELECT uid, title, CAST(ROUND((julianday(expires) - julianday('now')) * 86400) AS INTEGER), burn_after_reading, nonce IS NOT NULL FROM entries WHERE id=?1", params![id.to_i64()], |row| { let expiration = row.get::<_, Option>(2)? @@ -501,6 +506,7 @@ impl Handler { title: row.get::<_, Option>(1)?, expiration, must_be_deleted: row.get::<_, Option>(3)?.unwrap_or(false), + is_encrypted: row.get(4)?, }) } )?; diff --git a/crates/wastebin_server/src/handlers/html/paste.rs b/crates/wastebin_server/src/handlers/html/paste.rs index d7ac25b8..01879b3f 100644 --- a/crates/wastebin_server/src/handlers/html/paste.rs +++ b/crates/wastebin_server/src/handlers/html/paste.rs @@ -106,7 +106,11 @@ pub async fn get( page: page.clone(), theme: theme.clone(), lang, - title: metadata.title.clone(), + // The interstitial comes before any password is asked for, and the title is not + // encrypted along with the content. + title: (!metadata.is_encrypted) + .then(|| metadata.title.clone()) + .flatten(), action: FormAction::source(&key), } .into_response()); @@ -264,4 +268,41 @@ mod tests { Ok(()) } + + /// The interstitial is shown before any password is asked for, so an encrypted + /// burn-after-reading paste handed its title to whoever opened the link. + #[tokio::test] + async fn the_burn_interstitial_keeps_an_encrypted_title_back() + -> Result<(), Box> { + let client = Client::new(StoreCookies(false)).await; + + let paste = client + .post_json() + .json(&crate::handlers::insert::api::Entry { + text: "FooBarBaz".to_string(), + title: Some("Q1-layoff-list".to_string()), + password: Some("hunter2".to_string()), + burn_after_reading: Some(true), + ..Default::default() + }) + .send() + .await? + .json::() + .await?; + + for view in [paste.path.clone(), format!("/md{}", paste.path)] { + let body = client.get(&view).send().await?.text().await?; + + assert!( + body.contains("confirm_burn"), + "{view}: no interstitial: {body}" + ); + assert!( + !body.contains("Q1-layoff-list"), + "{view}: title leaked: {body}" + ); + } + + Ok(()) + } } diff --git a/crates/wastebin_server/src/handlers/html/qr.rs b/crates/wastebin_server/src/handlers/html/qr.rs index 882856f1..5eb3a265 100644 --- a/crates/wastebin_server/src/handlers/html/qr.rs +++ b/crates/wastebin_server/src/handlers/html/qr.rs @@ -38,9 +38,15 @@ pub async fn get( uid: owner_uid, title, expiration, + is_encrypted, .. } = db.get_metadata(key.id).await?; + // Only the content is encrypted; the title is a plain column, and this view never asks for + // a password. `/{id}` withholds it, so showing it here handed anyone with the id the one + // thing the password was assumed to cover. + let title = (!is_encrypted).then_some(title).flatten(); + let can_delete = match (uids, owner_uid) { (Some(Uids(uids)), Some(owner_uid)) => uids.contains(&owner_uid), _ => false, @@ -102,3 +108,61 @@ pub fn dark_modules(code: &QrCode) -> Vec<(i32, i32)> { .filter(|(x, y)| code.get_module(*x, *y)) .collect() } + +#[cfg(test)] +mod tests { + use crate::handlers::insert::api::{Entry, RedirectResponse}; + use crate::test_helpers::{Client, StoreCookies}; + + async fn qr_page_of(entry: &Entry) -> Result> { + let client = Client::new(StoreCookies(false)).await; + + let payload = client + .post_json() + .json(entry) + .send() + .await? + .json::() + .await?; + + Ok(client + .get(&format!("/qr{}", payload.path)) + .send() + .await? + .text() + .await?) + } + + /// Only the content is encrypted; the title is a plain column. This view reads metadata and + /// never asks for a password, so it handed the title to anyone holding the id — while `/{id}` + /// withholds it. People put in titles what they think the password covers. + #[tokio::test] + async fn an_encrypted_paste_keeps_its_title_back() -> Result<(), Box> { + let body = qr_page_of(&Entry { + text: "FooBarBaz".to_string(), + title: Some("Q1-layoff-list".to_string()), + password: Some("hunter2".to_string()), + ..Default::default() + }) + .await?; + + assert!(!body.contains("Q1-layoff-list"), "title leaked: {body}"); + + Ok(()) + } + + /// An unencrypted paste has nothing to hide, so its title still shows. + #[tokio::test] + async fn a_plain_paste_still_shows_its_title() -> Result<(), Box> { + let body = qr_page_of(&Entry { + text: "FooBarBaz".to_string(), + title: Some("release-notes".to_string()), + ..Default::default() + }) + .await?; + + assert!(body.contains("release-notes"), "title missing: {body}"); + + Ok(()) + } +} diff --git a/crates/wastebin_server/src/handlers/html/rendered.rs b/crates/wastebin_server/src/handlers/html/rendered.rs index d8c4f893..be9948e2 100644 --- a/crates/wastebin_server/src/handlers/html/rendered.rs +++ b/crates/wastebin_server/src/handlers/html/rendered.rs @@ -64,7 +64,11 @@ pub async fn get( page: page.clone(), theme: theme.clone(), lang, - title: metadata.title.clone(), + // The interstitial comes before any password is asked for, and the title is not + // encrypted along with the content. + title: (!metadata.is_encrypted) + .then(|| metadata.title.clone()) + .flatten(), action: FormAction::rendered(&key), } .into_response());