Skip to content

Commit cfb9e9b

Browse files
committed
API owner token
1 parent e305576 commit cfb9e9b

12 files changed

Lines changed: 281 additions & 64 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/wastebin_core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ rusqlite_migration = { version = "2", default-features = false }
1515
rust-argon2 = "3.0.0"
1616
serde = { workspace = true }
1717
thiserror = { workspace = true }
18-
tokio = { workspace = true, features = ["io-util", "rt-multi-thread"] }
18+
tokio = { workspace = true, features = ["io-util", "macros", "rt-multi-thread", "sync", "time"] }
1919
tracing = { workspace = true }
2020
zstd = "0.13"
2121

crates/wastebin_core/src/db.rs

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use std::path::PathBuf;
33
use std::time::Duration;
44

55
use chacha20poly1305::XNonce;
6-
use rusqlite::{Connection, Transaction, params};
6+
use rusqlite::{Connection, Transaction, params, params_from_iter};
77
use rusqlite_migration::{HookError, M, Migrations};
88
use tokio::sync::oneshot;
99

@@ -80,7 +80,7 @@ enum Command {
8080
},
8181
DeleteFor {
8282
id: Id,
83-
uid: i64,
83+
uids: Vec<i64>,
8484
result: oneshot::Sender<Result<(), Error>>,
8585
},
8686
NextUid {
@@ -420,9 +420,9 @@ impl Handler {
420420
.send(self.delete_many(ids))
421421
.map_err(|_| Error::ResultSendError)?;
422422
}
423-
Command::DeleteFor { id, uid, result } => {
423+
Command::DeleteFor { id, uids, result } => {
424424
result
425-
.send(self.delete_for(id, uid))
425+
.send(self.delete_for(id, &uids))
426426
.map_err(|_| Error::ResultSendError)?;
427427
}
428428
Command::NextUid { result } => {
@@ -571,12 +571,28 @@ impl Handler {
571571
Ok(affected)
572572
}
573573

574-
fn delete_for(&mut self, id: Id, uid: i64) -> Result<(), Error> {
574+
fn delete_for(&mut self, id: Id, uids: &[i64]) -> Result<(), Error> {
575+
if uids.is_empty() {
576+
return Err(Error::Delete);
577+
}
578+
579+
let placeholders = vec!["?"; uids.len()].join(",");
580+
let select_sql = format!(
581+
"SELECT 1 FROM entries WHERE id=? AND uid IN ({placeholders})",
582+
);
583+
let delete_sql = format!(
584+
"DELETE FROM entries WHERE id=? AND uid IN ({placeholders})",
585+
);
586+
587+
let mut params = Vec::with_capacity(uids.len() + 1);
588+
params.push(id.to_i64());
589+
params.extend_from_slice(uids);
590+
575591
let tx = self.conn.transaction()?;
576592

577593
let exists = match tx.query_row(
578-
"SELECT 1 FROM entries WHERE (id=?1 AND uid=?2)",
579-
params![id.to_i64(), uid],
594+
&select_sql,
595+
params_from_iter(&params),
580596
|row| row.get::<_, i64>(0),
581597
) {
582598
Ok(_) => true,
@@ -588,10 +604,7 @@ impl Handler {
588604
return Err(Error::Delete);
589605
}
590606

591-
tx.execute(
592-
"DELETE FROM entries WHERE (id=?1 AND uid=?2)",
593-
params![id.to_i64(), uid],
594-
)?;
607+
tx.execute(&delete_sql, params_from_iter(&params))?;
595608

596609
tx.commit()?;
597610
Ok(())
@@ -722,11 +735,15 @@ impl Database {
722735
command_result.await?
723736
}
724737

725-
/// Delete paste with `id` for user `uid`.
726-
pub async fn delete_for(&self, id: Id, uid: i64) -> Result<(), Error> {
738+
/// Delete paste with `id` if any of `uids` owns it.
739+
pub async fn delete_for(&self, id: Id, uids: &[i64]) -> Result<(), Error> {
727740
let (result, command_result) = oneshot::channel();
728741
self.sender
729-
.send(Command::DeleteFor { id, uid, result })
742+
.send(Command::DeleteFor {
743+
id,
744+
uids: uids.to_vec(),
745+
result,
746+
})
730747
.await
731748
.map_err(|_| Error::SendError)?;
732749
command_result.await?
@@ -867,7 +884,7 @@ mod tests {
867884
let (id, _entry) = db.insert(entry).await?;
868885

869886
assert!(db.get(id, None).await.is_ok());
870-
assert!(db.delete_for(id, uid).await.is_ok());
887+
assert!(db.delete_for(id, &[uid]).await.is_ok());
871888
assert!(db.get(id, None).await.is_err());
872889

873890
let entry = write::Entry {
@@ -878,7 +895,29 @@ mod tests {
878895

879896
let incorrect_uid = 99;
880897
assert!(matches!(
881-
db.delete_for(id, incorrect_uid).await,
898+
db.delete_for(id, &[incorrect_uid]).await,
899+
Err(Error::Delete)
900+
));
901+
902+
// Multi-uid: passes if any uid in the list matches.
903+
let entry = write::Entry {
904+
uid: Some(uid),
905+
..Default::default()
906+
};
907+
let (id, _entry) = db.insert(entry).await?;
908+
909+
assert!(db.delete_for(id, &[99, uid, 7]).await.is_ok());
910+
assert!(db.get(id, None).await.is_err());
911+
912+
// Empty slice: nothing to authorize against.
913+
let entry = write::Entry {
914+
uid: Some(uid),
915+
..Default::default()
916+
};
917+
let (id, _entry) = db.insert(entry).await?;
918+
919+
assert!(matches!(
920+
db.delete_for(id, &[]).await,
882921
Err(Error::Delete)
883922
));
884923

crates/wastebin_server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ askama_web = { version = "0.15.0", features = ["axum-0.8"] }
1111
axum = { version = "0.8", features = ["json", "query", "macros"] }
1212
axum-extra = { version = "0.12", features = ["cookie-signed", "typed-header"] }
1313
cached = { version = "0.59.0", default-features = false }
14+
cookie = { version = "0.18", features = ["signed"] }
1415
futures = "0.3.31"
1516
hex = "0.4"
1617
hostname = "0.4.0"

crates/wastebin_server/src/handlers/delete/api.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ use axum::extract::{Path, State};
22

33
use crate::Database;
44
use crate::errors::{Error, JsonErrorResponse};
5-
use crate::handlers::extract::Uid;
5+
use crate::handlers::extract::Uids;
66

77
pub async fn delete(
88
Path(id): Path<String>,
99
State(db): State<Database>,
10-
Uid(uid): Uid,
10+
Uids(uids): Uids,
1111
) -> Result<(), JsonErrorResponse> {
1212
let id = id.parse().map_err(Error::Id)?;
13-
db.delete_for(id, uid).await.map_err(Error::Database)?;
13+
db.delete_for(id, &uids).await.map_err(Error::Database)?;
1414
Ok(())
1515
}
1616

crates/wastebin_server/src/handlers/delete/form.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
use axum::extract::{Path, State};
22
use axum::response::Redirect;
33

4-
use crate::handlers::extract::{Theme, Uid};
4+
use crate::handlers::extract::{Theme, Uids};
55
use crate::handlers::html::{ErrorResponse, make_error};
66
use crate::{Database, Page};
77

88
pub async fn delete(
99
Path(id): Path<String>,
1010
State(db): State<Database>,
1111
State(page): State<Page>,
12-
Uid(uid): Uid,
12+
Uids(uids): Uids,
1313
theme: Option<Theme>,
1414
) -> Result<Redirect, ErrorResponse> {
1515
async {
1616
let id = id.parse()?;
17-
db.delete_for(id, uid).await?;
17+
db.delete_for(id, &uids).await?;
1818
Ok(Redirect::to("/"))
1919
}
2020
.await

crates/wastebin_server/src/handlers/extract.rs

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use axum::http::request::Parts;
88
use axum::response::Redirect;
99
use axum_extra::extract::cookie::Key;
1010
use axum_extra::extract::{CookieJar, SignedCookieJar};
11+
use cookie::Cookie;
1112
use serde::Deserialize;
1213

1314
use wastebin_core::crypto;
@@ -39,7 +40,12 @@ pub(crate) struct Preference {
3940
pub(crate) struct Password(pub crypto::Password);
4041

4142
/// Uid cookie value extractor, extracted from the `uid` cookie.
42-
pub(crate) struct Uid(pub i64);
43+
///
44+
/// The cookie holds a comma-separated list of i64 values: index 0 is the client's
45+
/// primary identity (used by the form route when tagging new pastes), subsequent
46+
/// entries are uids claimed via the magic-link `?owner=` handoff. An empty list
47+
/// is valid (e.g. cookie was just signed-but-empty).
48+
pub(crate) struct Uids(pub Vec<i64>);
4349

4450
/// Password header to encrypt a paste.
4551
pub(crate) const PASSWORD_HEADER_NAME: http::HeaderName =
@@ -87,7 +93,46 @@ where
8793
}
8894
}
8995

90-
impl<S> FromRequestParts<S> for Uid
96+
/// Parse the comma-separated `uid` cookie value into a list of i64s.
97+
/// Non-numeric tokens are skipped silently — a tampered cookie that survives
98+
/// HMAC verification but contains junk should not 500 the request.
99+
pub(crate) fn parse_uids(value: &str) -> Vec<i64> {
100+
value
101+
.split(',')
102+
.filter_map(|s| s.trim().parse::<i64>().ok())
103+
.collect()
104+
}
105+
106+
/// Serialize a uid list back into the cookie wire format.
107+
pub(crate) fn serialize_uids(uids: &[i64]) -> String {
108+
uids.iter()
109+
.map(i64::to_string)
110+
.collect::<Vec<_>>()
111+
.join(",")
112+
}
113+
114+
/// Sign a single uid using the same key as the `uid` cookie. The returned
115+
/// string is meant to be carried in the `?owner=` query of a paste URL so the
116+
/// server can promote it to a proper signed cookie on first GET.
117+
pub(crate) fn sign_owner_token(key: &Key, uid: i64) -> String {
118+
let mut jar = cookie::CookieJar::new();
119+
jar.signed_mut(key)
120+
.add(Cookie::new("uid", uid.to_string()));
121+
jar.get("uid")
122+
.map(|cookie| cookie.value().to_string())
123+
.unwrap_or_default()
124+
}
125+
126+
/// Verify a token produced by [`sign_owner_token`] and recover the uid.
127+
pub(crate) fn verify_owner_token(key: &Key, token: &str) -> Option<i64> {
128+
let mut jar = cookie::CookieJar::new();
129+
jar.add(Cookie::new("uid", token.to_owned()));
130+
jar.signed(key)
131+
.get("uid")
132+
.and_then(|cookie| cookie.value().parse::<i64>().ok())
133+
}
134+
135+
impl<S> FromRequestParts<S> for Uids
91136
where
92137
S: Send + Sync,
93138
Key: FromRef<S>,
@@ -99,19 +144,16 @@ where
99144
.await
100145
.map_err(|_| ())?;
101146

102-
jar.get("uid")
103-
.map(|cookie| {
104-
cookie
105-
.value_trimmed()
106-
.parse::<i64>()
107-
.map(Uid)
108-
.map_err(|_| ())
109-
})
110-
.ok_or(())?
147+
let uids = jar
148+
.get("uid")
149+
.map(|cookie| parse_uids(cookie.value_trimmed()))
150+
.ok_or(())?;
151+
152+
Ok(Uids(uids))
111153
}
112154
}
113155

114-
impl<S> OptionalFromRequestParts<S> for Uid
156+
impl<S> OptionalFromRequestParts<S> for Uids
115157
where
116158
S: Send + Sync,
117159
Key: FromRef<S>,
@@ -123,7 +165,7 @@ where
123165
state: &S,
124166
) -> Result<Option<Self>, Self::Rejection> {
125167
Ok(
126-
<Uid as FromRequestParts<S>>::from_request_parts(parts, state)
168+
<Uids as FromRequestParts<S>>::from_request_parts(parts, state)
127169
.await
128170
.ok(),
129171
)

crates/wastebin_server/src/handlers/html/paste.rs

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,31 @@
11
use askama::Template;
22
use askama_web::WebTemplate;
3-
use axum::extract::{Form, Path, State};
4-
use axum::response::{IntoResponse, Response};
3+
use axum::extract::{Form, Path, Query, State};
4+
use axum::response::{IntoResponse, Redirect, Response};
5+
use axum_extra::extract::SignedCookieJar;
6+
use axum_extra::extract::cookie::Key as CookieKey;
57
use serde::Deserialize;
68

79
use crate::cache::{Key, Mode};
8-
use crate::handlers::extract::{Theme, Uid};
10+
use crate::handlers::cookie;
11+
use crate::handlers::extract::{Theme, Uids, serialize_uids, verify_owner_token};
912
use crate::handlers::html::{BurnConfirmation, ErrorResponse, PasswordInput, make_error};
1013
use crate::{Cache, Database, Highlighter, Page};
1114
use wastebin_core::crypto::Password;
1215
use wastebin_core::db;
1316
use wastebin_core::db::read::{Data, Entry, Metadata};
1417
use wastebin_core::expiration::Expiration;
1518

19+
/// Magic-link handoff: when a paste was created via the JSON API, the response
20+
/// contains a signed `owner` token. Opening `/<id>?owner=<token>` lets the
21+
/// browser claim ownership of the paste — the server validates the token,
22+
/// appends the uid to the user's signed `uid` cookie, and redirects to the
23+
/// clean paste URL so the token never leaks via Referer.
24+
#[derive(Deserialize, Debug)]
25+
pub(crate) struct OwnerHandoff {
26+
pub(crate) owner: Option<String>,
27+
}
28+
1629
#[derive(Deserialize, Debug)]
1730
pub(crate) struct PasswordForm {
1831
pub(crate) password: String,
@@ -55,11 +68,29 @@ pub async fn get<E>(
5568
State(page): State<Page>,
5669
State(db): State<Database>,
5770
State(highlighter): State<Highlighter>,
71+
State(cookie_key): State<CookieKey>,
5872
Path(id): Path<String>,
59-
uid: Option<Uid>,
73+
Query(handoff): Query<OwnerHandoff>,
74+
jar: SignedCookieJar,
75+
uids: Option<Uids>,
6076
theme: Option<Theme>,
6177
form: Result<Form<PasteForm>, E>,
6278
) -> Result<Response, ErrorResponse> {
79+
if let Some(token) = handoff.owner.as_deref()
80+
&& let Some(claimed_uid) = verify_owner_token(&cookie_key, token)
81+
{
82+
let mut new_uids = uids
83+
.as_ref()
84+
.map(|Uids(list)| list.clone())
85+
.unwrap_or_default();
86+
if !new_uids.contains(&claimed_uid) {
87+
new_uids.push(claimed_uid);
88+
}
89+
let mut cookie = cookie("uid", serialize_uids(&new_uids));
90+
cookie.set_secure(true);
91+
return Ok((jar.add(cookie), Redirect::to(&format!("/{id}"))).into_response());
92+
}
93+
6394
async {
6495
let form = form.ok().map(|Form(form)| form);
6596
let password = form
@@ -108,9 +139,10 @@ pub async fn get<E>(
108139
..
109140
} = metadata;
110141

111-
let can_delete = uid
112-
.zip(owner_uid)
113-
.is_some_and(|(Uid(user_uid), owner_uid)| user_uid == owner_uid);
142+
let can_delete = match (uids, owner_uid) {
143+
(Some(Uids(uids)), Some(owner_uid)) => uids.contains(&owner_uid),
144+
_ => false,
145+
};
114146

115147
let html = if let Some(html) = cache.get(&key, Mode::Source) {
116148
tracing::trace!(?key, "found cached item");

0 commit comments

Comments
 (0)