Keep sign-in and invite tokens out of URLs that reach the server (#219)
* Keep sign-in and invite tokens out of URLs that reach the server
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- Deploy / Deploy to Cloudflaresuccess
- Deploy / Main's current tipsuccess
10 files changed+77−13
Files (10)
- apps/diffinery/docs/accounts/invitations.md+1−0
- apps/diffinery/docs/admin/site-administration.md+1−0
- apps/diffinery/docs/security/overview.md+1−1
- apps/diffinery/src/auth/sessions.ts+3−1
- apps/diffinery/src/lib/directory.ts+3−1
- apps/diffinery/src/mail/samples.ts+1−1
- apps/diffinery/src/web/auth.tsx+58−4
- apps/diffinery/src/web/routes.tsx+1−1
- apps/diffinery/src/web/seo.tsx+1−1
- apps/diffinery/test/e2e/passkeys.test.mjs+7−3
modifiedapps/diffinery/docs/accounts/invitations.md+1−0View file
| @@ -223,6 +223,7 @@ | ||
| 223 | 223 | | Lifetime | 14 days from creation | |
| 224 | 224 | | Uses | Once. A used, revoked or expired link shows "this invite is invalid, already used, or expired" (HTTP 404). If you are already signed in, the page still shows **Accept invite**, and clicking it answers "this invite is invalid or has expired" (HTTP 404). | |
| 225 | 225 | | Who can use it | Only the email address it was sent to | |
| 226 | +| Link format | The emailed link is `https://<host>/invite/<token>#proof=<proof>`. The proof, which shows you received the email, is in the URL fragment: browsers never send it to the server, so it can't end up in request logs. The page removes it from the address bar. Older links with `?proof=` still work. | | |
| 226 | 227 | | What joining accepts | Every open invite for that email address, not just the one you opened | |
| 227 | 228 | | Withdrawing | Pending invitations can be revoked: organization invitations on the organization's **People** tab, repository invitations on the repository's **Settings → Collaborators** page, instance invitations on **Settings → People and invitations**. Otherwise they stop working once used or after 14 days. | |
| 228 | 229 |
modifiedapps/diffinery/docs/admin/site-administration.md+1−0View file
| @@ -168,6 +168,7 @@ | ||
| 168 | 168 | | Lifetime | 24 hours | |
| 169 | 169 | | Uses | Once | |
| 170 | 170 | | Disabled accounts | Refused with "enable this account first"; enable the account, then create the link | |
| 171 | +| Link format | `https://<host>/signin#<token>`. The token is in the URL fragment, which browsers never send to the server, so it stays out of request logs and `Referer` headers. The page removes it from the address bar. Older `/signin/<token>` links still work. | | |
| 171 | 172 | | Effect | Adds a passkey and signs that browser in; the person's other passkeys stay | |
| 172 | 173 | | Audit | `signin_link.create` when you create it, `passkey.add` when it is used | |
| 173 | 174 |
modifiedapps/diffinery/docs/security/overview.md+1−1View file
| @@ -37,7 +37,7 @@ | ||
| 37 | 37 | - Diffinery requires user verification (a fingerprint, face or device PIN) and discoverable credentials, and does not ask for attestation. |
| 38 | 38 | - Each sign-in uses a fresh challenge that expires after 5 minutes, can be used once, and is tied to the browser that requested it by a `__Host-dfy_webauthn` cookie (`HttpOnly`, `Secure`, `SameSite=Strict`). |
| 39 | 39 | - New people create their first passkey from an invite link, which expires after 14 days and works once. |
| 40 | -- If someone loses all their passkeys, a site admin can create a one-time sign-in link that lets them add a new passkey. It expires after 24 hours and works once. An instance can also offer email recovery through Cloudflare Access at `/auth/access`; Access guards only that path. | |
| 40 | +- If someone loses all their passkeys, a site admin can create a one-time sign-in link that lets them add a new passkey. It expires after 24 hours and works once. Its token, like an invite's email proof, travels in the URL fragment (`/signin#<token>`), which browsers never send to the server, so it stays out of request and proxy logs. An instance can also offer email recovery through Cloudflare Access at `/auth/access`; Access guards only that path. | |
| 41 | 41 | - Agents cannot sign in to the web UI. Only people get browser sessions. |
| 42 | 42 | |
| 43 | 43 | Manage your passkeys under **Settings**, **Passkeys and sessions**. Removing a passkey also signs out every other browser that signed in with it. See [Passkeys and sign-in](/docs/accounts/passkeys-and-sign-in). |
modifiedapps/diffinery/src/auth/sessions.ts+3−1View file
| @@ -197,7 +197,9 @@ | ||
| 197 | 197 | .prepare("INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)") |
| 198 | 198 | .bind(newId(), await sha256(token), account.id, by.id, now(), expiresAt) |
| 199 | 199 | .run(); |
| 200 | - const url = `${this.env.PUBLIC_URL}/signin/${token}`; | |
| 200 | + // The token rides in the fragment, which browsers never send to the server, so request logs never hold | |
| 201 | + // it (web/auth.tsx /signin). Links sent before this used /signin/<token>, still accepted until they expire. | |
| 202 | + const url = `${this.env.PUBLIC_URL}/signin#${token}`; | |
| 201 | 203 | await enqueueMail(this.env, [{ kind: "signin_link", data: { url, admin: by.login, expiresAt }, to: { accountId: account.id }, reason: "account", at: now() }]); |
| 202 | 204 | return { url, expiresAt }; |
| 203 | 205 | } |
modifiedapps/diffinery/src/lib/directory.ts+3−1View file
| @@ -621,7 +621,9 @@ | ||
| 621 | 621 | */ |
| 622 | 622 | async mailInvite(inv: { id: string; email: string; org_id: string | null; repo_id: string | null; role: string; invited_by: string | null; expires_at: string }, token: string): Promise<void> { |
| 623 | 623 | const proof = await inviteProof(this.env, await sha256(token)); |
| 624 | - const url = `${this.env.PUBLIC_URL}/invite/${token}?proof=${proof}`; | |
| 624 | + // The proof rides in the fragment, which browsers never send to the server, so request logs never hold | |
| 625 | + // it; the invite page posts it (web/auth.tsx). Links sent before this carried it as ?proof=, still accepted. | |
| 626 | + const url = `${this.env.PUBLIC_URL}/invite/${token}#proof=${proof}`; | |
| 625 | 627 | const inviter = inv.invited_by ? await this.account(inv.invited_by) : null; |
| 626 | 628 | const org = inv.org_id ? await this.account(inv.org_id) : null; |
| 627 | 629 | const repo = inv.repo_id ? await this.repoById(inv.repo_id) : null; |
modifiedapps/diffinery/src/mail/samples.ts+1−1View file
| @@ -84,7 +84,7 @@ | ||
| 84 | 84 | item("invite-account", "Invitation (by username)", { kind: "invite", reason: "invitation", data: { inviter: me, url: `${pub}/settings/invitations`, repo, role: "write", expiresAt: exp, existing: true } }), |
| 85 | 85 | item("invite-accepted", "Invitation accepted", { kind: "invite_accepted", reason: "account", data: { login: "rivera", email: "rivera@example.com", org: repo.split("/")[0] } }), |
| 86 | 86 | item("welcome", "Welcome", { kind: "welcome", reason: "account", data: { login: me } }), |
| 87 | - item("signin-link", "Sign-in link", { kind: "signin_link", reason: "account", data: { url: `${pub}/signin/sample`, admin: "admin", expiresAt: new Date(Date.now() + 86400_000).toISOString() } }), | |
| 87 | + item("signin-link", "Sign-in link", { kind: "signin_link", reason: "account", data: { url: `${pub}/signin#sample`, admin: "admin", expiresAt: new Date(Date.now() + 86400_000).toISOString() } }), | |
| 88 | 88 | item("signup-code", "Sign-up code", { kind: "signup_code", reason: "signup", data: { code: "042917", expiresAt: new Date(Date.now() + 600_000).toISOString() } }), |
| 89 | 89 | item("email-verify", "Confirm an address after a bounce", { kind: "email_verify", reason: "account", data: { email: "diffy@example.com", url: `${pub}/settings/email/verify/sample`, expiresAt: new Date(Date.now() + 86400_000).toISOString() } }), |
| 90 | 90 | item("signup-attempt", "Sign-up with an existing address", { kind: "signup_attempt", reason: "account", data: { email: "diffy@example.com" } }), |
modifiedapps/diffinery/src/web/auth.tsx+58−4View file
| @@ -161,23 +161,37 @@ | ||
| 161 | 161 | return { login, name: str(body.name).trim().slice(0, 100) }; |
| 162 | 162 | } |
| 163 | 163 | |
| 164 | +/** | |
| 165 | + * Runs on the invite page: an invitation email carries its proof in the URL's fragment | |
| 166 | + * (`#proof=...`), which browsers never send, so it stays out of request logs. The page posts it | |
| 167 | + * to the server in a form body instead, with the fragment taken out of the address bar first. | |
| 168 | + */ | |
| 169 | +const INVITE_PROOF_SCRIPT = `(function(){function go(){var m=/^#proof=([A-Za-z0-9_-]{16,128})$/.exec(location.hash);var f=document.getElementById("invite-proof");if(!m||!f)return;history.replaceState(null,"",location.pathname);f.elements.proof.value=m[1];f.submit();}go();addEventListener("hashchange",go);})();`; | |
| 170 | + | |
| 171 | +/** Runs on /signin: takes a sign-in link's token from the fragment and shows which account it adds a passkey to. */ | |
| 172 | +const SIGNIN_LINK_SCRIPT = `(function(){function go(){var s=document.getElementById("signin-link-status"),f=document.getElementById("signin-link-form");var t=location.hash.slice(1);if(!t&&f.elements.link.value)return;if(!/^[A-Za-z0-9_-]{20,100}$/.test(t)){s.textContent="This link is incomplete. Open it again from the email or message it came in, in full.";f.style.display="none";return;}history.replaceState(null,"",location.pathname);fetch("/auth/passkey/link-info",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({link:t}),credentials:"same-origin"}).then(function(r){return r.json().then(function(j){if(!r.ok)throw new Error(j.message||"This sign-in link is invalid, already used, or expired.");return j;});}).then(function(j){s.textContent="This link lets you add a passkey to "+j.login+(j.email?" ("+j.email+")":"")+". It works once. After that, sign in with the passkey.";f.elements.link.value=t;f.style.display="";}).catch(function(e){s.textContent=e.message;f.style.display="none";});}go();addEventListener("hashchange",go);})();`; | |
| 173 | + | |
| 164 | 174 | /** Invite landing for someone without an account: create a passkey and join in one step. */ |
| 165 | -export async function invitePasskeyPage(c: AppContext, token: string): Promise<Response> { | |
| 175 | +export async function invitePasskeyPage(c: AppContext, token: string, posted?: string): Promise<Response> { | |
| 166 | 176 | const auth = new Auth(c.env); |
| 167 | 177 | const inv = await auth.invite(token); |
| 168 | 178 | if (!inv) throw new HttpError(404, "this invite is invalid, already used, or expired"); |
| 169 | 179 | const next = `/invite/${encodeURIComponent(token)}`; |
| 170 | 180 | // The link alone isn't enough to create the account: the copy emailed to the address carries a proof. |
| 171 | 181 | // Ask for it before saying whether the address has an account, since the inviter has the link too. |
| 172 | - const proof = c.req.query("proof"); | |
| 182 | + // Emails sent now carry it in the fragment (posted here by INVITE_PROOF_SCRIPT); older ones in the query. | |
| 183 | + const proof = posted ?? c.req.query("proof"); | |
| 173 | 184 | if (inv.email && !(await validInviteProof(c.env, inv.hash, proof))) { |
| 174 | 185 | return render( |
| 175 | 186 | c, |
| 176 | - { title: "Join Diffinery" }, | |
| 187 | + { title: "Join Diffinery", script: posted === undefined ? INVITE_PROOF_SCRIPT : undefined }, | |
| 177 | 188 | <AuthCard title="Confirm your email"> |
| 178 | 189 | <p class="muted"> |
| 179 | 190 | This invitation is for <b>{inv.email}</b>. To make sure it's you, open the link in the invitation email sent to that address. If you can't find it, we can send it again. |
| 180 | 191 | </p> |
| 192 | + <Form action={`/invite/${encodeURIComponent(token)}/proof`} t={csrfToken(c)} id="invite-proof" style="display:none"> | |
| 193 | + <input type="hidden" name="proof" value="" /> | |
| 194 | + </Form> | |
| 181 | 195 | <Form action={`/invite/${encodeURIComponent(token)}/email`} t={csrfToken(c)}> |
| 182 | 196 | <button class="btn primary block">Email me the link</button> |
| 183 | 197 | </Form> |
| @@ -391,7 +405,39 @@ | ||
| 391 | 405 | ); |
| 392 | 406 | }); |
| 393 | 407 | |
| 394 | - // Admin-issued sign-in link: add a passkey to an existing account. | |
| 408 | + // Admin-issued sign-in link: add a passkey to an existing account. Links carry the token in the | |
| 409 | + // fragment (/signin#<token>), which browsers never send, so request logs never hold a usable copy: | |
| 410 | + // SIGNIN_LINK_SCRIPT reads it, takes it out of the address bar, and asks which account it is for. | |
| 411 | + web.get("/signin", (c) => | |
| 412 | + render( | |
| 413 | + c, | |
| 414 | + { title: "Add a passkey", script: SIGNIN_LINK_SCRIPT }, | |
| 415 | + <AuthCard title="Add a passkey"> | |
| 416 | + <p class="muted" id="signin-link-status"> | |
| 417 | + Checking this sign-in link… | |
| 418 | + </p> | |
| 419 | + <p class="muted small">Using it signs out every other browser signed in to this account, in case a lost device is still signed in. Access tokens keep working.</p> | |
| 420 | + <form data-passkey="register" data-next="/settings/security" id="signin-link-form" style="display:none"> | |
| 421 | + <input type="hidden" name="link" value="" /> | |
| 422 | + <div class="field"> | |
| 423 | + <label for="label">Name for this passkey</label> | |
| 424 | + <input id="label" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} /> | |
| 425 | + </div> | |
| 426 | + <PasskeyMessage /> | |
| 427 | + <button class="btn primary block">Create a passkey</button> | |
| 428 | + </form> | |
| 429 | + </AuthCard>, | |
| 430 | + ), | |
| 431 | + ); | |
| 432 | + | |
| 433 | + // Which account a sign-in link is for, for the page above; the token comes in the body, never the URL. | |
| 434 | + jsonPost(web, "/auth/passkey/link-info", async (c, body) => { | |
| 435 | + const link = await new Auth(c.env).signinLink(str(body.link)); | |
| 436 | + if (!link) throw new HttpError(404, "This sign-in link is invalid, already used, or expired."); | |
| 437 | + return c.json({ login: link.account.login, email: link.account.email ?? null }); | |
| 438 | + }); | |
| 439 | + | |
| 440 | + // Links sent before the token moved to the fragment. | |
| 395 | 441 | web.get("/signin/:token", async (c) => { |
| 396 | 442 | const token = prm(c, "token"); |
| 397 | 443 | const link = await new Auth(c.env).signinLink(token); |
| @@ -608,6 +654,14 @@ | ||
| 608 | 654 | |
| 609 | 655 | // ------------------------------------------------------------ invitations: resend the email that proves the address |
| 610 | 656 | |
| 657 | + // The proof from an invitation email's fragment, posted by the invite page (INVITE_PROOF_SCRIPT). | |
| 658 | + post(web, "/invite/:token/proof", async (c, form) => { | |
| 659 | + const token = prm(c, "token"); | |
| 660 | + // Signed in: the invite is accepted as that account, with no proof needed. | |
| 661 | + if (c.get("principal").type === "account") return c.redirect(`/invite/${encodeURIComponent(token)}`, 303); | |
| 662 | + return invitePasskeyPage(c, token, form.s("proof")); | |
| 663 | + }); | |
| 664 | + | |
| 611 | 665 | post(web, "/invite/:token/email", async (c) => { |
| 612 | 666 | const token = prm(c, "token"); |
| 613 | 667 | const back = `/invite/${encodeURIComponent(token)}`; |
modifiedapps/diffinery/src/web/routes.tsx+1−1View file
| @@ -31,7 +31,7 @@ | ||
| 31 | 31 | |
| 32 | 32 | /** |
| 33 | 33 | * Server-rendered web UI. Mount at "/" after the API, Git and runner routes: |
| 34 | - * it owns `/`, `/docs/*`, `/llms.txt`, `/login`, `/auth/*`, `/signin/*`, `/new`, `/settings/*`, `/notifications/*`, `/invite/*`, `/-/*`, | |
| 34 | + * it owns `/`, `/docs/*`, `/llms.txt`, `/login`, `/auth/*`, `/signin`, `/signin/*`, `/new`, `/settings/*`, `/notifications/*`, `/invite/*`, `/-/*`, | |
| 35 | 35 | * `/:owner` and `/:owner/:repo/...` (including `/:owner/:repo/raw/*`). |
| 36 | 36 | * |
| 37 | 37 | * Expects the `principal` and `dir` variables to be set by an earlier middleware. |
modifiedapps/diffinery/src/web/seo.tsx+1−1View file
| @@ -75,7 +75,7 @@ | ||
| 75 | 75 | const lines = [ |
| 76 | 76 | "User-agent: *", |
| 77 | 77 | "Allow: /", |
| 78 | - ...["/api/", "/mcp", "/runner/", "/auth/", "/login", "/signin/", "/invite/", "/settings", "/notifications", "/new", "/admin", "/request-access", "/-/", "/*.git/", "/*/raw/"].map((p) => `Disallow: ${p}`), | |
| 78 | + ...["/api/", "/mcp", "/runner/", "/auth/", "/login", "/signin", "/invite/", "/settings", "/notifications", "/new", "/admin", "/request-access", "/-/", "/*.git/", "/*/raw/"].map((p) => `Disallow: ${p}`), | |
| 79 | 79 | "", |
| 80 | 80 | `Sitemap: ${origin}/sitemap.xml`, |
| 81 | 81 | "", |
modifiedapps/diffinery/test/e2e/passkeys.test.mjs+7−3View file
| @@ -172,8 +172,9 @@ | ||
| 172 | 172 | } |
| 173 | 173 | await Promise.all([page.waitForLoadState("load"), page.click("text=Email me the link")]); |
| 174 | 174 | assert.match(await text(page), /We emailed the link to pal@example\.com/); |
| 175 | - // The emailed link carries the proof. | |
| 176 | - await page.goto(`${base}/invite/${INVITE}?proof=${proofFor(INVITE)}`); | |
| 175 | + // The emailed link carries the proof in its fragment, which the page posts (so it never reaches a request log). | |
| 176 | + await Promise.all([page.waitForURL(`${base}/invite/${INVITE}/proof`), page.goto(`${base}/invite/${INVITE}#proof=${proofFor(INVITE)}`)]); | |
| 177 | + await page.waitForSelector("text=invited as pal@example.com"); | |
| 177 | 178 | assert.match(await text(page), /invited as pal@example\.com/); |
| 178 | 179 | await page.fill("#login", "pal"); |
| 179 | 180 | await page.fill("#name", "Pal Friend"); |
| @@ -250,9 +251,12 @@ | ||
| 250 | 251 | await Promise.all([boss.page.waitForLoadState("load"), boss.page.locator("tr:has-text('pal@example.com') button:has-text('Sign-in link')").click()]); |
| 251 | 252 | await boss.page.waitForSelector("text=Sign-in link for pal"); |
| 252 | 253 | const link = (await boss.page.textContent(".secret")).trim(); |
| 253 | - assert.match(link, new RegExp(`^${base}/signin/`)); | |
| 254 | + // The token is in the fragment: browsers never send it, so no request log holds it. | |
| 255 | + assert.match(link, new RegExp(`^${base}/signin#[A-Za-z0-9_-]{20,}$`)); | |
| 254 | 256 | await pal.newDevice(); |
| 255 | 257 | await page.goto(link); |
| 258 | + await page.waitForSelector("text=add a passkey to pal"); | |
| 259 | + assert.equal(page.url(), `${base}/signin`, "the page takes the token out of the address bar"); | |
| 256 | 260 | await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("button:has-text('Create a passkey')")]); |
| 257 | 261 | assert.equal(await page.locator("button:has-text('Remove')").count(), 2); |
| 258 | 262 |