mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 00:47:01 +01:00
feature(SHARE) added secret token to share to prevent getting crawled
This commit is contained in:
1 parent
7646ecc397
commit
76f0887ac5
12 files changed
+199
-67
No files matched your search
+2
-2
@@ -27,6 +27,6 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/entries/{id}/share", delete(share::disable))
|
||||
.route("/media", post(media::upload).get(media::list))
|
||||
.route("/media/{id}", get(media::serve).delete(media::remove))
|
||||
.route("/share/{token}", get(share::read))
|
||||
.route("/share/{token}/media/{id}", get(share::serve_media))
|
||||
.route("/share/{token}/{key}", get(share::read))
|
||||
.route("/share/{token}/{key}/media/{id}", get(share::serve_media))
|
||||
}
|
||||
+100
-30
@@ -7,7 +7,9 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine};
|
||||
use rand::RngCore;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::Row;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::{
|
||||
auth::Session,
|
||||
@@ -25,39 +27,63 @@ pub struct SharedEntry {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
fn new_token() -> String {
|
||||
fn random_secret() -> String {
|
||||
let mut bytes = [0u8; 24];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
B64.encode(bytes)
|
||||
}
|
||||
|
||||
fn hash_key(key: &str) -> String {
|
||||
B64.encode(Sha256::digest(key.as_bytes()))
|
||||
}
|
||||
|
||||
/// Constant-time check of a presented key against the stored hash. A share with
|
||||
/// no hash at all is unreadable rather than open: that combination should not
|
||||
/// exist, and failing closed is the safe reading of it.
|
||||
fn key_matches(stored: Option<&str>, presented: &str) -> bool {
|
||||
let Some(stored) = stored else { return false };
|
||||
bool::from(hash_key(presented).as_bytes().ct_eq(stored.as_bytes()))
|
||||
}
|
||||
|
||||
/// Publish an entry, or mint a fresh key for one that is already published.
|
||||
///
|
||||
/// The plaintext key is returned exactly once, here. Nothing but its hash is
|
||||
/// stored, so a link that the author loses cannot be recovered — only replaced,
|
||||
/// which is what calling this again does. Rotating the key kills the old link
|
||||
/// while leaving the token, so a revoked reader loses access even if they kept
|
||||
/// the path.
|
||||
pub async fn enable(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let existing: Option<Option<String>> = sqlx::query("SELECT share_token FROM entries WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.map(|row| row.get("share_token"));
|
||||
let existing: Option<Option<String>> =
|
||||
sqlx::query("SELECT share_token FROM entries WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.map(|row| row.get("share_token"));
|
||||
|
||||
let token = match existing {
|
||||
None => return Err(AppError::NotFound),
|
||||
Some(Some(token)) => token,
|
||||
Some(None) => {
|
||||
let token = new_token();
|
||||
sqlx::query("UPDATE entries SET share_token = ?1 WHERE id = ?2")
|
||||
.bind(&token)
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
token
|
||||
}
|
||||
Some(None) => random_secret(),
|
||||
};
|
||||
let key = random_secret();
|
||||
|
||||
sqlx::query("UPDATE entries SET share_token = ?1, share_key_hash = ?2 WHERE id = ?3")
|
||||
.bind(&token)
|
||||
.bind(hash_key(&key))
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
state.backup.signal();
|
||||
Ok(Json(json!({ "token": token, "path": format!("/s/{token}") })))
|
||||
Ok(Json(json!({
|
||||
"token": token,
|
||||
"key": key,
|
||||
"path": format!("/s/{token}#{key}"),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn disable(
|
||||
@@ -65,11 +91,12 @@ pub async fn disable(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let affected = sqlx::query("UPDATE entries SET share_token = NULL WHERE id = ?1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
let affected =
|
||||
sqlx::query("UPDATE entries SET share_token = NULL, share_key_hash = NULL WHERE id = ?1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if affected == 0 {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
@@ -77,19 +104,28 @@ pub async fn disable(
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Public: no session required, the unguessable token is the credential.
|
||||
/// Public: no session required. The credential is the pair — the token from the
|
||||
/// link's path and the key the reader's browser lifted out of its fragment.
|
||||
pub async fn read(
|
||||
State(state): State<AppState>,
|
||||
Path(token): Path<String>,
|
||||
Path((token, key)): Path<(String, String)>,
|
||||
) -> AppResult<Json<SharedEntry>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT title, body, created_at, updated_at FROM entries WHERE share_token = ?1",
|
||||
"SELECT title, body, created_at, updated_at, share_key_hash
|
||||
FROM entries WHERE share_token = ?1",
|
||||
)
|
||||
.bind(&token)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
|
||||
let stored: Option<String> = row.get("share_key_hash");
|
||||
if !key_matches(stored.as_deref(), &key) {
|
||||
// Deliberately the same answer as an unknown token: a wrong key must not
|
||||
// confirm that the token itself is live.
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
Ok(Json(SharedEntry {
|
||||
title: row.get("title"),
|
||||
body: row.get("body"),
|
||||
@@ -99,13 +135,14 @@ pub async fn read(
|
||||
}))
|
||||
}
|
||||
|
||||
/// Public: serves a media file only while it is embedded in a shared entry.
|
||||
/// Public: serves a media file only while it is embedded in a shared entry, and
|
||||
/// only to a caller holding that entry's key.
|
||||
pub async fn serve_media(
|
||||
State(state): State<AppState>,
|
||||
Path((token, id)): Path<(String, String)>,
|
||||
Path((token, key, id)): Path<(String, String, String)>,
|
||||
) -> AppResult<Response> {
|
||||
let allowed = sqlx::query(
|
||||
"SELECT 1 AS ok
|
||||
let stored: Option<String> = sqlx::query(
|
||||
"SELECT e.share_key_hash
|
||||
FROM entry_media em
|
||||
JOIN entries e ON e.id = em.entry_id
|
||||
WHERE em.media_id = ?1 AND e.share_token = ?2",
|
||||
@@ -114,10 +151,43 @@ pub async fn serve_media(
|
||||
.bind(&token)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.is_some();
|
||||
.and_then(|row| row.get("share_key_hash"));
|
||||
|
||||
if !allowed {
|
||||
if !key_matches(stored.as_deref(), &key) {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
media::stream_media(&state, &id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{hash_key, key_matches, random_secret};
|
||||
|
||||
#[test]
|
||||
fn a_key_matches_only_its_own_hash() {
|
||||
let key = random_secret();
|
||||
assert!(key_matches(Some(&hash_key(&key)), &key));
|
||||
assert!(!key_matches(Some(&hash_key(&key)), &random_secret()));
|
||||
assert!(!key_matches(Some(&hash_key(&key)), ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stored_hash_is_not_the_key() {
|
||||
let key = random_secret();
|
||||
let stored = hash_key(&key);
|
||||
assert_ne!(stored, key);
|
||||
// The hash itself must not open the link either.
|
||||
assert!(!key_matches(Some(&stored), &stored));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_share_without_a_hash_stays_shut() {
|
||||
assert!(!key_matches(None, &random_secret()));
|
||||
assert!(!key_matches(None, ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_do_not_repeat() {
|
||||
assert_ne!(random_secret(), random_secret());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user