diff --git a/AGENTS.md b/AGENTS.md index 505736a..dc9e56f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,11 +95,19 @@ npm run deploy - Public vote totals live in the `VOTE_STORE` SQLite Durable Object. A GitHub account may hold at most one vote per loop and can switch or remove it. -- Vote writes require a valid HMAC-signed, HTTP-only session cookie plus an - exact trusted `Origin`. Never accept provider IDs, usernames, or voter keys - from browser JSON. -- Keep OAuth state in short-lived, signed, HTTP-only cookies. Keep post-login - redirects on the canonical Loop Library path. +- The here.now proxy strips browser cookies and mutation `Origin` headers, and + it follows upstream redirects. Do not build voting auth around proxied + cookies, HTTP redirect responses, or forwarded authorization headers. +- Start OAuth with a browser-generated nonce held in `sessionStorage`. Bind the + nonce and safe return path into a short-lived HMAC-signed OAuth state value. + The callback must return a no-store HTML bridge that verifies the stored + nonce before saving the signed session token and returning to the canonical + Loop Library path. +- Keep the signed session token in tab-scoped `sessionStorage` and send it only + in JSON bodies to the session and vote endpoints. Vote writes must derive the + provider ID, username, and voter key exclusively from that verified token. + Reject explicit untrusted Origins; missing Origins are expected through the + here.now proxy and remain protected by the required bearer token. - Do not expose OAuth client secrets or `SESSION_SECRET` in Worker variables, browser code, logs, or committed development files. Configure them with: @@ -116,8 +124,9 @@ npm run deploy production release. Vote controls render hidden and disabled, then appear only when `/api/votes` returns `uiEnabled: true`; missing or malformed values must remain fail-closed. -- With the launch flag off, verify the canonical GitHub start, callback, - session, vote persistence, reload, and logout flow. Change the flag to the +- With the launch flag off, verify the canonical GitHub start, nonce-bound + callback bridge, session, vote persistence, reload, and local logout flow. + Change the flag to the exact string `true` and redeploy the Worker from newest integrated `main` only after that smoke test passes. No site republish is required to reveal the controls. diff --git a/README.md b/README.md index 43e21cb..0c0c9c1 100644 --- a/README.md +++ b/README.md @@ -302,9 +302,16 @@ secrets; use `worker/.dev.vars.example` for local variable names only. Register the canonical callbacks shown in `AGENTS.md`, then deploy the Worker before the site shell because the shell calls the new auth and vote routes. +The here.now proxy does not forward browser cookies or mutation Origin headers +and follows upstream redirects. The OAuth flow therefore uses an HMAC-signed, +browser-nonce-bound state value and a no-store callback bridge. The bridge saves +the signed session token in tab-scoped `sessionStorage`; session lookup and vote +writes send it only inside same-origin JSON request bodies. + The production launch is fail-closed. Keep `VOTING_UI_ENABLED=false` while the -Worker and proxy are deployed, then complete a GitHub login, session, vote, -reload, and logout smoke test on the canonical domain. Set the value to the +Worker and proxy are deployed, then complete a GitHub login, nonce-bound +callback, session, vote, reload, and logout smoke test on the canonical domain. +Set the value to the exact string `true` and redeploy only the Worker after the smoke test passes; the already-published site will reveal voting without another site publish. diff --git a/scripts/check.mjs b/scripts/check.mjs index d9ad62f..8938b32 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -125,15 +125,15 @@ for (const value of [ assert(html.includes("Search the library")); assert(html.includes("Search by title, task, or contributor")); assert(html.includes('class="search-field"')); -assert(html.includes("styles.css?v=20260623-auth-gate")); -assert(html.includes("script.js?v=20260623-auth-gate")); +assert(html.includes("styles.css?v=20260623-proxy-auth")); +assert(html.includes("script.js?v=20260623-proxy-auth")); assert(css.includes(".search-control-label")); assert(css.includes(".search-control:hover .search-field")); assert(css.includes(".search-control:focus-within .search-field")); assert.equal((html.match(/data-here-now-credit/g) || []).length, 2); for (const page of [learnHtml, agentHtml]) { - assert(page.includes("styles.css?v=20260623-auth-gate")); - assert(page.includes("script.js?v=20260623-auth-gate")); + assert(page.includes("styles.css?v=20260623-proxy-auth")); + assert(page.includes("script.js?v=20260623-proxy-auth")); } for (const page of [html, learnHtml, agentHtml]) { const brandPosition = page.indexOf('class="brand-lockup"'); @@ -191,6 +191,13 @@ assert(rendererSource.includes('aria-label="Vote on this loop" hidden')); assert(browserScript.includes("setVotingUiVisible(body.uiEnabled === true)")); assert(css.includes(".vote-controls[hidden]")); assert(authVotesSource.includes('scope: "read:user"')); +assert(authVotesSource.includes("function authBridge")); +assert(authVotesSource.includes("readSignedValue(state")); +assert(browserScript.includes('window.sessionStorage.setItem(OAUTH_NONCE_KEY')); +assert(browserScript.includes('window.sessionStorage.getItem(VOTE_SESSION_KEY)')); +assert(browserScript.includes('url.searchParams.set("client_nonce", nonce)')); +assert(browserScript.includes("sessionToken: readVoteSessionToken()")); +assert(!authVotesSource.includes("Set-Cookie")); assert(!authVotesSource.includes("X_OAUTH")); assert(!authVotesSource.includes('"/auth/x"')); assert(!browserScript.includes("Continue with X")); diff --git a/site/agents/index.html b/site/agents/index.html index 5decd87..4a94559 100644 --- a/site/agents/index.html +++ b/site/agents/index.html @@ -76,8 +76,8 @@ href="https://signals.forwardfuture.ai/loop-library/catalog.txt" /> - - + + - + Loop Library: Repeatable AI Agent Workflows | Forward Future diff --git a/site/learn/index.html b/site/learn/index.html index 114f615..8cf0045 100644 --- a/site/learn/index.html +++ b/site/learn/index.html @@ -74,8 +74,8 @@ href="https://signals.forwardfuture.ai/loop-library/agents/" /> - - + + `; + return new Response(body, { + status: 200, headers: { "Cache-Control": "no-store", - Location: location, + "Content-Security-Policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'none'; base-uri 'none'; frame-ancestors 'none'", + "Content-Type": "text/html; charset=utf-8", "Referrer-Policy": "no-referrer", - ...headers, + "X-Content-Type-Options": "nosniff", }, }); } +function scriptJson(value) { + return JSON.stringify(value) + .replace(//g, "\\u003e") + .replace(/&/g, "\\u0026") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + function jsonResponse(body, status = 200, headers = {}) { return Response.json(body, { status, diff --git a/worker/src/render-loops.js b/worker/src/render-loops.js index 6eed216..c6cf8d0 100644 --- a/worker/src/render-loops.js +++ b/worker/src/render-loops.js @@ -311,9 +311,9 @@ export function renderLoopPage(loop, loops) { - + - + ${escapeHtml(loop.seoTitle)} diff --git a/worker/test/auth-votes.test.js b/worker/test/auth-votes.test.js index e88bdb9..9281446 100644 --- a/worker/test/auth-votes.test.js +++ b/worker/test/auth-votes.test.js @@ -88,28 +88,20 @@ function makeEnv() { }; } -function cookiePair(setCookie, name) { - const match = setCookie.match(new RegExp(`(?:^|, )(${name}=[^;]+)`)); - assert(match, `${name} cookie missing from ${setCookie}`); - return match[1]; -} - async function githubSession(env) { + const clientNonce = "test-browser-nonce-that-is-at-least-32-chars"; const start = await handleAuthVoteRoute( - new Request(`${BASE}/auth/github?return_to=%2Floop-library%2Floops%2Fovernight-docs-sweep%2F`), + new Request(`${BASE}/auth/github?return_to=%2Floop-library%2Floops%2Fovernight-docs-sweep%2F&client_nonce=${clientNonce}`), env, ); - assert.equal(start.status, 302); - const authorization = new URL(start.headers.get("Location")); + assert.equal(start.status, 200); + const authorization = new URL((await start.json()).authorizationUrl); assert.equal(authorization.origin, "https://github.com"); assert.equal(authorization.searchParams.get("scope"), "read:user"); const state = authorization.searchParams.get("state"); - const oauthCookie = cookiePair(start.headers.get("Set-Cookie"), "ll_oauth"); const calls = []; const callback = await handleAuthVoteRoute( - new Request(`${BASE}/auth/callback/github?code=test-code&state=${state}`, { - headers: { Cookie: oauthCookie }, - }), + new Request(`${BASE}/auth/callback/github?code=test-code&state=${encodeURIComponent(state)}`), env, { fetch: async (input, init = {}) => { @@ -124,32 +116,35 @@ async function githubSession(env) { }, }, ); - assert.equal(callback.status, 302); - assert.equal( - callback.headers.get("Location"), - `${BASE}/loops/overnight-docs-sweep/`, - ); + assert.equal(callback.status, 200); + const callbackBody = await callback.text(); + assert.match(callbackBody, /sessionStorage\.setItem\("ll_session"/); + assert.match(callbackBody, /loop-library\/loops\/overnight-docs-sweep/); + assert.match(callbackBody, new RegExp(clientNonce)); assert.equal(calls.length, 2); assert.equal( new Headers(calls[1].init.headers).get("Authorization"), "Bearer github-access-token", ); - return cookiePair(callback.headers.get("Set-Cookie"), "ll_session"); + const sessionMatch = callbackBody.match( + /"sessionToken":"([A-Za-z0-9_.-]+)"/, + ); + assert(sessionMatch, "session token missing from OAuth bridge"); + return sessionMatch[1]; } test("GitHub OAuth creates a signed session that can cast, switch, and remove a vote", async () => { const env = makeEnv(); - const sessionCookie = await githubSession(env); + const sessionToken = await githubSession(env); const voteRequest = (value, origin = ORIGIN) => new Request( `${BASE}/api/loops/overnight-docs-sweep/vote`, { method: "POST", headers: { "Content-Type": "application/json", - Cookie: sessionCookie, Origin: origin, }, - body: JSON.stringify({ value }), + body: JSON.stringify({ value, sessionToken }), }, ); @@ -168,17 +163,26 @@ test("GitHub OAuth creates a signed session that can cast, switch, and remove a score: -1, }); - const totals = await handleAuthVoteRoute( - new Request(`${BASE}/api/votes`, { headers: { Cookie: sessionCookie } }), + const session = await handleAuthVoteRoute( + new Request(`${BASE}/auth/session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionToken }), + }), env, ); - const totalsBody = await totals.json(); - assert.deepEqual(totalsBody.viewer, { + const sessionBody = await session.json(); + assert.deepEqual(sessionBody.viewer, { provider: "github", username: "octoloop", name: "Octo Loop", }); - assert.equal(totalsBody.viewerVotes["overnight-docs-sweep"], -1); + assert.equal(sessionBody.viewerVotes["overnight-docs-sweep"], -1); + + const totals = await handleAuthVoteRoute(new Request(`${BASE}/api/votes`), env); + const totalsBody = await totals.json(); + assert.equal(totalsBody.viewer, null); + assert.deepEqual(totalsBody.viewerVotes, {}); assert.equal(totalsBody.uiEnabled, true); const removed = await handleAuthVoteRoute(voteRequest(0), env); @@ -226,16 +230,15 @@ test("vote writes reject anonymous, cross-site, malformed, and unpublished reque ); assert.equal(anonymous.status, 401); - const sessionCookie = await githubSession(env); + const sessionToken = await githubSession(env); const crossSite = await handleAuthVoteRoute( new Request(`${BASE}/api/loops/overnight-docs-sweep/vote`, { method: "POST", headers: { "Content-Type": "application/json", - Cookie: sessionCookie, Origin: "https://phishing.example", }, - body: JSON.stringify({ value: 1 }), + body: JSON.stringify({ value: 1, sessionToken }), }), env, ); @@ -246,10 +249,9 @@ test("vote writes reject anonymous, cross-site, malformed, and unpublished reque method: "POST", headers: { "Content-Type": "application/json", - Cookie: sessionCookie, Origin: ORIGIN, }, - body: JSON.stringify({ value: 2 }), + body: JSON.stringify({ value: 2, sessionToken }), }), env, ); @@ -260,10 +262,9 @@ test("vote writes reject anonymous, cross-site, malformed, and unpublished reque method: "POST", headers: { "Content-Type": "application/json", - Cookie: sessionCookie, Origin: ORIGIN, }, - body: JSON.stringify({ value: 1 }), + body: JSON.stringify({ value: 1, sessionToken }), }), env, ); @@ -272,19 +273,25 @@ test("vote writes reject anonymous, cross-site, malformed, and unpublished reque test("GitHub OAuth state is verified and X routes are absent", async () => { const env = makeEnv(); + const clientNonce = "another-browser-nonce-that-is-at-least-32-chars"; const githubStart = await handleAuthVoteRoute( - new Request(`${BASE}/auth/github?return_to=%2Floop-library%2F`), + new Request(`${BASE}/auth/github?return_to=%2Floop-library%2F&client_nonce=${clientNonce}`), env, ); + assert.equal(githubStart.status, 200); const invalidCallback = await handleAuthVoteRoute( - new Request(`${BASE}/auth/callback/github?code=test-code&state=wrong-state`, { - headers: { Cookie: cookiePair(githubStart.headers.get("Set-Cookie"), "ll_oauth") }, - }), + new Request(`${BASE}/auth/callback/github?code=test-code&state=wrong-state`), env, ); - assert.equal(invalidCallback.status, 302); - assert.match(invalidCallback.headers.get("Location"), /auth_error=invalid_state/); + assert.equal(invalidCallback.status, 200); + assert.match(await invalidCallback.text(), /auth_error=invalid_state/); + + const missingNonce = await handleAuthVoteRoute( + new Request(`${BASE}/auth/github?return_to=%2Floop-library%2F`), + env, + ); + assert.equal(missingNonce.status, 400); assert.equal( await handleAuthVoteRoute(new Request(`${BASE}/auth/x`), env), @@ -292,22 +299,21 @@ test("GitHub OAuth state is verified and X routes are absent", async () => { ); }); -test("logout requires a trusted origin and clears the session", async () => { +test("mutation origins reject explicit cross-site requests and allow stripped proxy origins", async () => { const env = makeEnv(); const rejected = await handleAuthVoteRoute( - new Request(`${BASE}/auth/logout`, { method: "POST" }), + new Request(`${BASE}/auth/logout`, { + method: "POST", + headers: { Origin: "https://phishing.example" }, + }), env, ); assert.equal(rejected.status, 403); const accepted = await handleAuthVoteRoute( - new Request(`${BASE}/auth/logout`, { - method: "POST", - headers: { Origin: ORIGIN }, - }), + new Request(`${BASE}/auth/logout`, { method: "POST" }), env, ); assert.equal(accepted.status, 200); - assert.match(accepted.headers.get("Set-Cookie"), /ll_session=;/); - assert.match(accepted.headers.get("Set-Cookie"), /Max-Age=0/); + assert.deepEqual(await accepted.json(), { ok: true }); });