sign the frame cookie instead of storing it

The minted tokens lived in an in-memory Map, so every restart of the platform
silently invalidated every open frame: the browser kept sending a cookie that
matched nothing and the frame answered 401 mid-session with "no gitea frame
session". Deploying anything at all logged everyone out of the iframe — which
is exactly how this was found, a restart landing between a sign-in and the POST
that followed it.

An HMAC over the account and an expiry needs no state, so a restart changes
nothing. Same shape api/browser/relay-auth.ts already uses, and the same key —
getKey('jwt') from the secret store, which persists.

Compared with timingSafeEqual, because comparing a signature with === leaks how
much of it was right.
This commit is contained in:
officer
2026-08-19 09:23:25 +00:00
parent 9d219e9c8c
commit 01e4210c62
+30 -21
View File
@@ -1,4 +1,5 @@
import { randomBytes } from 'node:crypto'; import { createHmac, timingSafeEqual } from 'node:crypto';
import { getKey } from 'officerdb/secret-store';
import { createRouter } from '@@/create-router'; import { createRouter } from '@@/create-router';
import { createSidecarProxy } from '@@/sidecar/create-proxy'; import { createSidecarProxy } from '@@/sidecar/create-proxy';
import { mountPrefix } from '@@/plugins/manifest'; import { mountPrefix } from '@@/plugins/manifest';
@@ -127,27 +128,29 @@ export const router = router_;
export const getGiteaServerUrl = proxy.getHttpUrl; export const getGiteaServerUrl = proxy.getHttpUrl;
// ── The cookie ── // ── The cookie ──
//
// SIGNED, not stored. The first version held minted tokens in a `Map`, which meant every restart of the
// platform silently invalidated every open frame — the browser kept sending a cookie that matched
// nothing, and the frame answered 401 mid-session with "no gitea frame session". Deploying anything at
// all logged everyone out of the iframe.
//
// An HMAC over the account and an expiry needs no state, so a restart changes nothing. Same shape
// `api/browser/relay-auth.ts` already uses, and the same key: `getKey('jwt')` from the secret store,
// which persists.
const COOKIE = 'officer_gitea_frame'; const COOKIE = 'officer_gitea_frame';
const TTL_MS = 12 * 60 * 60 * 1000; const TTL_MS = 12 * 60 * 60 * 1000;
/** token → { userId, expires }. In memory on purpose: a restart closing every frame is correct. */ const sign = (payload: string): string => createHmac('sha256', getKey('jwt')).update(`gitea-frame:${payload}`).digest('base64url');
const sessions = new Map<string, { userId: number; expires: number }>();
const sweep = () => { /** Mint the Set-Cookie an authenticated caller needs before the frame loads. */
const now = Date.now();
for (const [token, s] of sessions) if (s.expires < now) sessions.delete(token);
};
/** Mint a frame token for an authenticated caller, and the Set-Cookie that carries it. */
export function mintFrameCookie(userId: number, prefix: string): string { export function mintFrameCookie(userId: number, prefix: string): string {
sweep(); const payload = `${userId}.${Date.now() + TTL_MS}`;
const token = randomBytes(32).toString('base64url'); const value = `${payload}.${sign(payload)}`;
sessions.set(token, { userId, expires: Date.now() + TTL_MS });
// Path-scoped to the frame, so it is never sent to the rest of the platform. `Lax` rather than `None` // Path-scoped to the frame, so it is never sent to the rest of the platform. `Lax` rather than `None`
// because the frame is same-origin — which is the entire reason this is proxied instead of pointed at // because the frame is same-origin — which is the entire reason this is proxied instead of pointed at
// gitea.officer.dev, where the cookie would be third-party and dropped. // the Gitea host, where the cookie would be third-party and dropped.
return `${COOKIE}=${token}; Path=${prefix}/_web; Max-Age=${Math.floor(TTL_MS / 1000)}; HttpOnly; SameSite=Lax`; return `${COOKIE}=${value}; Path=${prefix}/_web; Max-Age=${Math.floor(TTL_MS / 1000)}; HttpOnly; SameSite=Lax`;
} }
/** The user this request's frame cookie belongs to, or null. */ /** The user this request's frame cookie belongs to, or null. */
@@ -158,11 +161,17 @@ export function frameUser(cookieHeader: string | null): number | null {
.map((p) => p.trim()) .map((p) => p.trim())
.find((p) => p.startsWith(`${COOKIE}=`)); .find((p) => p.startsWith(`${COOKIE}=`));
if (!raw) return null; if (!raw) return null;
const found = sessions.get(raw.slice(COOKIE.length + 1));
if (!found) return null; const [id, expires, mac] = raw.slice(COOKIE.length + 1).split('.');
if (found.expires < Date.now()) { if (!id || !expires || !mac) return null;
sessions.delete(raw.slice(COOKIE.length + 1));
return null; const expected = sign(`${id}.${expires}`);
} // Constant-time, because comparing a signature with `===` leaks how much of it was right.
return found.userId; const a = Buffer.from(mac);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
if (Number(expires) < Date.now()) return null;
const userId = Number(id);
return Number.isInteger(userId) && userId > 0 ? userId : null;
} }