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
+257 -51

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@
let article = $state<HTMLElement>()
let position = $state('All')
// 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 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.
+1 -1
View File
@@ -468,7 +468,7 @@ export const api = {
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
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'),
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,
* 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, and a link to another document, an address inside
* the private workspace that would lead the reader to its login page, is kept
* as its text.
* Render entry markdown to sanitised HTML. On a share page, media URLs are
* rewritten to the public route under the share's token. That route asks for
* the cookie reading the document with its key handed out, so the key itself
* never sits in a URL a proxy could log. A link to another document, an
* address inside the private workspace that would lead the reader to its
* 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
? source.replaceAll(
'/api/media/',
`/api/share/${encodeURIComponent(share.token)}/${encodeURIComponent(share.key)}/media/`,
)
? source.replaceAll('/api/media/', `/api/share/${encodeURIComponent(share.token)}/media/`)
: source
const html = marked.parse(md, { async: false }) as string