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.
scout/forge:scout/5612-a-note-from-the-app-s-own-origin-is-a-vi → main 3 commits fork 3 threads · 3 unresolved
8 files
changed +227
−20
README.md +11
| @@ -989,6 +989,17 @@ Under the box, four starter sentences are plain links (no script): | ||
| 989 | 989 | server-rendered page paints up to 100 ms later (a page sent with a |
| 990 | 990 | `Content-Length`, or one that starts with something other than markup, does |
| 991 | 991 | not wait). Event streams and WebSockets are untouched. |
| 992 | - **A signed-in note needs the panel's one-use proof (#5612).** The grant | |
| 993 | cookie alone no longer makes a note `@name`: the forge puts a one-use token in | |
| 994 | the panel's script tag on a signed-in page load, the panel sends it as | |
| 995 | `x-forge-proof` and each answer carries the next. Without it, a reused one or | |
| 996 | one for another grant or host, the sender is a visitor and `admin` is false. | |
| 997 | This stops an app's code that merely holds the cookie (a `fetch` from its | |
| 998 | page). It does **not** stop app code that runs in the same page and watches | |
| 999 | for the tag or patches `fetch` before the panel runs: the token reaches the | |
| 1000 | panel through the page, so that code can read it. Closing that needs the | |
| 1001 | panel in a cross-origin frame, which is not built. If a call fails the panel | |
| 1002 | asks for a reload, since the chain is then spent. | |
| 992 | 1003 | - **The forge button is a feedback door (#4758).** One tap opens *Tell the |
| 993 | 1004 | builders*: a text box and Send, no account. The note is posted to the app's |
| 994 | 1005 | own host (`POST /__forge/note`, same-origin only) and filed as an issue on |
src/apps/feedback.ts +46 −3
| @@ -35,6 +35,8 @@ export interface FeedbackAsk { | ||
| 35 | 35 | readonly sealed: boolean; |
| 36 | 36 | /** The person the app's grant names, if any. */ |
| 37 | 37 | readonly viewerId: string | undefined; |
| 38 | /** The grant cookie's value, which the panel's proof is bound to. */ | |
| 39 | readonly grantCookie?: string | undefined; | |
| 38 | 40 | readonly secure: boolean; |
| 39 | 41 | /** The client's limit key, from the socket (`clientBucket`). */ |
| 40 | 42 | readonly source: string; |
| @@ -85,6 +87,39 @@ function doorFor(ask: FeedbackAsk): Door | undefined { | ||
| 85 | 87 | return undefined; |
| 86 | 88 | } |
| 87 | 89 | |
| 90 | // ---- the panel's one-use proof ---- | |
| 91 | // | |
| 92 | // The app's own code runs on this origin and can send the person's grant | |
| 93 | // cookie with a fetch, so a cookie alone does not say the forge panel asked. | |
| 94 | // The panel's script tag carries a token the forge minted for that page load; | |
| 95 | // each answer to a request that spent one carries the next in `x-forge-proof`. | |
| 96 | // Held in memory, as the process is one: a restart makes everyone a visitor | |
| 97 | // until the next page load. | |
| 98 | ||
| 99 | export const PROOF_HEADER = "x-forge-proof"; | |
| 100 | const PROOF_TTL_MS = 24 * 3_600_000; | |
| 101 | const PROOF_MAX = 20_000; | |
| 102 | const proofs = new Map<string, { bind: string; expires: number }>(); | |
| 103 | ||
| 104 | const bindOf = (grantCookie: string, host: string): string => sha256(`${host}\n${grantCookie}`); | |
| 105 | ||
| 106 | /** A fresh token for this grant cookie on this host. Never put it in a URL. */ | |
| 107 | export function mintProof(grantCookie: string, host: string, now = Date.now()): string { | |
| 108 | while (proofs.size >= PROOF_MAX) proofs.delete(proofs.keys().next().value!); | |
| 109 | const token = randomBytes(24).toString("base64url"); | |
| 110 | proofs.set(token, { bind: bindOf(grantCookie, host), expires: now + PROOF_TTL_MS }); | |
| 111 | return token; | |
| 112 | } | |
| 113 | ||
| 114 | /** Spend a token: true once, for the grant and host it was minted for. */ | |
| 115 | function spendProof(token: string | null, grantCookie: string | undefined, host: string | null, now = Date.now()): boolean { | |
| 116 | if (token === null || grantCookie === undefined || host === null) return false; | |
| 117 | const held = proofs.get(token); | |
| 118 | if (held === undefined) return false; | |
| 119 | proofs.delete(token); | |
| 120 | return held.expires > now && held.bind === bindOf(grantCookie, host); | |
| 121 | } | |
| 122 | ||
| 88 | 123 | /** The sender: a person's key and name, or the anonymous cookie's. */ |
| 89 | 124 | function senderOf(ask: FeedbackAsk, door: Door): { sender: Sender; fresh?: string } { |
| 90 | 125 | if (door.viewer !== undefined) |
| @@ -155,12 +190,12 @@ async function send(ask: FeedbackAsk, door: Door): Promise<Response> { | ||
| 155 | 190 | return json(201, { note: sent.value, name: sender.name }, cookies); |
| 156 | 191 | } |
| 157 | 192 | |
| 158 | function list(ask: FeedbackAsk, door: Door): Response { | |
| 193 | function list(ask: FeedbackAsk, door: Door, proven: boolean): Response { | |
| 159 | 194 | const { sender, fresh } = senderOf(ask, door); |
| 160 | 195 | const notes = notesFor(ask.app, ask.record, door.repo, ask.forgeUrl, sender); |
| 161 | 196 | return json( |
| 162 | 197 | 200, |
| 163 | { name: sender.name, notes, admin: door.viewer !== undefined && canAdmin(ask.app.db, door.repo, door.viewer) }, | |
| 198 | { name: sender.name, notes, admin: proven && door.viewer !== undefined && canAdmin(ask.app.db, door.repo, door.viewer) }, | |
| 164 | 199 | fresh === undefined ? {} : { "set-cookie": visitorCookie(fresh, ask.secure) }, |
| 165 | 200 | ); |
| 166 | 201 | } |
| @@ -171,11 +206,19 @@ export async function feedback(ask: FeedbackAsk): Promise<Response | undefined> | ||
| 171 | 206 | const method = ask.request.method; |
| 172 | 207 | if (path === "/__forge/note/moderate") return json(404, { error: "not_found" }); |
| 173 | 208 | if (path !== "/__forge/note" && path !== "/__forge/notes") return undefined; |
| 209 | const host = ask.request.headers.get("host"); | |
| 210 | const proven = spendProof(ask.request.headers.get(PROOF_HEADER), ask.grantCookie, host); | |
| 211 | const answer = await answerNote(proven ? ask : { ...ask, viewerId: undefined }, path, method, proven); | |
| 212 | if (proven) answer.headers.set(PROOF_HEADER, mintProof(ask.grantCookie!, host!)); | |
| 213 | return answer; | |
| 214 | } | |
| 215 | ||
| 216 | async function answerNote(ask: FeedbackAsk, path: string, method: string, proven: boolean): Promise<Response> { | |
| 174 | 217 | const door = doorFor(ask); |
| 175 | 218 | if (door === undefined) return json(404, { error: "not_found" }); |
| 176 | 219 | if (path === "/__forge/notes") { |
| 177 | 220 | if (method !== "GET") return json(405, { error: "method" }, { allow: "GET" }); |
| 178 | return list(ask, door); | |
| 221 | return list(ask, door, proven); | |
| 179 | 222 | } |
| 180 | 223 | if (method !== "POST") return json(405, { error: "method" }, { allow: "POST" }); |
| 181 | 224 | if (!sameOrigin(ask.request)) return json(403, { error: "cross_origin" }); |
src/apps/note_words.ts +3
| @@ -14,3 +14,6 @@ export const NOTE_FAILED = "That did not go through. Try again."; | ||
| 14 | 14 | |
| 15 | 15 | /** A note longer than one issue body may be. */ |
| 16 | 16 | export const NOTE_TOO_LONG = "That is too long to send in one go."; |
| 17 | ||
| 18 | /** The panel's proof chain broke: the page must be loaded again to be known as its signed-in person. */ | |
| 19 | export const PROOF_LOST = "Reload this page to send as yourself; until then notes go as a visitor."; |
src/apps/proxy.ts +19 −1
| @@ -36,7 +36,7 @@ import { loadKey } from "../kernel/secret.ts"; | ||
| 36 | 36 | import { previewEnv, type EnvName } from "./data.ts"; |
| 37 | 37 | import type { AppsRuntime, EnvStatus } from "./runtime.ts"; |
| 38 | 38 | import { crewNow, tellLink } from "./crew.ts"; |
| 39 | import { feedback } from "./feedback.ts"; | |
| 39 | import { feedback, mintProof } from "./feedback.ts"; | |
| 40 | 40 | import { clientBucket } from "../http/peer.ts"; |
| 41 | 41 | import { doorPage } from "./door.ts"; |
| 42 | 42 | import { notFound } from "../http/respond.ts"; |
| @@ -673,6 +673,7 @@ export async function proxyApp( | ||
| 673 | 673 | env: target.env, |
| 674 | 674 | sealed, |
| 675 | 675 | viewerId, |
| 676 | grantCookie: cookie, | |
| 676 | 677 | secure: |
| 677 | 678 | url.protocol === "https:" || |
| 678 | 679 | request.headers.get("x-forwarded-proto") === "https", |
| @@ -811,10 +812,20 @@ export async function proxyApp( | ||
| 811 | 812 | } catch { |
| 812 | 813 | return unavailable(runtime, target, status, "the app stopped answering"); |
| 813 | 814 | } |
| 815 | // A signed-in person's page load gets the panel's one-use proof, and so | |
| 816 | // is theirs alone: never stored or shared. A page the app's own script | |
| 817 | // fetched (dest "empty", or a same-origin frame) gets none. | |
| 818 | const grantCookie = cookieValue(request, grantCookieName(target)); | |
| 819 | const proof = | |
| 820 | vouched?.who !== undefined && grantCookie !== undefined && proofWanted(request) | |
| 821 | ? mintProof(grantCookie, request.headers.get("host") ?? "") | |
| 822 | : undefined; | |
| 823 | if (proof !== undefined) out.set("cache-control", "private, no-store"); | |
| 814 | 824 | const page = withToolbar( |
| 815 | 825 | bytes, |
| 816 | 826 | runtime.options.forgeUrl, |
| 817 | 827 | pillWhere(runtime, target, vouched?.who), |
| 828 | proof, | |
| 818 | 829 | ); |
| 819 | 830 | let sent = page; |
| 820 | 831 | // Whether it is gzipped depends on the client, so every copy says so. |
| @@ -847,6 +858,13 @@ export async function proxyApp( | ||
| 847 | 858 | ); |
| 848 | 859 | } |
| 849 | 860 | |
| 861 | /** Whether a request is a page load the panel may be proved on (see `mintProof`). */ | |
| 862 | export function proofWanted(request: Request): boolean { | |
| 863 | const dest = request.headers.get("sec-fetch-dest"); | |
| 864 | if (dest === null || dest === "document") return true; | |
| 865 | return dest === "iframe" && request.headers.get("sec-fetch-site") === "cross-site"; | |
| 866 | } | |
| 867 | ||
| 850 | 868 | /** |
| 851 | 869 | * An event stream, passed through untouched while it keeps the environment |
| 852 | 870 | * busy: held from its first byte until it ends, fails, or the browser |
src/apps/toolbar.ts +36 −6
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | |
| 14 | 14 | import { staticResponse } from "../web/asset.ts"; |
| 15 | 15 | import { sha256 } from "../kernel/hash.ts"; |
| 16 | import { NOTE_EMPTY, NOTE_FAILED } from "./note_words.ts"; | |
| 16 | import { NOTE_EMPTY, NOTE_FAILED, PROOF_LOST } from "./note_words.ts"; | |
| 17 | 17 | |
| 18 | 18 | /** The client, as plain source: no `eval`, no inline handlers. */ |
| 19 | 19 | export const toolbarJs = `(function () { |
| @@ -31,6 +31,34 @@ export const toolbarJs = `(function () { | ||
| 31 | 31 | var pill = document.createElement("button"); |
| 32 | 32 | pill.type = "button"; |
| 33 | 33 | var d = me.dataset || {}; |
| 34 | // The one-use proof the forge put on this page load: taken, then gone from the page. | |
| 35 | var proof = d.proof || ""; | |
| 36 | if (me.removeAttribute) me.removeAttribute("data-proof"); | |
| 37 | var queue = Promise.resolve(); | |
| 38 | var lost = false; | |
outdated
If the fetch rejects, or the response has no next token, |
||
| 39 | // The chain broke (a failed call, or an answer with no next proof): say so once, in the panel. | |
| 40 | function broke() { | |
| 41 | proof = ""; | |
| 42 | lost = true; | |
| 43 | if (typeof tellSay !== "undefined") tellSay.textContent = ${JSON.stringify(PROOF_LOST)}; | |
| 44 | } | |
| 45 | // One call at a time, each carrying the proof the last answer left. | |
| 46 | function call(url, init) { | |
| 47 | var run = function () { | |
| 48 | init.headers = Object.assign({}, init.headers); | |
| 49 | var sent = proof; | |
| 50 | if (sent) init.headers["x-forge-proof"] = sent; | |
| 51 | return fetch(url, init).then(function (r) { | |
| 52 | var next = r.headers.get("x-forge-proof"); | |
| 53 | if (next) proof = next; | |
| 54 | else if (sent) broke(); | |
| 55 | return r; | |
| 56 | }, function (e) { if (sent) broke(); throw e; }); | |
| 57 | }; | |
| 58 | var p = queue.then(run, run); | |
| 59 | queue = p.catch(function () {}); | |
| 60 | return p; | |
| 61 | } | |
| 34 | 62 | var where = d.env === "production" ? "production" : d.pull ? "preview of pull #" + d.pull : ""; |
| 35 | 63 | pill.setAttribute("aria-label", [d.app, where].filter(Boolean).join(", ") || "Forge"); |
| 36 | 64 | pill.setAttribute("aria-expanded", "false"); |
| @@ -141,7 +169,7 @@ export const toolbarJs = `(function () { | ||
| 141 | 169 | }); |
| 142 | 170 | } |
| 143 | 171 | function refresh(sent) { |
| 144 | fetch("/__forge/notes", { credentials: "same-origin", cache: "no-store" }) | |
| 172 | call("/__forge/notes", { credentials: "same-origin", cache: "no-store" }) | |
| 145 | 173 | .then(function (r) { return r.ok ? r.json() : null; }) |
| 146 | 174 | .then(function (j) { |
| 147 | 175 | if (!j || !Array.isArray(j.notes)) return; |
| @@ -160,7 +188,7 @@ export const toolbarJs = `(function () { | ||
| 160 | 188 | tellGo.setAttribute("aria-busy", "true"); |
| 161 | 189 | tellGo.style.opacity = ".6"; |
| 162 | 190 | function idle() { tellGo.disabled = false; tellGo.removeAttribute("aria-busy"); tellGo.style.opacity = "1"; } |
| 163 | fetch("/__forge/note", { | |
| 191 | call("/__forge/note", { | |
| 164 | 192 | method: "POST", |
| 165 | 193 | credentials: "same-origin", |
| 166 | 194 | headers: { "content-type": "application/json" }, |
| @@ -421,7 +449,7 @@ export function escapeAttr(text: string): string { | ||
| 421 | 449 | } |
| 422 | 450 | |
| 423 | 451 | /** The script tag, with where-you-are as escaped data attributes. */ |
| 424 | export function toolbarTag(forgeUrl: string, where?: PillWhere): string { | |
| 452 | export function toolbarTag(forgeUrl: string, where?: PillWhere, proof?: string): string { | |
| 425 | 453 | const data = |
| 426 | 454 | where === undefined |
| 427 | 455 | ? "" |
| @@ -435,7 +463,8 @@ export function toolbarTag(forgeUrl: string, where?: PillWhere): string { | ||
| 435 | 463 | .filter((pair): pair is [string, string] => pair[1] !== undefined) |
| 436 | 464 | .map(([k, v]) => ` data-${k}="${escapeAttr(v)}"`) |
| 437 | 465 | .join(""); |
| 438 | return `<script src="${new URL(TOOLBAR_PATH, forgeUrl).href}"${data} defer></script>`; | |
| 466 | const token = proof === undefined ? "" : ` data-proof="${escapeAttr(proof)}"`; | |
| 467 | return `<script src="${new URL(TOOLBAR_PATH, forgeUrl).href}"${data}${token} defer></script>`; | |
| 439 | 468 | } |
| 440 | 469 | |
| 441 | 470 | /** `bytes` with the pill's tag before the last `</body>`, or at the end. */ |
| @@ -443,8 +472,9 @@ export function withToolbar( | ||
| 443 | 472 | bytes: Uint8Array, |
| 444 | 473 | forgeUrl: string, |
| 445 | 474 | where?: PillWhere, |
| 475 | proof?: string, | |
| 446 | 476 | ): Uint8Array<ArrayBuffer> { |
| 447 | const tag = Buffer.from(toolbarTag(forgeUrl, where)); | |
| 477 | const tag = Buffer.from(toolbarTag(forgeUrl, where, proof)); | |
| 448 | 478 | const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); |
| 449 | 479 | // latin1 is one character per byte, so an index in it is a byte offset. |
| 450 | 480 | const at = buffer.toString("latin1").toLowerCase().lastIndexOf("</body"); |
test/apps-crew-pill.test.ts +4 −3
| @@ -197,9 +197,10 @@ describe("the crew line", () => { | ||
| 197 | 197 | |
| 198 | 198 | test("the script asks once, same-origin, and never reads the page or its cookies", () => { |
| 199 | 199 | expect(toolbarJs).toContain("/__forge/crew"); |
| 200 | // The crew line is asked once; the feedback door (#4758) adds the | |
| 201 | // sender's notes, polled only while the panel is open, and the send. | |
| 202 | expect(toolbarJs.match(/fetch\(/g)?.length).toBe(3); | |
| 200 | // One fetch is the crew line; the other is `call`, the feedback door's | |
outdated
Wrap this comment; it is one very long line. |
||
| 201 | // single path (#4758): the sender's notes, polled only while the panel | |
| 202 | // is open, and the send, each carrying the proof header in and out. | |
| 203 | expect(toolbarJs.match(/fetch\(/g)?.length).toBe(2); | |
| 203 | 204 | expect(toolbarJs.match(/setInterval/g)?.length).toBe(1); |
| 204 | 205 | expect(toolbarJs).not.toMatch( |
| 205 | 206 | /document\.cookie|querySelector|getElementById|setTimeout/, |
test/apps-feedback.test.ts +106 −5
| @@ -4,13 +4,14 @@ | ||
| 4 | 4 | * a status that follows the issue, and the owner can hide or ban. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; | |
| 8 | 8 | import { mkdtemp, rm } from "node:fs/promises"; |
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { newApp } from "../src/actions/apps.ts"; |
| 13 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 13 | import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts"; | |
| 14 | import { PROOF_LOST } from "../src/apps/note_words.ts"; | |
| 14 | 15 | import { toolbarJs } from "../src/apps/toolbar.ts"; |
| 15 | 16 | import { filtered, moderateNote, noteClock, PER_APP_DAY, PER_SENDER, PER_SOURCE, saysFromApp } from "../src/actions/app_notes.ts"; |
| 16 | 17 | import { feedback, REQUEST_MAX } from "../src/apps/feedback.ts"; |
| @@ -85,6 +86,24 @@ describe("the forge button takes notes from anyone", () => { | ||
| 85 | 86 | }); |
| 86 | 87 | const post = (text: string, cookie = "", source = "", slug = "notes") => |
| 87 | 88 | at(slug, "/__forge/note", { method: "POST", body: JSON.stringify({ text, page: `http://${host(slug)}/lists?x=1`, viewport: "390x844" }) }, cookie, source); |
| 89 | /** A page load through the proxy; the app behind it is a stub that serves a small page. */ | |
| 90 | const page = async (slug: string, cookie: string, headers: Record<string, string> = {}): Promise<{ res: Response; proof: string | undefined }> => { | |
| 91 | const backend = Bun.serve({ port: 0, fetch: () => new Response("<html><body>hi</body></html>", { headers: { "content-type": "text/html" } }) }); | |
| 92 | const spy = spyOn(AppsRuntime.prototype, "envStatus").mockImplementation( | |
| 93 | () => ({ env: "production", state: "running", port: backend.port, commitOid: "a".repeat(40) }) as EnvStatus, | |
| 94 | ); | |
| 95 | try { | |
| 96 | const res = await at(slug, "/", { headers }, cookie); | |
| 97 | const text = await res.text(); | |
| 98 | return { res, proof: /data-proof="([^"]+)"/.exec(text)?.[1] }; | |
| 99 | } finally { | |
| 100 | spy.mockRestore(); | |
| 101 | backend.stop(true); | |
| 102 | } | |
| 103 | }; | |
| 104 | const withProof = (proof: string | undefined, headers: Record<string, string> = {}) => ({ ...headers, ...(proof ? { "x-forge-proof": proof } : {}) }); | |
| 105 | const postAs = (slug: string, cookie: string, proof: string | undefined, text = "A note") => | |
| 106 | at(slug, "/__forge/note", { method: "POST", headers: withProof(proof), body: JSON.stringify({ text, page: `http://${host(slug)}/`, viewport: "390x844" }) }, cookie, `10.9.${Math.floor(Math.random() * 250)}.${Math.floor(Math.random() * 250)}`); | |
| 88 | 107 | const cookieOf = (res: Response) => (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 89 | 108 | |
| 90 | 109 | test("a visitor's note files a from-app issue with page, viewport and time", async () => { |
| @@ -186,7 +205,8 @@ describe("the forge button takes notes from anyone", () => { | ||
| 186 | 205 | expect(issues()).toBe(before); |
| 187 | 206 | expect((await at("notes--98", "/__forge/notes")).status).toBe(404); |
| 188 | 207 | const owner = await grantedCookie(app, servers.url, alice, getAppBySlug(app.db, "notes")!, "pull-98"); |
| 189 | expect((await post("from a person", owner, "", "notes--98")).status).toBe(201); | |
| 208 | const { proof } = await page("notes--98", owner); | |
| 209 | expect((await postAs("notes--98", owner, proof, "from a person")).status).toBe(201); | |
| 190 | 210 | expect(issues()).toBe(before + 1); |
| 191 | 211 | // The same app's production stays open to visitors. |
| 192 | 212 | expect((await post("still open", "", "", "notes")).status).toBe(201); |
| @@ -299,14 +319,15 @@ describe("the forge button takes notes from anyone", () => { | ||
| 299 | 319 | |
| 300 | 320 | test("a private app takes notes from a signed-in reader, as themselves, and from no one else", async () => { |
| 301 | 321 | const reader = await grantedCookie(app, servers.url, alice, getAppBySlug(app.db, "diary")!); |
| 302 | const res = await post("A note on the diary", reader, "10.5.0.1", "diary"); | |
| 322 | const first = await page("diary", reader); | |
| 323 | const res = await postAs("diary", reader, first.proof, "A note on the diary"); | |
| 303 | 324 | expect(res.status).toBe(201); |
| 304 | 325 | expect(((await res.json()) as { name: string }).name).toBe("@alice"); |
| 305 | 326 | const repo = getRepo(app.db, "alice", "diary")!; |
| 306 | 327 | const issue = app.db.query<{ body: string }, [string]>("SELECT body FROM issue WHERE repo_id = ?1 ORDER BY number DESC").get(repo.id)!; |
| 307 | 328 | expect(issue.body).toContain("by @alice."); |
| 308 | 329 | expect(issue.body).toContain("Untrusted data"); |
| 309 | const mine = (await (await at("diary", "/__forge/notes", {}, reader)).json()) as { notes: unknown[]; admin: boolean }; | |
| 330 | const mine = (await (await at("diary", "/__forge/notes", { headers: withProof(res.headers.get("x-forge-proof")!) }, reader)).json()) as { notes: unknown[]; admin: boolean }; | |
| 310 | 331 | expect(mine.notes.length).toBe(1); |
| 311 | 332 | expect(mine.admin).toBe(true); |
| 312 | 333 | expect((await at("diary", "/__forge/notes")).status).toBe(404); |
| @@ -413,6 +434,86 @@ describe("the forge button takes notes from anyone", () => { | ||
| 413 | 434 | expect((await at("notes", path, { method: "POST", body: JSON.stringify({ id: secondId, action: "ban" }) }, "")).status).toBe(404); |
| 414 | 435 | }); |
| 415 | 436 | |
| 437 | test("a note from the app's own origin is a visitor's unless it carries the panel's proof", async () => { | |
| 438 | const record = getAppBySlug(app.db, "notes")!; | |
| 439 | const owner = await grantedCookie(app, servers.url, alice, record); | |
| 440 | const list = async (cookie: string, proof?: string) => | |
| 441 | (await (await at("notes", "/__forge/notes", { headers: withProof(proof) }, cookie)).json()) as { name: string; admin: boolean }; | |
| 442 | ||
| 443 | // Unproven: the grant cookie alone is a visitor. | |
| 444 | const bare = await postAs("notes", owner, undefined); | |
| 445 | expect(bare.status).toBe(201); | |
| 446 | expect(((await bare.json()) as { name: string }).name).toMatch(/^visitor-/); | |
| 447 | expect(bare.headers.get("x-forge-proof")).toBeNull(); | |
| 448 | const seen = await list(owner); | |
| 449 | expect(seen.name).toMatch(/^visitor-/); | |
| 450 | expect(seen.admin).toBe(false); | |
| 451 | ||
| 452 | // Proven: @alice, the list is theirs, and the answer carries the next token. | |
| 453 | const loaded = await page("notes", owner); | |
| 454 | expect(loaded.proof).toBeTruthy(); | |
| 455 | expect(loaded.res.headers.get("cache-control")).toBe("private, no-store"); | |
| 456 | const proven = await postAs("notes", owner, loaded.proof); | |
| 457 | expect(((await proven.json()) as { name: string }).name).toBe("@alice"); | |
| 458 | const next = proven.headers.get("x-forge-proof")!; | |
| 459 | expect(next).toBeTruthy(); | |
| 460 | expect(next).not.toBe(loaded.proof); | |
| 461 | const mine = await list(owner, next); | |
| 462 | expect(mine.name).toBe("@alice"); | |
| 463 | expect(mine.admin).toBe(true); | |
| 464 | ||
| 465 | // Reused: the spent token is a visitor. | |
| 466 | const reused = await postAs("notes", owner, loaded.proof); | |
| 467 | expect(((await reused.json()) as { name: string }).name).toMatch(/^visitor-/); | |
| 468 | expect(reused.headers.get("x-forge-proof")).toBeNull(); | |
| 469 | ||
| 470 | // Another grant's token, and another host's. | |
| 471 | const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase" }); | |
| 472 | if (!bob.ok) throw new Error(bob.error); | |
| 473 | const bobs = await grantedCookie(app, servers.url, bob.value, record); | |
| 474 | const foreignGrant = await page("notes", owner); | |
| 475 | expect((await list(bobs, foreignGrant.proof)).name).toMatch(/^visitor-/); | |
| 476 | const foreignHost = await page("notes", owner); | |
| 477 | const onOther = await at("diary", "/__forge/notes", { headers: withProof(foreignHost.proof) }, await grantedCookie(app, servers.url, alice, getAppBySlug(app.db, "diary")!)); | |
| 478 | expect(onOther.status).toBe(404); | |
| 479 | ||
| 480 | // A same-origin fetch of the page gets no token; a document load and a cross-site frame do. | |
| 481 | expect((await page("notes", owner, { "sec-fetch-dest": "empty", "sec-fetch-site": "same-origin" })).proof).toBeUndefined(); | |
| 482 | expect((await page("notes", owner, { "sec-fetch-dest": "iframe", "sec-fetch-site": "same-origin" })).proof).toBeUndefined(); | |
| 483 | expect((await page("notes", owner, { "sec-fetch-dest": "document" })).proof).toBeTruthy(); | |
| 484 | expect((await page("notes", owner, { "sec-fetch-dest": "iframe", "sec-fetch-site": "cross-site" })).proof).toBeTruthy(); | |
| 485 | // A visitor with no grant is given none. | |
| 486 | expect((await page("notes", "")).proof).toBeUndefined(); | |
| 487 | }); | |
| 488 | ||
| 489 | test("app code that holds the cookie and a copy of a spent or stale token is refused", async () => { | |
| 490 | const owner = await grantedCookie(app, servers.url, alice, getAppBySlug(app.db, "notes")!); | |
| 491 | const loaded = await page("notes", owner); | |
| 492 | // The panel spends the page's token and gets the next; a hostile script that | |
| 493 | // copied the page's token, or guesses one, acts as a visitor. | |
| 494 | const panel = await postAs("notes", owner, loaded.proof); | |
| 495 | expect(((await panel.json()) as { name: string }).name).toBe("@alice"); | |
| 496 | for (const stolen of [loaded.proof, "A".repeat(32), ""]) { | |
| 497 | const res = await postAs("notes", owner, stolen); | |
| 498 | expect(((await res.json()) as { name: string }).name).toMatch(/^visitor-/); | |
| 499 | expect(res.headers.get("x-forge-proof")).toBeNull(); | |
| 500 | } | |
| 501 | // The chain's next token is still good for the panel. | |
| 502 | const next = panel.headers.get("x-forge-proof")!; | |
| 503 | const again = await at("notes", "/__forge/notes", { headers: withProof(next) }, owner); | |
| 504 | expect(((await again.json()) as { name: string }).name).toBe("@alice"); | |
| 505 | }); | |
| 506 | ||
| 507 | test("the panel says so when its proof chain breaks", () => { | |
| 508 | expect(toolbarJs).toContain("broke()"); | |
| 509 | expect(toolbarJs).toContain(PROOF_LOST); | |
| 510 | }); | |
| 511 | ||
| 512 | test("the panel takes the proof from its tag, strips it, and sends it with each call", () => { | |
| 513 | expect(toolbarJs).toContain('removeAttribute("data-proof")'); | |
| 514 | expect(toolbarJs).toContain("x-forge-proof"); | |
| 515 | }); | |
| 516 | ||
| 416 | 517 | test("the clock is the real one again", () => { |
| 417 | 518 | noteClock.now = () => Date.now(); |
| 418 | 519 | }); |
test/toolbar-actions.test.ts +2 −2
| @@ -90,9 +90,9 @@ describe("toolbar actions", () => { | ||
| 90 | 90 | expect(toolbarJs).toContain('"/toolbar"'); |
| 91 | 91 | // Its requests are all to the app's own origin: the crew line (#2233) and, |
| 92 | 92 | // for the feedback door (#4758), the sender's notes and their status. |
| 93 | expect(toolbarJs.match(/fetch\(/g)?.length).toBe(3); | |
| 93 | expect(toolbarJs.match(/fetch\(/g)?.length).toBe(2); | |
| 94 | 94 | expect(toolbarJs).toContain('fetch("/__forge/crew"'); |
| 95 | expect(toolbarJs).toContain('fetch("/__forge/note"'); | |
| 95 | expect(toolbarJs).toContain('call("/__forge/note"'); | |
| 96 | 96 | expect(toolbarJs).not.toMatch(/FormData|\.send\(/); |
| 97 | 97 | expect(toolbarJs).not.toContain("XMLHttpRequest"); |
| 98 | 98 | expect(toolbarJs).not.toContain("document.cookie"); |
new above
offline
The token is readable here by any earlier app script (a MutationObserver or a patched window.fetch) before this line strips it. Deliver it so app JS cannot see it, and capture the native fetch first.