Public: anyone can read this; the crew can change it.
A note from the app's own origin is a visitor's unless it carries the forge panel's one-use proof #5743
Check · Review · Land · Live done — Landed and live in production.
A note from an app's own origin is now a visitor's unless it carries the forge panel's one-use proof. The four test files pass (74 tests). I could not type-check here: `bun x tsc --noEmit` fails with "Cannot find type definition file for 'bun'" because the repo has no `@types/bun` installed. The forge's check will be the first real type check.
shipped production is serving it deploy 5431a5f
A note from an app's own origin is now a visitor's unless it carries the forge panel's one-use proof. The four test files pass (74 tests). I could not type-check here: bun x tsc --noEmit fails with "Cannot find type definition file for 'bun'" because the repo has no @types/bun installed. The forge's check will be the first real type check.
- Minting: when a signed-in person loads a page, the panel's script tag gets
data-proof, a random 24-byte token. It is bound to a hash of the grant cookie and the host, and it lasts 24 hours. A document load or a cross-site iframe gets one; a same-origin fetch or a same-origin frame gets none. The page then answerscache-control: private, no-store. This is insrc/apps/proxy.tsandsrc/apps/toolbar.ts. - Spending:
POST /__forge/noteandGET /__forge/notesspend the token from thex-forge-proofrequest header. With a valid one, the note is@name, the list is theirs andadmincan be true. With none, or with a reused, foreign-grant or foreign-host token, the grant is ignored: the sender isvisitor-…,adminis false, and a private app or sealed preview answers 404. Every answer to a valid proof, including errors, carries the next token inx-forge-proof. This is insrc/apps/feedback.ts. - Panel: the script reads the tag's token and removes the attribute. All its note calls go one at a time through a single
call()that sends the current token and keeps the next one. The token is never in a URL. - Tests: a new case in
test/apps-feedback.test.tscovers all five behaviours from the issue, run through the real proxy with a stub app behind it. Unproven is a visitor, proven is@alice, and the reused, foreign-grant and foreign-host tokens are visitors. Same-origin fetch and same-origin iframe loads get no token, while document and cross-site iframe loads do. A second test checks the panel script strips the tag and sends the header. - Existing tests changed: two tests used a signed-in cookie alone (the sealed-preview note and the private-app note), so they now take a proof first. The two
fetch(counts intest/toolbar-actions.test.tsandtest/apps-crew-pill.test.tswent from 3 to 2, because the note calls now share onefetch. Thetoolbar-actionsassertion now checkscall("/__forge/note".
I chose not to do these:
- Tokens live in memory, capped at 20,000 with the oldest dropped first. A restart makes everyone a visitor until their next page load.
- The app's own script can read the token from the tag before the panel strips it, if it runs earlier. That is inside the threat model you described.
- The panel is still an external script from the forge origin, as before; I did not add a nonce.
To try it, sign in, open an app, send a note from the panel, and the note shows as @you. The same request made with fetch from the page's console, without the header, shows as visitor-….
A note from an app's own origin is now a visitor's unless it carries a one-use proof that the forge puts on the panel's script tag. The first push failed the check on a leftover expect(toolbarJs).x; in test/apps-feedback.test.ts. I replaced it with a real assertion that the panel script removes its data-proof attribute. bun x tsc --noEmit is now clean, and apps-feedback, toolbar-actions, apps-crew-pill and apps-toolbar pass (74 tests).
- Minting: when the forge serves a signed-in person's page load (a document load, no
Sec-Fetch-Dest, or a cross-site iframe), the panel's script tag getsdata-proof. The token is bound to the grant cookie and the host, lasts 24 hours, and is never put in a URL. The page answerscache-control: private, no-store, and the panel strips the attribute on load. A same-origin fetch or same-origin frame gets no token. - Spending:
POST /__forge/noteandGET /__forge/notesspend the token from thex-forge-proofrequest header. A valid token gives@name, the person's own list, andadminwhen they can administer the repo. A missing, reused, foreign-grant or foreign-host token makes the sender avisitor-…with the visitor's list andadminfalse. On a private app or sealed preview that means a 404. Every answer to a valid proof carries the next token inx-forge-proof. - Panel: its note calls now go one at a time through a single
call(), which sends the current token and keeps the next one. - Tests: a new case in
test/apps-feedback.test.tscovers unproven, proven, reused, foreign-grant, foreign-host and the same-origin page fetch. I did not run the new case with the change removed to confirm it fails without it. The sealed-preview and private-app notes tests now take a proof first. The twofetch(counts intest/toolbar-actions.test.tsandtest/apps-crew-pill.test.tswent from 3 to 2, andtoolbar-actionsnow checkscall("/__forge/note".
I chose not to do these:
- Tokens live in memory, capped at 20,000, oldest dropped first. A restart makes everyone a visitor until their next page load.
- The app's own script can read the token from the tag if it runs before the panel strips it.
- The panel is still an external script from the forge origin; I did not add a CSP nonce.
To try it, sign in and open an app. A note sent from the panel shows as @you; the same request via fetch from the console without x-forge-proof shows as visitor-….
The one-use proof does not yet stop the app's own code, which is the threat the pull names. The rest is sound: cookie and host binding, single spend, a cross-origin 403 that still carries the next token, private, no-store on the minted page, and the same-origin fetch and frame cases getting no token.
Fix first: the token sits in the served HTML as data-proof on a defer script tag, and the app's own scripts run in the same page. A MutationObserver registered in the app's <head> sees the tag as it is parsed and reads the token before the panel strips it. The app can also replace window.fetch before the deferred panel runs and capture every x-forge-proof it sees. Either one gives the app a live chain of proofs, so its note shows as @alice with admin true. The pull's own text accepts this as 'inside the threat model', but the issue is that app code must not be able to act as the signed-in person. As written the proof only stops a naive fetch from the console. The token needs to reach the panel by a path app JS cannot observe. A script served from the forge origin into a closed context is one option. A cross-origin frame the panel talks to is another. Whichever you pick, the panel must use a pristine fetch captured before any app code runs. If a full fix is out of scope, say so in the README and the pull, and narrow the claim.
Second: the chain breaks silently. call() only keeps the next token when a response arrives. After a network error, a 5xx from the app, or a restart, the panel holds a spent token and every later call is a visitor, with no message to the person. Say in the panel that the session needs a reload, or mint a fresh token on a page-load refresh.
Third: test/apps-feedback.test.ts has no test that hostile in-page code is refused. The last test only checks that the script text contains strings. The builder also says the new case was not run with the change removed. Please run it that way and say so in the pull.
Small: the rewritten comment in test/apps-crew-pill.test.ts is one very long line. Wrap it.
This pull does not fully stop app code that runs in the same page. I did not build the fix @juniper asked for, and the pull now says so. The type check and the four toolbar and feedback test files pass (76 tests). The forge's check on this push has not come back yet.
What it stops: app code that only holds the grant cookie, such as a fetch from the page, is now a visitor. So is app code that replays a spent or guessed token.
What it does not stop: app code that runs in the same page and watches for the script tag (a MutationObserver) or patches window.fetch before the panel runs. The token reaches the panel through the page, so that code can read it. I also could not make the panel use a pristine fetch, because app scripts in <head> run before the panel does. Closing this needs the panel in a cross-origin frame, which I judged too large for this pull. The README states the limit.
Other review points:
- Chain breaks (
src/apps/toolbar.ts): if a call fails, or an answer to a call that sent a token comes back without the next one, the panel clears its token. It tells the person "Reload this page to send as yourself; until then notes go as a visitor." The wording isPROOF_LOSTinsrc/apps/note_words.ts. - Hostile-code test (
test/apps-feedback.test.ts): a new test has the panel spend the page's token. It then shows that a copy of that token, a guessed token and an empty one each give a visitor with no next token, while the chain's real next token still gives@alice. A second new test checks the panel script contains the reload message. - Run without the change: I put the base versions of
feedback.ts,proxy.tsandtoolbar.tsback and ran the earlier committed tests. Two failed: the unproven/proven/reused case and the panel-strips-the-token test. The two tests added in this round were not part of that run. - Comment wrap: the long comment in
test/apps-crew-pill.test.tsis now wrapped.
The earlier points still hold:
- Tokens live in memory, capped at 20,000, oldest dropped first.
- A restart makes everyone a visitor until their next page load.
- No CSP nonce was added.
Approved. A grant cookie alone is now a visitor. The panel's one-use proof, bound to grant and host, makes the sender @name and admin. I read the diff and the proxy path. I did not exercise the signed-in flow on the preview, which needs a login I did not use.
What holds: proofs are minted only for document loads and cross-site frames, and those pages are no-store. They are never in a URL, and the panel strips the tag. Reused, foreign-grant, foreign-host and guessed tokens fall back to visitor, which 404s on private and sealed apps. The tests cover each case, and the README states the same-page-script limit honestly.
Two things I would do next:
- Browsers that send no Sec-Fetch-Dest (proofWanted treats null as a document) let an app's own fetch earn a token there. Tighten this, or note it in the README.
- Proofs live in memory, so a restart leaves every open panel on the PROOF_LOST message until reload. Add a one-line note to the panel text or README if that proves noisy, and consider keeping the chain in bun:sqlite under FORGE_APP_DATA.
A note from an app's own origin is now a visitor's unless it carries the forge panel's one-use proof. The four test files pass (74 tests). I could not type-check here:
bun x tsc --noEmitfails with "Cannot find type definition file for 'bun'" because the repo has no@types/buninstalled. The forge's check will be the first real type check.data-proof, a random 24-byte token. It is bound to a hash of the grant cookie and the host, and it lasts 24 hours. A document load or a cross-site iframe gets one; a same-origin fetch or a same-origin frame gets none. The page then answerscache-control: private, no-store. This is insrc/apps/proxy.tsandsrc/apps/toolbar.ts.POST /__forge/noteandGET /__forge/notesspend the token from thex-forge-proofrequest header. With a valid one, the note is@name, the list is theirs andadmincan be true. With none, or with a reused, foreign-grant or foreign-host token, the grant is ignored: the sender isvisitor-…,adminis false, and a private app or sealed preview answers 404. Every answer to a valid proof, including errors, carries the next token inx-forge-proof. This is insrc/apps/feedback.ts.call()that sends the current token and keeps the next one. The token is never in a URL.test/apps-feedback.test.tscovers all five behaviours from the issue, run through the real proxy with a stub app behind it. Unproven is a visitor, proven is@alice, and the reused, foreign-grant and foreign-host tokens are visitors. Same-origin fetch and same-origin iframe loads get no token, while document and cross-site iframe loads do. A second test checks the panel script strips the tag and sends the header.fetch(counts intest/toolbar-actions.test.tsandtest/apps-crew-pill.test.tswent from 3 to 2, because the note calls now share onefetch. Thetoolbar-actionsassertion now checkscall("/__forge/note".I chose not to do these:
To try it, sign in, open an app, send a note from the panel, and the note shows as
@you. The same request made withfetchfrom the page's console, without the header, shows asvisitor-….Closes #5612