Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

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
test-bot authored 1 parent 767e01acommit 5beb36abadf5116bfbc3903893b2d327affd435eBrowse files
10 files changed+77−13
Files (10)
modifiedapps/diffinery/docs/accounts/invitations.md+1−0View file
@@ -223,6 +223,7 @@
223223 | Lifetime | 14 days from creation |
224224 | 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). |
225225 | 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. |
226227 | What joining accepts | Every open invite for that email address, not just the one you opened |
227228 | 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. |
228229
modifiedapps/diffinery/docs/admin/site-administration.md+1−0View file
@@ -168,6 +168,7 @@
168168 | Lifetime | 24 hours |
169169 | Uses | Once |
170170 | 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. |
171172 | Effect | Adds a passkey and signs that browser in; the person's other passkeys stay |
172173 | Audit | `signin_link.create` when you create it, `passkey.add` when it is used |
173174
modifiedapps/diffinery/docs/security/overview.md+1−1View file
@@ -37,7 +37,7 @@
3737 - Diffinery requires user verification (a fingerprint, face or device PIN) and discoverable credentials, and does not ask for attestation.
3838 - 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`).
3939 - 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.
4141 - Agents cannot sign in to the web UI. Only people get browser sessions.
4242
4343 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 @@
197197 .prepare("INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)")
198198 .bind(newId(), await sha256(token), account.id, by.id, now(), expiresAt)
199199 .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}`;
201203 await enqueueMail(this.env, [{ kind: "signin_link", data: { url, admin: by.login, expiresAt }, to: { accountId: account.id }, reason: "account", at: now() }]);
202204 return { url, expiresAt };
203205 }
modifiedapps/diffinery/src/lib/directory.ts+3−1View file
@@ -621,7 +621,9 @@
621621 */
622622 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> {
623623 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}`;
625627 const inviter = inv.invited_by ? await this.account(inv.invited_by) : null;
626628 const org = inv.org_id ? await this.account(inv.org_id) : null;
627629 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 @@
8484 item("invite-account", "Invitation (by username)", { kind: "invite", reason: "invitation", data: { inviter: me, url: `${pub}/settings/invitations`, repo, role: "write", expiresAt: exp, existing: true } }),
8585 item("invite-accepted", "Invitation accepted", { kind: "invite_accepted", reason: "account", data: { login: "rivera", email: "rivera@example.com", org: repo.split("/")[0] } }),
8686 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() } }),
8888 item("signup-code", "Sign-up code", { kind: "signup_code", reason: "signup", data: { code: "042917", expiresAt: new Date(Date.now() + 600_000).toISOString() } }),
8989 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() } }),
9090 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 @@
161161 return { login, name: str(body.name).trim().slice(0, 100) };
162162 }
163163
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+
164174 /** 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> {
166176 const auth = new Auth(c.env);
167177 const inv = await auth.invite(token);
168178 if (!inv) throw new HttpError(404, "this invite is invalid, already used, or expired");
169179 const next = `/invite/${encodeURIComponent(token)}`;
170180 // The link alone isn't enough to create the account: the copy emailed to the address carries a proof.
171181 // 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");
173184 if (inv.email && !(await validInviteProof(c.env, inv.hash, proof))) {
174185 return render(
175186 c,
176- { title: "Join Diffinery" },
187+ { title: "Join Diffinery", script: posted === undefined ? INVITE_PROOF_SCRIPT : undefined },
177188 <AuthCard title="Confirm your email">
178189 <p class="muted">
179190 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.
180191 </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>
181195 <Form action={`/invite/${encodeURIComponent(token)}/email`} t={csrfToken(c)}>
182196 <button class="btn primary block">Email me the link</button>
183197 </Form>
@@ -391,7 +405,39 @@
391405 );
392406 });
393407
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.
395441 web.get("/signin/:token", async (c) => {
396442 const token = prm(c, "token");
397443 const link = await new Auth(c.env).signinLink(token);
@@ -608,6 +654,14 @@
608654
609655 // ------------------------------------------------------------ invitations: resend the email that proves the address
610656
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+
611665 post(web, "/invite/:token/email", async (c) => {
612666 const token = prm(c, "token");
613667 const back = `/invite/${encodeURIComponent(token)}`;
modifiedapps/diffinery/src/web/routes.tsx+1−1View file
@@ -31,7 +31,7 @@
3131
3232 /**
3333 * 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/*`, `/-/*`,
3535 * `/:owner` and `/:owner/:repo/...` (including `/:owner/:repo/raw/*`).
3636 *
3737 * 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 @@
7575 const lines = [
7676 "User-agent: *",
7777 "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}`),
7979 "",
8080 `Sitemap: ${origin}/sitemap.xml`,
8181 "",
modifiedapps/diffinery/test/e2e/passkeys.test.mjs+7−3View file
@@ -172,8 +172,9 @@
172172 }
173173 await Promise.all([page.waitForLoadState("load"), page.click("text=Email me the link")]);
174174 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");
177178 assert.match(await text(page), /invited as pal@example\.com/);
178179 await page.fill("#login", "pal");
179180 await page.fill("#name", "Pal Friend");
@@ -250,9 +251,12 @@
250251 await Promise.all([boss.page.waitForLoadState("load"), boss.page.locator("tr:has-text('pal@example.com') button:has-text('Sign-in link')").click()]);
251252 await boss.page.waitForSelector("text=Sign-in link for pal");
252253 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,}$`));
254256 await pal.newDevice();
255257 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");
256260 await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("button:has-text('Create a passkey')")]);
257261 assert.equal(await page.locator("button:has-text('Remove')").count(), 2);
258262