From f6956af8c7bc865112a5d5f3e14ef26c843c32d7 Mon Sep 17 00:00:00 2001 From: jwaldrip Date: Tue, 11 Aug 2026 14:00:37 -0600 Subject: [PATCH] Make the sandbox frame actually run, and survive a reload Two things, both about HTML relics rendering blank. The sandbox frame is given no allow-same-origin, which puts its document in an opaque origin. Every request that document makes is therefore cross-origin with Origin: null, and sandbox.html linked its handler as a type="module" script. Module scripts are fetched with CORS semantics, the asset carried no Access-Control-Allow-Origin, so the browser refused it. The script never ran, the frame never announced itself, and the parent posted markup to a listener that did not exist. It failed with an empty console, because the failure was in the frame's origin rather than the page's. Verified against the deployed sandbox: the frame never sends relic:sandbox-ready. The bundle is now inlined into the page at build time. Relaxing CORS on the asset would have fixed the symptom by making the sandbox origin serve something cross-origin, which is the property the sandbox exists to remove. An inline script fetches nothing, so there is no request to block and no header anybody can regress. The build refuses to produce a linked version, and the deploy asserts the served page carries its handler and links no script. Second, a reload lost the key. Reading the fragment strips it from the address bar, which is worth keeping, but it meant a refresh landed on a dead page telling the reader to go find the original link. The key is now remembered in local storage on the service origin, keyed by relic, until the relic expires, and dropped as soon as the relic is gone or the key stops working. Storage that is absent or refuses to write degrades to the old behaviour rather than costing somebody the relic they are looking at. That changes who can open a relic: anyone using that browser profile can reopen it without ever being sent the link. The disclosure says so rather than leaving it to be discovered. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KAZyikoYbH8XdXkGJrxFCv --- .github/workflows/deploy.yml | 18 +++ packages/relic-server/src/app.ts | 13 ++ packages/relic-viewer/build.ts | 43 ++++++ packages/relic-viewer/src/main.ts | 100 ++++++++++++ packages/relic-viewer/src/viewer.ts | 55 ++++++- packages/relic-viewer/test/keyvault.test.ts | 160 ++++++++++++++++++++ packages/relic-viewer/test/viewer.test.ts | 116 +++++++++++++- 7 files changed, 501 insertions(+), 4 deletions(-) create mode 100644 packages/relic-viewer/test/keyvault.test.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5b87097..851a0a0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -174,3 +174,21 @@ jobs: echo "and the two are different registrable domains, or there is no boundary" test "$(echo "$SERVICE" | sed 's#https\?://##')" != "$(echo "$SANDBOX" | sed 's#https\?://##')" + + # The frame has no allow-same-origin, so its document is an opaque + # origin and every request it makes is cross-origin with Origin: + # null. A linked module script is fetched with CORS and silently + # refused, which renders as a blank frame and an empty console. The + # script has to be inline, and this is the only place that can tell. + echo "and the sandbox page carries its script inline, fetching nothing" + curl -fsS "$SANDBOX/sandbox.html" -o /tmp/sandbox.html + + if ! grep -q 'relic:sandbox-ready' /tmp/sandbox.html; then + echo "sandbox.html does not carry its handler inline; the frame will never render" + exit 1 + fi + + if grep -qE ']+src=' /tmp/sandbox.html; then + echo "sandbox.html links a script; an opaque-origin document cannot fetch it" + exit 1 + fi diff --git a/packages/relic-server/src/app.ts b/packages/relic-server/src/app.ts index c213627..48598f1 100644 --- a/packages/relic-server/src/app.ts +++ b/packages/relic-server/src/app.ts @@ -981,6 +981,19 @@ key lives in the URL fragment, after the \`#\`. **Your browser never sends the key to Relic's servers.** That is the correct form of the claim. Saying "the key never reaches a server" unqualified would be wrong. +## What your own browser keeps + +Opening a relic strips the key out of the address bar immediately, which means +a reload would otherwise lose it. So the viewer remembers the key in your +browser's local storage for that relic, until the relic expires, and clears it +as soon as the relic is gone. + +It never leaves your machine, and it is stored under Relic's origin rather than +the sandbox that renders content. The trade is real and worth stating plainly: +for as long as the entry lives, anyone using this browser profile can reopen +that relic without ever having been sent the link. Clearing site data for this +origin removes every remembered key. + ## What we do know - **A coarse renderer class**, one of: markdown, code, html, image, media, diff --git a/packages/relic-viewer/build.ts b/packages/relic-viewer/build.ts index f357fad..4c3eea4 100644 --- a/packages/relic-viewer/build.ts +++ b/packages/relic-viewer/build.ts @@ -36,5 +36,48 @@ for (const name of await readdir('./public')) { await copyFile(`./public/${name}`, `${out}${name}`); } +/** + * Inline the sandbox bundle into its page, rather than linking it. + * + * The sandbox frame is deliberately given no `allow-same-origin`, which puts + * its document in an opaque origin. Every request that document makes is + * therefore cross-origin with `Origin: null`, and a `type="module"` script is + * fetched with CORS semantics. With no `Access-Control-Allow-Origin` on the + * response the browser refuses the module, the script never runs, the frame + * never announces itself, and the parent's markup is posted to a listener that + * does not exist. The visible result is a blank frame and an empty console, + * because the failure is in the frame's origin and not the page's. + * + * Relaxing CORS on the asset would fix the symptom by making the sandbox + * origin serve something cross-origin, which is the property the sandbox + * exists to remove. An inline script fetches nothing, so there is no request + * to be blocked and no header anybody can regress. + * + * This runs after the copy above, which would otherwise put the linked version + * back. + */ +const SCRIPT_TAG = ''; + +const shell = await Bun.file('./public/sandbox.html').text(); +if (!shell.includes(SCRIPT_TAG)) { + console.error( + 'sandbox.html no longer contains the expected script tag, so the bundle ' + + 'would ship linked instead of inlined and the frame would never render' + ); + process.exit(1); +} + +const sandboxJs = await Bun.file(`${out}sandbox.js`).text(); +await Bun.write( + `${out}sandbox.html`, + shell.replace( + SCRIPT_TAG, + // A `${sandboxJs.replace(/<\/script/gi, '<\\/script')}` + ) +); + const names = (await readdir(out)).sort(); console.log(`built ${names.length} assets: ${names.join(', ')}`); diff --git a/packages/relic-viewer/src/main.ts b/packages/relic-viewer/src/main.ts index e194d32..789179e 100644 --- a/packages/relic-viewer/src/main.ts +++ b/packages/relic-viewer/src/main.ts @@ -17,6 +17,7 @@ import { highlightCode, renderMarkdown } from './markdown.ts'; import { type DeadView, formatBytes, + type KeyVault, load, type ReadyView, type ViewerDeps, @@ -453,10 +454,109 @@ function renderDead(dead: DeadView): void { document.body.appendChild(main); } +const VAULT_PREFIX = 'relic:key:'; + +/** + * Keys remembered in this browser's storage for the service origin. + * + * Storage can be absent or refuse to write: private browsing, a quota, an + * embedded webview, or a user who has blocked site data. None of that should + * cost somebody the relic they are currently looking at, so every operation + * degrades to doing nothing. The worst case is the behaviour that existed + * before this: a reload asks for the original link. + * + * Entries carry their relic's expiry and are swept on every read, so storage + * does not accumulate keys to relics that stopped existing days ago. + */ +export function localStorageKeyVault( + storage: Storage | undefined = globalThis.localStorage, + now: () => number = Date.now +): KeyVault { + const read = (): Storage | undefined => { + try { + // Touching localStorage throws outright in some embedded contexts, + // rather than being absent, so the guard has to be a try and not a null + // check. + return storage ?? undefined; + } catch { + return undefined; + } + }; + + const sweep = (store: Storage): void => { + const stale: string[] = []; + for (let i = 0; i < store.length; i++) { + const name = store.key(i); + if (name === null || !name.startsWith(VAULT_PREFIX)) continue; + try { + const entry = JSON.parse(store.getItem(name) ?? '') as { + expiresAt?: number; + }; + if (typeof entry.expiresAt !== 'number' || entry.expiresAt <= now()) { + stale.push(name); + } + } catch { + // Unreadable entry. Not ours to interpret, and not worth keeping. + stale.push(name); + } + } + for (const name of stale) store.removeItem(name); + }; + + return { + remember(relicId, fragment, expiresAt) { + const store = read(); + if (store === undefined) return; + if (!Number.isFinite(expiresAt) || expiresAt <= now()) return; + try { + store.setItem( + `${VAULT_PREFIX}${relicId}`, + JSON.stringify({ fragment, expiresAt }) + ); + } catch { + // Quota, or storage disabled mid-session. A remembered key is a + // convenience; failing to store one is not worth an error page. + } + }, + + recall(relicId) { + const store = read(); + if (store === undefined) return undefined; + try { + sweep(store); + const raw = store.getItem(`${VAULT_PREFIX}${relicId}`); + if (raw === null) return undefined; + const entry = JSON.parse(raw) as { + fragment?: unknown; + expiresAt?: unknown; + }; + if (typeof entry.fragment !== 'string') return undefined; + if (typeof entry.expiresAt !== 'number' || entry.expiresAt <= now()) { + return undefined; + } + return entry.fragment; + } catch { + return undefined; + } + }, + + forget(relicId) { + const store = read(); + if (store === undefined) return; + try { + store.removeItem(`${VAULT_PREFIX}${relicId}`); + } catch { + // Nothing to do, and nothing worth telling the reader about. + } + }, + }; +} + export function makeBrowserDeps(): ViewerDeps { return { serviceOrigin: SERVICE_ORIGIN, fetch: globalThis.fetch.bind(globalThis), + keyVault: localStorageKeyVault(), takeFragment: () => window.location.hash, stripFragment: () => { // Replace the current entry with the fragment removed. The URL that diff --git a/packages/relic-viewer/src/viewer.ts b/packages/relic-viewer/src/viewer.ts index 8a932ca..cd642c4 100644 --- a/packages/relic-viewer/src/viewer.ts +++ b/packages/relic-viewer/src/viewer.ts @@ -78,6 +78,26 @@ export interface MintResponse { readonly mints_remaining: number; } +/** + * Somewhere to keep a key so a reload does not lose it. + * + * The fragment is stripped from the address bar the moment it is read, which + * is what makes a reload land on a page with no key. That is a real cost paid + * for a real benefit, and remembering the key on the recipient's own machine + * buys back the reload without putting the key back in the URL. + * + * It does change who can open the relic. Anyone with this browser profile can + * reopen it for as long as the entry lives, without ever having the link. + * That is a deliberate trade, it is scoped to the service origin, and it is + * stated in the disclosure rather than done quietly. + */ +export interface KeyVault { + /** Remember a key against a relic, until the relic expires. */ + remember(relicId: string, fragment: string, expiresAt: number): void; + recall(relicId: string): string | undefined; + forget(relicId: string): void; +} + export interface ViewerDeps { readonly serviceOrigin: string; readonly fetch: typeof globalThis.fetch; @@ -86,6 +106,7 @@ export interface ViewerDeps { /** Calls `history.replaceState` to drop the fragment from the address bar. */ readonly stripFragment: () => void; readonly locationHref: string; + readonly keyVault: KeyVault; } export async function load( @@ -100,12 +121,22 @@ export async function load( // existed before the replace, so browser history sync, an extension with // host permissions, and the application the link was clicked from all // already saw it. - const fragment = deps.takeFragment(); + const fromUrl = deps.takeFragment(); const shareUrl = deps.locationHref; deps.stripFragment(); + // A reload arrives with no fragment, because reading it stripped it. Fall + // back to what this browser was told last time. + const recalled = + fromUrl.length === 0 || fromUrl === '#' + ? deps.keyVault.recall(relicId) + : undefined; + const fragment = recalled ?? fromUrl; + /** Whether the key came from storage, so a bad one can be evicted. */ + const fromVault = recalled !== undefined; + if (fragment.length === 0 || fragment === '#') { - // A reload loses the key. The reloaded page is dead and says so, pointing + // No key in the URL and none remembered here. Say so plainly, pointing // back at the original link rather than showing a decrypt error. return { kind: 'dead', @@ -136,6 +167,9 @@ export async function load( 'unknown_version' ); } + // A remembered key that no longer parses is corrupt storage, not a bad + // link. Drop it so the next attempt is a clean one. + if (fromVault) deps.keyVault.forget(relicId); if (error instanceof MalformedFragmentError) { return dead( 'This link looks truncated', @@ -150,9 +184,24 @@ export async function load( } const minted = await mint(relicId, deps); - if ('dead' in minted) return { kind: 'dead', dead: minted.dead }; + if ('dead' in minted) { + // Expired, removed, or never published. Whatever this browser remembered + // is worthless now, and keeping a dead key is keeping a secret for no + // reason at all. + deps.keyVault.forget(relicId); + return { kind: 'dead', dead: minted.dead }; + } const mintResponse = minted.mint; + // The relic is real and this key reached it, so it is worth remembering + // until the relic itself expires. Done after the mint rather than before, + // so a key for a relic that does not exist is never written down. + deps.keyVault.remember( + relicId, + fragment, + Date.parse(mintResponse.relic_expires_at) + ); + // Refuse before allocating, using a bound computed from the object length // and the record size rather than anything the server declared. const upperBound = plaintextSizeUpperBound(mintResponse.object_length); diff --git a/packages/relic-viewer/test/keyvault.test.ts b/packages/relic-viewer/test/keyvault.test.ts new file mode 100644 index 0000000..2682eb5 --- /dev/null +++ b/packages/relic-viewer/test/keyvault.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test } from 'bun:test'; +import { localStorageKeyVault } from '../src/main.ts'; + +/** Enough of the Storage interface for the vault, with no browser involved. */ +function memoryStorage(): Storage { + const map = new Map(); + return { + get length() { + return map.size; + }, + key: (i: number) => [...map.keys()][i] ?? null, + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => { + map.set(k, v); + }, + removeItem: (k: string) => { + map.delete(k); + }, + clear: () => map.clear(), + } as Storage; +} + +/** Storage that refuses every write, as a full quota or blocked site data does. */ +function refusingStorage(): Storage { + const base = memoryStorage(); + return { + ...base, + get length() { + return base.length; + }, + key: (i: number) => base.key(i), + getItem: () => { + throw new Error('SecurityError'); + }, + setItem: () => { + throw new Error('QuotaExceededError'); + }, + removeItem: () => { + throw new Error('SecurityError'); + }, + } as Storage; +} + +const HOUR = 3_600_000; + +describe('localStorageKeyVault', () => { + test('a remembered key comes back, which is the whole point', () => { + const now = () => 1000; + const vault = localStorageKeyVault(memoryStorage(), now); + + vault.remember('relic1', '#r1abc', 1000 + HOUR); + expect(vault.recall('relic1')).toBe('#r1abc'); + }); + + test('an unknown relic recalls nothing', () => { + const vault = localStorageKeyVault(memoryStorage(), () => 1000); + expect(vault.recall('never-seen')).toBeUndefined(); + }); + + test('keys are scoped per relic', () => { + const vault = localStorageKeyVault(memoryStorage(), () => 1000); + vault.remember('relic1', '#r1aaa', 1000 + HOUR); + vault.remember('relic2', '#r1bbb', 1000 + HOUR); + + expect(vault.recall('relic1')).toBe('#r1aaa'); + expect(vault.recall('relic2')).toBe('#r1bbb'); + }); + + test('forget removes it', () => { + const vault = localStorageKeyVault(memoryStorage(), () => 1000); + vault.remember('relic1', '#r1abc', 1000 + HOUR); + vault.forget('relic1'); + + expect(vault.recall('relic1')).toBeUndefined(); + }); + + // A key outliving its relic is a secret kept for nothing. + test('an entry past the relic expiry is not returned', () => { + const storage = memoryStorage(); + let clock = 1000; + const vault = localStorageKeyVault(storage, () => clock); + + vault.remember('relic1', '#r1abc', 1000 + HOUR); + clock = 1000 + HOUR + 1; + + expect(vault.recall('relic1')).toBeUndefined(); + }); + + test('an already expired key is never written down at all', () => { + const storage = memoryStorage(); + const vault = localStorageKeyVault(storage, () => 5000); + + vault.remember('relic1', '#r1abc', 4000); + expect(storage.length).toBe(0); + }); + + test('a non-finite expiry is refused rather than stored forever', () => { + const storage = memoryStorage(); + const vault = localStorageKeyVault(storage, () => 1000); + + vault.remember('relic1', '#r1abc', Number.NaN); + expect(storage.length).toBe(0); + }); + + // Otherwise storage fills with keys to relics that stopped existing. + test('reading sweeps every expired entry, not just the one asked for', () => { + const storage = memoryStorage(); + let clock = 1000; + const vault = localStorageKeyVault(storage, () => clock); + + vault.remember('old1', '#r1aaa', 1000 + HOUR); + vault.remember('old2', '#r1bbb', 1000 + HOUR); + vault.remember('fresh', '#r1ccc', 1000 + 10 * HOUR); + expect(storage.length).toBe(3); + + clock = 1000 + 2 * HOUR; + vault.recall('fresh'); + + expect(storage.length).toBe(1); + expect(vault.recall('fresh')).toBe('#r1ccc'); + }); + + test('a corrupt entry is swept rather than thrown over', () => { + const storage = memoryStorage(); + storage.setItem('relic:key:relic1', 'not json'); + const vault = localStorageKeyVault(storage, () => 1000); + + expect(vault.recall('relic1')).toBeUndefined(); + expect(storage.length).toBe(0); + }); + + test('the sweep leaves other applications keys alone', () => { + const storage = memoryStorage(); + storage.setItem('unrelated', 'keep me'); + const vault = localStorageKeyVault(storage, () => 1000); + + vault.remember('relic1', '#r1abc', 1000 + HOUR); + vault.recall('relic1'); + + expect(storage.getItem('unrelated')).toBe('keep me'); + }); + + // Private browsing, a full quota, or blocked site data. None of these should + // cost somebody the relic they are looking at right now. + test('storage that throws on every call degrades to doing nothing', () => { + const vault = localStorageKeyVault(refusingStorage(), () => 1000); + + expect(() => vault.remember('relic1', '#r1abc', 1000 + HOUR)).not.toThrow(); + expect(vault.recall('relic1')).toBeUndefined(); + expect(() => vault.forget('relic1')).not.toThrow(); + }); + + test('absent storage degrades to doing nothing', () => { + const vault = localStorageKeyVault(undefined, () => 1000); + + expect(() => vault.remember('relic1', '#r1abc', 1000 + HOUR)).not.toThrow(); + expect(vault.recall('relic1')).toBeUndefined(); + expect(() => vault.forget('relic1')).not.toThrow(); + }); +}); diff --git a/packages/relic-viewer/test/viewer.test.ts b/packages/relic-viewer/test/viewer.test.ts index 6360ac8..f6b6492 100644 --- a/packages/relic-viewer/test/viewer.test.ts +++ b/packages/relic-viewer/test/viewer.test.ts @@ -34,10 +34,35 @@ function shimFetch(): typeof globalThis.fetch { }) as typeof globalThis.fetch; } -function deps(fragment: string, href = `${SERVICE}/x#${fragment}`): ViewerDeps { +/** An in-memory stand-in for the browser's storage, shared across a test. */ +export function fakeVault(seed: Record = {}) { + const entries = new Map(Object.entries(seed)); + return { + entries, + vault: { + // No expiry check. The app under test runs on its own clock, so + // comparing its expiry to real wall time would reject everything. + // Expiry belongs to the storage implementation and is tested there. + remember(relicId: string, fragment: string, _expiresAt: number) { + entries.set(relicId, fragment); + }, + recall: (relicId: string) => entries.get(relicId), + forget: (relicId: string) => { + entries.delete(relicId); + }, + }, + }; +} + +function deps( + fragment: string, + href = `${SERVICE}/x#${fragment}`, + vault = fakeVault().vault +): ViewerDeps { return { serviceOrigin: SERVICE, fetch: shimFetch(), + keyVault: vault, takeFragment: () => { fragmentReads += 1; return fragment; @@ -175,6 +200,95 @@ describe('the fragment', () => { }); }); +/** + * The reload path. + * + * Reading the fragment strips it, so a reload arrives with nothing in the URL. + * Before this the page simply died and told the reader to find the original + * link, which is a bad answer to a refresh. + */ +describe('remembering the key', () => { + test('a reload with no fragment opens from the remembered key', async () => { + const { id, fragment } = await seed( + utf8('# Still here\n'), + 'report.md', + 'text/markdown' + ); + const { vault } = fakeVault(); + + const first = await load( + id, + deps(fragment, `${SERVICE}/x#${fragment}`, vault) + ); + expect(first.kind).toBe('ready'); + + // Same browser, same relic, no fragment: a refresh. + const reloaded = await load(id, deps('', `${SERVICE}/x`, vault)); + expect(reloaded.kind).toBe('ready'); + if (reloaded.kind !== 'ready') return; + expect(new TextDecoder().decode(reloaded.view.content)).toBe( + '# Still here\n' + ); + }); + + test('nothing is remembered for a relic that does not exist', async () => { + const { entries, vault } = fakeVault(); + const { fragment } = await seed(utf8('x'), 'a.md', 'text/markdown'); + + // A well-formed key against an id that was never published. + const state = await load( + generateRelicId(), + deps(fragment, undefined, vault) + ); + + expect(state.kind).toBe('dead'); + expect(entries.size).toBe(0); + }); + + test('a dead relic evicts whatever this browser remembered', async () => { + const { id, fragment } = await seed( + utf8('# Gone soon\n'), + 'report.md', + 'text/markdown' + ); + const { entries, vault } = fakeVault(); + + await load(id, deps(fragment, `${SERVICE}/x#${fragment}`, vault)); + expect(entries.size).toBe(1); + + await app.fetch( + new Request(`${SERVICE}/api/relics/${id}`, { + method: 'DELETE', + headers: { authorization: 'Bearer operator-secret' }, + }) + ); + + const after = await load(id, deps('', `${SERVICE}/x`, vault)); + expect(after.kind).toBe('dead'); + // Keeping a key to a relic that no longer exists is keeping a secret for + // no reason at all. + expect(entries.size).toBe(0); + }); + + test('a remembered key that is corrupt is dropped, not retried forever', async () => { + const { id } = await seed(utf8('x'), 'a.md', 'text/markdown'); + const { entries, vault } = fakeVault({ [id]: '#r1notavalidkey' }); + + const state = await load(id, deps('', `${SERVICE}/x`, vault)); + + expect(state.kind).toBe('dead'); + expect(entries.size).toBe(0); + }); + + test('with nothing remembered, a reload still says what to do', async () => { + const state = await load(generateRelicId(), deps('', `${SERVICE}/x`)); + + expect(state.kind).toBe('dead'); + if (state.kind !== 'dead') return; + expect(state.dead.code).toBe('fragment_missing'); + }); +}); + describe('rendering', () => { test('opens markdown and hands back the exact bytes', async () => { const body = '# Q3\n\nRevenue was up.\n';