Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion crates/wastebin_core/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ pub mod read {
pub expiration: Option<Expiration>,
/// 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.
Expand Down Expand Up @@ -488,7 +493,7 @@ impl Handler {

fn get_metadata(&self, id: Id) -> Result<Metadata, Error> {
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<i64>>(2)?
Expand All @@ -501,6 +506,7 @@ impl Handler {
title: row.get::<_, Option<String>>(1)?,
expiration,
must_be_deleted: row.get::<_, Option<bool>>(3)?.unwrap_or(false),
is_encrypted: row.get(4)?,
})
}
)?;
Expand Down
43 changes: 42 additions & 1 deletion crates/wastebin_server/src/handlers/html/paste.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,11 @@ pub async fn get<E>(
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());
Expand Down Expand Up @@ -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<dyn std::error::Error>> {
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::<crate::handlers::insert::api::RedirectResponse>()
.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(())
}
}
64 changes: 64 additions & 0 deletions crates/wastebin_server/src/handlers/html/qr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String, Box<dyn std::error::Error>> {
let client = Client::new(StoreCookies(false)).await;

let payload = client
.post_json()
.json(entry)
.send()
.await?
.json::<RedirectResponse>()
.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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}
}
6 changes: 5 additions & 1 deletion crates/wastebin_server/src/handlers/html/rendered.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ pub async fn get<E>(
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());
Expand Down
Loading