From 76f0887ac5dcbd9419b5ecf325248cd7621a5988 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 9 Sep 2026 14:37:16 +0200 Subject: [PATCH] feature(SHARE) added secret token to share to prevent getting crawled --- README.md | 34 ++++++--- migrations/0005_share_key.sql | 10 +++ src/routes/mod.rs | 4 +- src/routes/share.rs | 130 ++++++++++++++++++++++++++-------- web/src/App.svelte | 7 +- web/src/lib/EntryPane.svelte | 13 ++-- web/src/lib/Help.svelte | 2 +- web/src/lib/Shared.svelte | 10 ++- web/src/lib/api.ts | 7 +- web/src/lib/commands.ts | 16 +++-- web/src/lib/markdown.ts | 16 +++-- web/src/lib/store.svelte.ts | 17 ++++- 12 files changed, 199 insertions(+), 67 deletions(-) create mode 100644 migrations/0005_share_key.sql diff --git a/README.md b/README.md index dd9310e..e96bb18 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ The desktop UI is modal. In the browse pane: | `i`, `a` | edit the open entry | | `Esc`, `h`, `q` | back out to the list | | `/` | full-text search (`n` clears it) | -| `s` / `y` | toggle sharing / copy the share link | +| `s` / `y` | toggle sharing / mint a new share link | | `x`, `dd` | delete the entry | | `:` | command line | | `Ctrl-S` | save from anywhere | @@ -143,11 +143,25 @@ backup at all. ## Sharing -`:share` mints a 192-bit random token and copies `https://your-host/s/`. -That page needs no session, renders the entry read-only, and serves only the -media files that entry currently embeds — dropping a picture out of a shared -entry immediately makes it unreachable through the link. `:unshare` destroys the -token, and a new `:share` mints a different one. +`:share` mints two 192-bit random secrets and copies +`https://your-host/s/#`. That page needs no session, renders the +entry read-only, and serves only the media files that entry currently embeds — +dropping a picture out of a shared entry immediately makes it unreachable +through the link. + +The two halves do different jobs. The token names the entry; the key proves the +reader was given the whole link. A browser never sends a fragment to the server, +so the key stays on the reader's machine and out of every access log, referrer +and link preview along the way — a crawler or a chat client that harvested only +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 +exactly like an unknown token, so guessing never confirms that a token is live. + +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` +mints a fresh key for the same token — which retires the previous link, making +it the way to cut off a reader without unpublishing — and `:unshare` destroys +both halves. ## API @@ -161,11 +175,11 @@ Everything except the two share routes requires the session cookie. | `GET`/`PUT`/`DELETE` | `/api/entries/{id}` | read / update / delete | | `GET` | `/api/export` | every entry and file, as a zip | | `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now | -| `POST`/`DELETE` | `/api/entries/{id}/share` | mint / revoke a share token | +| `POST`/`DELETE` | `/api/entries/{id}/share` | mint or rotate / revoke a share link | | `GET`/`POST` | `/api/media` | list / upload (multipart) | | `GET`/`DELETE` | `/api/media/{id}` | serve / delete a file | -| `GET` | `/api/share/{token}` | public: read a shared entry | -| `GET` | `/api/share/{token}/media/{id}` | public: media inside a shared entry | +| `GET` | `/api/share/{token}/{key}` | public: read a shared entry | +| `GET` | `/api/share/{token}/{key}/media/{id}` | public: media inside a shared entry | ## Deploying @@ -177,6 +191,8 @@ The app sends its own `Content-Security-Policy`, `Referrer-Policy: no-referrer`, `X-Content-Type-Options` and `X-Frame-Options`; a proxy that adds its own should not weaken them. `no-referrer` matters in particular, because a share token lives in the URL and would otherwise leak to any site a shared entry links to. +Share links minted before the key existed are revoked by migration `0005`; +re-run `:share` on those entries to publish them again. Uploaded files are only ever served as types that cannot execute — anything else is handed back as an opaque download. diff --git a/migrations/0005_share_key.sql b/migrations/0005_share_key.sql new file mode 100644 index 0000000..b6d55a7 --- /dev/null +++ b/migrations/0005_share_key.sql @@ -0,0 +1,10 @@ +-- Shared links gain a secret that rides in the URL fragment: /s/#. +-- Browsers never send a fragment to the origin, so a crawler that harvests the +-- link's path alone still cannot read the entry. The server keeps only the +-- key's hash; the plaintext exists once, in the link the author copied. +-- +-- Links minted before this migration have no key, and there is no way to give +-- them one without leaving a permanent keyless bypass, so they are revoked. +ALTER TABLE entries ADD COLUMN share_key_hash TEXT; + +UPDATE entries SET share_token = NULL WHERE share_token IS NOT NULL; diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 22df6ba..a954fc7 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -27,6 +27,6 @@ pub fn api_router() -> Router { .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)) } diff --git a/src/routes/share.rs b/src/routes/share.rs index 83fbb99..b7f005f 100644 --- a/src/routes/share.rs +++ b/src/routes/share.rs @@ -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, Path(id): Path, ) -> AppResult> { - let existing: Option> = 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> = + 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, Path(id): Path, ) -> AppResult> { - 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, - Path(token): Path, + Path((token, key)): Path<(String, String)>, ) -> AppResult> { 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 = 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, - Path((token, id)): Path<(String, String)>, + Path((token, key, id)): Path<(String, String, String)>, ) -> AppResult { - let allowed = sqlx::query( - "SELECT 1 AS ok + let stored: Option = 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()); + } +} diff --git a/web/src/App.svelte b/web/src/App.svelte index 45d5c02..04a9b1b 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -11,9 +11,14 @@ import StatusBar from './lib/StatusBar.svelte' import { diary } from './lib/store.svelte' + /* + * A share link is /s/#. The fragment never leaves the browser, so + * a crawler that only scraped the path cannot ask the server for the entry. + */ const shareToken = location.pathname.startsWith('/s/') ? decodeURIComponent(location.pathname.slice(3)) : null + const shareKey = shareToken ? decodeURIComponent(location.hash.slice(1)) : '' let cmdline = $state(null) let fileInput = $state() @@ -196,7 +201,7 @@ {#if shareToken} - + {:else if diary.booting}
booting ~/diary…
{:else if !diary.user} diff --git a/web/src/lib/EntryPane.svelte b/web/src/lib/EntryPane.svelte index 91802d2..215063c 100644 --- a/web/src/lib/EntryPane.svelte +++ b/web/src/lib/EntryPane.svelte @@ -9,7 +9,12 @@ const words = $derived( (diary.editing ? diary.draft.body : entry.body).split(/\s+/).filter(Boolean).length, ) - const shareUrl = $derived(entry.share_token ? `${location.origin}/s/${entry.share_token}` : null) + /* + * Only the token half of a share link is recoverable — the key lives in the + * fragment, and the server kept nothing but its hash — so the bar shows the + * link's shape and offers a fresh one rather than a copy of the old. + */ + const sharePath = $derived(entry.share_token ? `${location.origin}/s/${entry.share_token}#…` : null)
@@ -43,11 +48,11 @@ - {#if shareUrl} + {#if sharePath}
◉ public - {shareUrl} - + {sharePath} +
{/if} diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index a2acaed..7dd3159 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -12,7 +12,7 @@ ['/', 'full-text search'], ['n', 'clear the search'], ['s', 'toggle sharing for the entry'], - ['y', 'copy the share link'], + ['y', 'mint a new share link and copy it'], ['x · dd', 'delete the entry (asks first)'], [':', 'command line'], ['Ctrl-S', 'save, from anywhere'], diff --git a/web/src/lib/Shared.svelte b/web/src/lib/Shared.svelte index d50cf09..c3a4f9c 100644 --- a/web/src/lib/Shared.svelte +++ b/web/src/lib/Shared.svelte @@ -3,14 +3,18 @@ import { renderMarkdown } from './markdown' import { formatStamp, formatWeekday } from './util' - const { token }: { token: string } = $props() + const { token, shareKey }: { token: string; shareKey: string } = $props() let entry = $state(null) let error = $state('') $effect(() => { + if (!shareKey) { + error = 'this link is incomplete — the part after # is missing from it.' + return + } api - .readShared(token) + .readShared(token, shareKey) .then((data) => { entry = data document.title = data.title.trim() || `~/diary ${formatStamp(data.created_at)}` @@ -30,7 +34,7 @@ {#if entry.title.trim()}

{entry.title}

{/if} - {@html renderMarkdown(entry.body, token)} + {@html renderMarkdown(entry.body, { token, key: shareKey })}
— written in ~/diary
{:else} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a4c3f4e..2ee4d1e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -105,7 +105,9 @@ export const api = { deleteEntry: (id: number) => request<{ ok: true }>(`/entries/${id}`, { method: 'DELETE' }), - share: (id: number) => request<{ token: string; path: string }>(`/entries/${id}/share`, { method: 'POST' }), + /** Publishes the entry, or mints a fresh key for one already published. */ + share: (id: number) => + request<{ token: string; key: string; path: string }>(`/entries/${id}/share`, { method: 'POST' }), unshare: (id: number) => request<{ ok: true }>(`/entries/${id}/share`, { method: 'DELETE' }), @@ -123,5 +125,6 @@ export const api = { backupNow: () => request('/backup', { method: 'POST' }), - readShared: (token: string) => request(`/share/${encodeURIComponent(token)}`), + readShared: (token: string, key: string) => + request(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`), } diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index e2af25d..7022869 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -279,7 +279,7 @@ export const commands: CommandSpec[] = [ const id = diary.open?.id ?? diary.selected?.id if (!id) return diary.say('no entry selected', 'error') const entry = diary.open?.id === id ? diary.open : diary.selected - if (entry?.shared) return diary.say('already shared — :link to copy, :unshare to revoke') + if (entry?.shared) return diary.say('already shared — :link for a new link, :unshare to revoke') await diary.guard(() => diary.toggleShare(id)) }, }, @@ -298,12 +298,16 @@ export const commands: CommandSpec[] = [ { name: 'link', group: 'sharing', - help: 'copy the share link of the current entry', + help: 'mint a new share link for the entry and copy it', run: async () => { - const token = diary.open?.share_token ?? diary.selected?.share_token - if (!token) return diary.say('entry is not shared — :share first', 'error') - const url = `${location.origin}/s/${token}` - diary.say((await diary.copy(url)) ? `copied ${url}` : url) + const id = diary.open?.id ?? diary.selected?.id + if (!id) return diary.say('no entry selected', 'error') + const entry = diary.open?.id === id ? diary.open : diary.selected + if (!entry?.shared) return diary.say('entry is not shared — :share first', 'error') + await diary.guard(async () => { + const url = await diary.mintShareLink(id) + diary.say(`new link → ${url} (copied) — the previous one is dead`) + }) }, }, { diff --git a/web/src/lib/markdown.ts b/web/src/lib/markdown.ts index 2cee56c..2fd0490 100644 --- a/web/src/lib/markdown.ts +++ b/web/src/lib/markdown.ts @@ -4,13 +4,17 @@ import { marked } from 'marked' marked.setOptions({ gfm: true, breaks: true }) /** - * Render entry markdown to sanitised HTML. When a share token is given, media - * URLs are rewritten to the public route so the page works for a reader who has - * no session. + * Render entry markdown to sanitised HTML. When share credentials are given, + * media URLs are rewritten to the public route — token and key both, since that + * route demands the same pair the entry itself did — so the page works for a + * reader who has no session. */ -export function renderMarkdown(source: string, shareToken?: string): string { - const md = shareToken - ? source.replaceAll('/api/media/', `/api/share/${encodeURIComponent(shareToken)}/media/`) +export function renderMarkdown(source: string, share?: { token: string; key: string }): string { + const md = share + ? source.replaceAll( + '/api/media/', + `/api/share/${encodeURIComponent(share.token)}/${encodeURIComponent(share.key)}/media/`, + ) : source const html = marked.parse(md, { async: false }) as string diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 2b0c028..68468b3 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -257,15 +257,26 @@ class Diary { await api.unshare(id) this.say(`entry #${id} is private again`) } else { - const { token } = await api.share(id) - const url = `${location.origin}/s/${token}` - await this.copy(url) + const url = await this.mintShareLink(id) this.say(`shared → ${url} (copied)`) } if (this.open?.id === id) this.open = await api.getEntry(id) await this.refresh() } + /* + * The server keeps only a hash of the key that lives in the link's fragment, + * so a link exists in full exactly once: here, in the moment it is minted. + * There is nothing to re-copy later — asking again mints a new link and + * retires the old one. + */ + async mintShareLink(id: number) { + const { path } = await api.share(id) + const url = `${location.origin}${path}` + await this.copy(url) + return url + } + async copy(text: string) { try { await navigator.clipboard.writeText(text)