share: the key never travels in a URL

The share page used to put the key in the API path, and every embedded
picture or video carried it in its src, so a reverse proxy's access log
held whole links. The page now sends the key in the body of
POST /api/share/{token}. The answer sets a diary_share cookie
(__Secure- over HTTPS) scoped to /api/share/{token}/, HttpOnly,
SameSite=Strict, good for twelve hours, and signed over the key's hash.
Media is served from /api/share/{token}/media/{id} only to a browser
holding that cookie, so minting a new key or unsharing voids it.

The old /api/share/{token}/{key} routes are gone; links themselves are
unchanged, since the key was always in their fragment.
This commit is contained in:
nvrl committed 2026-10-20 12:33:59 +02:00
1 parent fe1b12042d
commit 947cc2ba96
8 files changed
+252 -46

No files matched your search

+9 -2
View File
@@ -546,6 +546,13 @@ the path holds a URL that answers `404`. The server stores nothing but a
SHA-256 of the key and compares in constant time, and a wrong key is answered SHA-256 of the key and compares in constant time, and a wrong key is answered
exactly like an unknown token, so guessing never confirms that a token is live. exactly like an unknown token, so guessing never confirms that a token is live.
The page sends the key on to the server once, in the body of the request that
reads the entry, never in a URL. That answer gives the browser a cookie scoped
to this one link's path, good for 12 hours, and the entry's pictures and
videos are fetched with it — so not even this server's reverse proxy logs the
key. A fresh key from `:link`, or `:unshare`, voids the cookies handed out
under the old one.
Because only the hash is kept, a link exists in full exactly once: in the moment Because only the hash is kept, a link exists in full exactly once: in the moment
`:share` copies it. It cannot be re-read from the entry afterwards. `:link` `:share` copies it. It cannot be re-read from the entry afterwards. `:link`
mints a fresh key for the same token — which retires the previous link, making mints a fresh key for the same token — which retires the previous link, making
@@ -593,8 +600,8 @@ sealed or not.
| `POST` | `/api/vault/unlock`, `/api/vault/lock` | open / close the vault | | `POST` | `/api/vault/unlock`, `/api/vault/lock` | open / close the vault |
| `GET`/`POST` | `/api/ssh-keys` | the keys that may mount over SFTP / add one | | `GET`/`POST` | `/api/ssh-keys` | the keys that may mount over SFTP / add one |
| `DELETE` | `/api/ssh-keys/{id}` | remove a key, which ends its mounts | | `DELETE` | `/api/ssh-keys/{id}` | remove a key, which ends its mounts |
| `GET` | `/api/share/{token}/{key}` | public: read a shared document | | `POST` | `/api/share/{token}` | public: read a shared document, body `{"key"}`; sets the share's media cookie |
| `GET` | `/api/share/{token}/{key}/media/{id}` | public: media inside a shared document | | `GET` | `/api/share/{token}/media/{id}` | public: media inside a shared document, with that cookie |
## Deploying ## Deploying
+1 -1
View File
@@ -21,7 +21,7 @@ pub const SECURE_COOKIE_NAME: &str = "__Host-diary_session";
type HmacSha256 = Hmac<Sha256>; type HmacSha256 = Hmac<Sha256>;
fn sign(secret: &[u8], payload: &str) -> String { pub(crate) fn sign(secret: &[u8], payload: &str) -> String {
let mut mac = HmacSha256::new_from_slice(secret).expect("hmac accepts any key length"); let mut mac = HmacSha256::new_from_slice(secret).expect("hmac accepts any key length");
mac.update(payload.as_bytes()); mac.update(payload.as_bytes());
B64.encode(mac.finalize().into_bytes()) B64.encode(mac.finalize().into_bytes())
+5 -5
View File
@@ -338,11 +338,11 @@ async fn deadline(
/// Below the ten seconds `docker stop` waits before it sends SIGKILL. /// Below the ten seconds `docker stop` waits before it sends SIGKILL.
const SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(8); const SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(8);
/// A request path as it may go into the log: a share link's token and key are /// A request path as it may go into the log: a share link's token is half of
/// the whole of its secret, and the key is kept out of every log on purpose — /// its secret. The other half, the key, never travels in a path — it rides in
/// it travels in the URL fragment so that no server ever sees it, except this /// the URL fragment, which no server sees, and then in the body of the read
/// one's API, which must not write it down either. Query strings, which carry /// that trades it for a cookie. Query strings, which carry search terms, are
/// search terms, are never logged at all. /// never logged at all.
fn loggable(path: &str) -> &str { fn loggable(path: &str) -> &str {
if path.starts_with("/api/share/") { if path.starts_with("/api/share/") {
"/api/share/…" "/api/share/…"
+5 -8
View File
@@ -88,8 +88,8 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
post(media::upload).layer(uploads).get(media::list), post(media::upload).layer(uploads).get(media::list),
) )
.route("/media/{id}", get(media::serve).delete(media::remove)) .route("/media/{id}", get(media::serve).delete(media::remove))
.route("/share/{token}/{key}", get(share::read)) .route("/share/{token}", post(share::read))
.route("/share/{token}/{key}/media/{id}", get(share::serve_media)) .route("/share/{token}/media/{id}", get(share::serve_media))
.route("/ssh-keys", get(ssh_keys::list).post(ssh_keys::add)) .route("/ssh-keys", get(ssh_keys::list).post(ssh_keys::add))
.route( .route(
"/ssh-keys/{id}", "/ssh-keys/{id}",
@@ -114,8 +114,8 @@ pub(crate) mod tests {
"/health", "/health",
"/login", "/login",
"/logout", "/logout",
"/share/{token}/{key}", "/share/{token}",
"/share/{token}/{key}/media/{id}", "/share/{token}/media/{id}",
]; ];
async fn state() -> AppState { async fn state() -> AppState {
@@ -193,10 +193,7 @@ pub(crate) mod tests {
let app = super::api_router(1024 * 1024).with_state(state().await); let app = super::api_router(1024 * 1024).with_state(state().await);
for (method, path) in routes { for (method, path) in routes {
let uri = path let uri = path.replace("{id}", "1").replace("{token}", "t");
.replace("{id}", "1")
.replace("{token}", "t")
.replace("{key}", "k");
let response = app let response = app
.clone() .clone()
.oneshot( .oneshot(
+222 -17
View File
@@ -1,18 +1,20 @@
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
response::Response, http::{header, HeaderMap},
response::{AppendHeaders, IntoResponse, Response},
Json, Json,
}; };
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine};
use rand::RngCore; use rand::RngCore;
use serde::Serialize; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use sqlx::Row; use sqlx::Row;
use subtle::ConstantTimeEq; use subtle::ConstantTimeEq;
use crate::{ use crate::{
auth::Session, auth::{self, Session},
config::Config,
error::{AppError, AppResult}, error::{AppError, AppResult},
routes::media, routes::media,
state::AppState, state::AppState,
@@ -119,12 +121,71 @@ pub async fn disable(
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
} }
/// Public: no session required. The credential is the pair — the token from the /// The cookie a share page gets for its media. `<img>` and `<video>` cannot
/// link's path and the key the reader's browser lifted out of its fragment. /// send the key in a header, and a key in their URLs would be written into
/// every proxy's access log; so reading the document hands out this, scoped
/// to the share's own path, and media asks for it instead.
const SHARE_COOKIE: &str = "diary_share";
const SECURE_SHARE_COOKIE: &str = "__Secure-diary_share";
const SHARE_COOKIE_HOURS: i64 = 12;
fn share_cookie_name(config: &Config) -> &'static str {
if config.secure_cookie {
SECURE_SHARE_COOKIE
} else {
SHARE_COOKIE
}
}
/// Signed over the key's hash, so minting a new key or unsharing the
/// document voids every cookie handed out under the old one.
fn share_signature(config: &Config, token: &str, key_hash: &str, expires: i64) -> String {
auth::sign(
&config.secret,
&format!("share:{token}:{key_hash}:{expires}"),
)
}
fn share_cookie(config: &Config, token: &str, key_hash: &str) -> String {
let max_age = SHARE_COOKIE_HOURS * 3600;
let expires = crate::now() + max_age;
let signature = share_signature(config, token, key_hash, expires);
let name = share_cookie_name(config);
let secure = if config.secure_cookie { "; Secure" } else { "" };
format!(
"{name}={expires}.{signature}; Path=/api/share/{token}/; HttpOnly; SameSite=Strict; \
Max-Age={max_age}{secure}"
)
}
/// Whether the request carries a share cookie for this token and key.
fn holds_share_cookie(config: &Config, headers: &HeaderMap, token: &str, key_hash: &str) -> bool {
let Some((expires, signature)) =
auth::cookie_value(headers, share_cookie_name(config)).and_then(|v| v.split_once('.'))
else {
return false;
};
let Ok(expires) = expires.parse::<i64>() else {
return false;
};
let expected = share_signature(config, token, key_hash, expires);
expires > crate::now() && bool::from(expected.as_bytes().ct_eq(signature.as_bytes()))
}
#[derive(Deserialize)]
pub struct ReadRequest {
pub key: String,
}
/// `POST /api/share/{token}` — 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 and sends in the body, where no log
/// on the way sees it.
pub async fn read( pub async fn read(
State(state): State<AppState>, State(state): State<AppState>,
Path((token, key)): Path<(String, String)>, Path(token): Path<String>,
) -> AppResult<Json<SharedDocument>> { Json(body): Json<ReadRequest>,
) -> AppResult<impl IntoResponse> {
let row = sqlx::query( let row = sqlx::query(
"SELECT name, body, created_at, updated_at, share_key_hash "SELECT name, body, created_at, updated_at, share_key_hash
FROM nodes WHERE share_token = ?1", FROM nodes WHERE share_token = ?1",
@@ -135,27 +196,32 @@ pub async fn read(
.ok_or(AppError::NotFound)?; .ok_or(AppError::NotFound)?;
let stored: Option<String> = row.get("share_key_hash"); let stored: Option<String> = row.get("share_key_hash");
if !key_matches(stored.as_deref(), &key) { let Some(stored) = stored.filter(|stored| key_matches(Some(stored), &body.key)) else {
// Deliberately the same answer as an unknown token: a wrong key must not // Deliberately the same answer as an unknown token: a wrong key must not
// confirm that the token itself is live. // confirm that the token itself is live.
return Err(AppError::NotFound); return Err(AppError::NotFound);
} };
Ok(Json(SharedDocument { let cookie = share_cookie(&state.config, &token, &stored);
Ok((
AppendHeaders([(header::SET_COOKIE, cookie)]),
Json(SharedDocument {
name: row.get("name"), name: row.get("name"),
body: row.get("body"), body: row.get("body"),
created_at: row.get("created_at"), created_at: row.get("created_at"),
updated_at: row.get("updated_at"), updated_at: row.get("updated_at"),
token, token,
})) }),
))
} }
/// Public: serves a media file only while it is embedded in a shared document, /// `GET /api/share/{token}/media/{id}` — public: serves a media file only
/// and only to a caller holding that document's key. /// while it is embedded in a shared document, and only to a browser that
/// read that document with its key and holds the cookie it got for it.
pub async fn serve_media( pub async fn serve_media(
State(state): State<AppState>, State(state): State<AppState>,
Path((token, key, id)): Path<(String, String, String)>, Path((token, id)): Path<(String, String)>,
headers: axum::http::HeaderMap, headers: HeaderMap,
) -> AppResult<Response> { ) -> AppResult<Response> {
let stored: Option<String> = sqlx::query( let stored: Option<String> = sqlx::query(
"SELECT n.share_key_hash "SELECT n.share_key_hash
@@ -169,15 +235,154 @@ pub async fn serve_media(
.await? .await?
.and_then(|row| row.get("share_key_hash")); .and_then(|row| row.get("share_key_hash"));
if !key_matches(stored.as_deref(), &key) { let allowed =
stored.is_some_and(|stored| holds_share_cookie(&state.config, &headers, &token, &stored));
if !allowed {
return Err(AppError::NotFound); return Err(AppError::NotFound);
} }
media::stream_media(&state, &id, false, headers.get(axum::http::header::RANGE)).await media::stream_media(&state, &id, false, headers.get(header::RANGE)).await
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use axum::{
body::Body,
http::{header, Request, StatusCode},
Router,
};
use tower::ServiceExt;
use super::{hash_key, key_matches, random_secret}; use super::{hash_key, key_matches, random_secret};
use crate::state::AppState;
struct Shared {
app: Router,
state: AppState,
_dir: tempfile::TempDir,
token: String,
key: String,
media: String,
}
/// A shared document embedding one file.
async fn shared() -> Shared {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("uploads")).unwrap();
let state = crate::routes::tests::state_in(dir.path().into()).await;
let (token, key) = (random_secret(), random_secret());
let doc: i64 = sqlx::query_scalar(
"INSERT INTO nodes (kind, name, slug, body, created_at, updated_at, share_token, share_key_hash)
VALUES ('document', 'shared', 'shared', 'see', 1, 1, ?1, ?2) RETURNING id",
)
.bind(&token)
.bind(hash_key(&key))
.fetch_one(&state.db)
.await
.unwrap();
let media = uuid::Uuid::new_v4().to_string();
std::fs::write(state.config.uploads_dir().join(&media), b"picture").unwrap();
sqlx::query("INSERT INTO media (id, filename, mime, size, created_at) VALUES (?1, 'a.png', 'image/png', 7, 1)")
.bind(&media)
.execute(&state.db)
.await
.unwrap();
sqlx::query("INSERT INTO node_media (node_id, media_id) VALUES (?1, ?2)")
.bind(doc)
.bind(&media)
.execute(&state.db)
.await
.unwrap();
let app = crate::routes::api_router(1024).with_state(state.clone());
Shared {
app,
state,
_dir: dir,
token,
key,
media,
}
}
impl Shared {
async fn read(&self, key: &str) -> (StatusCode, Option<String>) {
let request = Request::post(format!("/share/{}", self.token))
.header("content-type", "application/json")
.body(Body::from(serde_json::json!({ "key": key }).to_string()))
.unwrap();
let response = self.app.clone().oneshot(request).await.unwrap();
let cookie = response
.headers()
.get(header::SET_COOKIE)
.map(|v| v.to_str().unwrap().to_string());
(response.status(), cookie)
}
async fn media(&self, cookie: Option<&str>) -> StatusCode {
let mut request = Request::get(format!("/share/{}/media/{}", self.token, self.media));
if let Some(cookie) = cookie {
request = request.header(header::COOKIE, cookie);
}
let response = self
.app
.clone()
.oneshot(request.body(Body::empty()).unwrap())
.await;
response.unwrap().status()
}
}
#[tokio::test]
async fn media_needs_the_cookie_reading_with_the_key_gave() {
let s = shared().await;
assert_eq!(
s.read(&random_secret()).await,
(StatusCode::NOT_FOUND, None)
);
let (status, set) = s.read(&s.key).await;
assert_eq!(status, StatusCode::OK);
let set = set.unwrap();
assert!(
set.contains(&format!("Path=/api/share/{}/", s.token)),
"{set}"
);
assert!(
set.contains("HttpOnly") && set.contains("SameSite=Strict"),
"{set}"
);
let cookie = set.split(';').next().unwrap().to_string();
assert_eq!(s.media(Some(&cookie)).await, StatusCode::OK);
assert_eq!(s.media(None).await, StatusCode::NOT_FOUND);
let (expires, _) = cookie.split_once('.').unwrap();
let forged = format!("{expires}.{}", random_secret());
assert_eq!(s.media(Some(&forged)).await, StatusCode::NOT_FOUND);
// A new key, minted with `y`, voids the cookie the old one gave.
sqlx::query("UPDATE nodes SET share_key_hash = ?1")
.bind(hash_key(&random_secret()))
.execute(&s.state.db)
.await
.unwrap();
assert_eq!(s.media(Some(&cookie)).await, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn the_key_is_not_taken_from_the_path() {
let s = shared().await;
let request = Request::get(format!("/share/{}/{}", s.token, s.key))
.body(Body::empty())
.unwrap();
let response = s.app.clone().oneshot(request).await.unwrap();
assert!(response.status().is_client_error(), "{}", response.status());
let old_media = format!("/share/{}/{}/media/{}", s.token, s.key, s.media);
let response = s
.app
.clone()
.oneshot(Request::get(old_media).body(Body::empty()).unwrap())
.await;
assert!(response.unwrap().status().is_client_error());
}
#[test] #[test]
fn a_key_matches_only_its_own_hash() { fn a_key_matches_only_its_own_hash() {
+1 -1
View File
@@ -19,7 +19,7 @@
let article = $state<HTMLElement>() let article = $state<HTMLElement>()
let position = $state('All') let position = $state('All')
// Links into the private workspace come back as their text; see renderMarkdown. // Links into the private workspace come back as their text; see renderMarkdown.
const html = $derived(doc ? renderMarkdown(doc.body, { token, key: shareKey }) : '') const html = $derived(doc ? renderMarkdown(doc.body, { token }) : '')
const title = $derived(doc?.name.trim() ?? '') const title = $derived(doc?.name.trim() ?? '')
const words = $derived(doc ? doc.body.split(/\s+/).filter(Boolean).length : 0) const words = $derived(doc ? doc.body.split(/\s+/).filter(Boolean).length : 0)
// The outline gets a window of its own only when there is something to outline. // The outline gets a window of its own only when there is something to outline.
+1 -1
View File
@@ -468,7 +468,7 @@ export const api = {
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }), backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
readShared: (token: string, key: string) => readShared: (token: string, key: string) =>
request<SharedDocument>(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`), request<SharedDocument>(`/share/${encodeURIComponent(token)}`, { method: 'POST', body: body({ key }) }),
sessions: () => request<SignedIn[]>('/sessions'), sessions: () => request<SignedIn[]>('/sessions'),
revokeSession: (id: string) => revokeSession: (id: string) =>
+8 -11
View File
@@ -79,19 +79,16 @@ function escapeHtml(text: string): string {
} }
/** /**
* Render entry markdown to sanitised HTML. When share credentials are given, * Render entry markdown to sanitised HTML. On a share page, media URLs are
* media URLs are rewritten to the public route — token and key both, since that * rewritten to the public route under the share's token. That route asks for
* route demands the same pair the entry itself did — so the page works for a * the cookie reading the document with its key handed out, so the key itself
* reader who has no session, and a link to another document, an address inside * never sits in a URL a proxy could log. A link to another document, an
* the private workspace that would lead the reader to its login page, is kept * address inside the private workspace that would lead the reader to its
* as its text. * login page, is kept as its text.
*/ */
export function renderMarkdown(source: string, share?: { token: string; key: string }): string { export function renderMarkdown(source: string, share?: { token: string }): string {
const md = share const md = share
? source.replaceAll( ? source.replaceAll('/api/media/', `/api/share/${encodeURIComponent(share.token)}/media/`)
'/api/media/',
`/api/share/${encodeURIComponent(share.token)}/${encodeURIComponent(share.key)}/media/`,
)
: source : source
const html = marked.parse(md, { async: false }) as string const html = marked.parse(md, { async: false }) as string