Files
officer 01e4210c62 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.
2026-08-19 09:23:25 +00:00

178 lines
8.7 KiB
TypeScript

import { createHmac, timingSafeEqual } from 'node:crypto';
import { getKey } from 'officerdb/secret-store';
import { createRouter } from '@@/create-router';
import { createSidecarProxy } from '@@/sidecar/create-proxy';
import { mountPrefix } from '@@/plugins/manifest';
import { manifest } from '../manifest';
// Two doors, because an iframe cannot carry a bearer token.
//
// ── The ordinary door ──
//
// `/_config`, `/_health` and `/_api/*` are normal API calls from the panel. They go through the
// platform's gate like any plugin's, and `createSidecarProxy` forwards them.
//
// ── The iframe door ──
//
// Gitea already has a web interface, and this plugin renders it rather than reimplementing it. That
// means an `<iframe>`, and an iframe's sub-resources — every stylesheet, script and avatar — are fetched
// by the browser with no Authorization header. So `/_web/*` is served ABOVE the platform's gate
// (`unauthenticatedPaths` in the manifest) and authenticates itself with a cookie instead.
//
// The cookie holds a MINTED token, not a constant. `dev-proxy` sets `=1`, which is fine for a
// tailnet-only owner-gated preview but means anyone who knows the cookie name can set it themselves.
// Here the value is 32 random bytes, held in memory against the user who asked for it, and it expires.
//
// What it protects is narrow, and worth being honest about: Gitea authenticates the person itself, with
// its own session cookie, so an unauthenticated caller reaching `/_web/` sees Gitea's login page and
// nothing else. This stops Officer being used as an open proxy to it — not a data leak, a manners
// problem, but a cheap one to fix.
const PREFIX = `/api${mountPrefix({ appName: 'gitea', manifest })}`;
const proxy = createSidecarProxy({ name: 'gitea', prefix: PREFIX });
/**
* OUR routes first, then the proxy's catch-all.
*
* `createSidecarProxy` registers `all('/*')` when it is CALLED, so anything added to `proxy.router`
* afterwards is dead — hono matches in registration order and the wildcard has already claimed
* everything. Both routes below were added that way at first, and the symptom was
* `POST /session -> 500`: forwarded to a sidecar that has no such route, reported as "internal error",
* and looking for all the world like the sidecar being broken.
*
* So this is a router of our own, with the proxy's mounted UNDER it at the end.
*/
const router_ = createRouter();
/** Mint the frame cookie for the iframe, which cannot carry a bearer token. */
router_.post('/session', (ctx) => {
const user = ctx.get('user') as { id: number } | undefined;
if (!user) return ctx.json({ error: 'not authenticated' }, 401);
ctx.header('set-cookie', mintFrameCookie(user.id, PREFIX));
return ctx.json({ ok: true });
});
/**
* The iframe door, forwarded by hand rather than through `createSidecarProxy`.
*
* That proxy forwards exactly three headers — `content-type`, `range`, `if-none-match` — and nothing
* else, on purpose: it is the shared forwarder for every sidecar and its restraint is what makes it safe
* to reuse. **It strips cookies**, and this path is nothing but cookies. Loosening it for gitea would
* loosen it for the wallet.
*
* So this one path forwards the browser's request itself, headers and all. Everything else about the
* plugin still goes through the shared proxy.
*/
router_.all('/_web/*', async (ctx) => {
const userId = frameUser(ctx.req.header('cookie') ?? null);
// Deliberately terse. Whoever reaches this without a cookie is a stray request, not a person — the
// panel mints one before the frame loads.
if (userId === null) return ctx.text('no gitea frame session', 401);
const sidecar = proxy.getHttpUrl();
if (!sidecar) return ctx.text('gitea sidecar not available', 503);
const url = new URL(ctx.req.url);
const subpath = url.pathname.slice(PREFIX.length); // keeps the `/_web` the sidecar routes on
const headers = new Headers(ctx.req.raw.headers);
for (const h of ['host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'authorization']) {
headers.delete(h);
}
// ACCEPT-ENCODING GOES TOO, and this one is not tidiness.
//
// `fetch()` transparently decompresses a response, but `upstream.headers` still says
// `content-encoding: gzip`. Re-emitting those headers with the decompressed body hands the browser a
// lie, and it fails with ZlibError — reported here as a 502, which is what every asset in the frame
// was doing while the HTML rendered fine. The HTML escaped only because the sidecar drops that header
// when it rewrites.
//
// Asking for no compression at all removes the mismatch instead of chasing it through two hops. The
// cost is nil: this hop is loopback, and the browser's own connection to the platform is compressed by
// whatever terminates TLS.
headers.delete('accept-encoding');
// The sidecar binds loopback and trusts these, which is exactly why nothing but this may set them.
headers.set('X-Officer-User', String(userId));
// Where the browser thinks the frame lives. The sidecar rewrites Gitea's HTML, redirects and cookies
// against it and has no other way to know — it must not hardcode how the platform routes to it, and
// `mountPrefix` is the only thing entitled to decide that.
headers.set('X-Officer-Frame-Base', `${PREFIX}/_web`);
const method = ctx.req.method;
const hasBody = method !== 'GET' && method !== 'HEAD';
try {
const upstream = await fetch(`${sidecar}${subpath}${url.search}`, {
method,
headers,
...(hasBody ? { body: await ctx.req.raw.arrayBuffer() } : {}),
// The sidecar has already rewritten any Location for the frame; following it here would fetch a
// page the browser never asked for and lose the redirect it needs to see.
redirect: 'manual',
});
return new Response(upstream.body, { status: upstream.status, headers: upstream.headers });
} catch (err) {
// Logged, not swallowed. "unreachable" on its own sent two people to the wrong half of the system:
// the sidecar was answering 200 to a direct curl the whole time, and the failure was here.
console.error(`[gitea] frame ${method} ${subpath} -> ${sidecar} failed:`, err);
return ctx.text('gitea unreachable', 502);
}
});
// Last, so everything above wins.
router_.route('/', proxy.router);
export const router = router_;
/** Base URL of the sidecar's HTTP server, or null if it has not reported in yet. */
export const getGiteaServerUrl = proxy.getHttpUrl;
// ── 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 TTL_MS = 12 * 60 * 60 * 1000;
const sign = (payload: string): string => createHmac('sha256', getKey('jwt')).update(`gitea-frame:${payload}`).digest('base64url');
/** Mint the Set-Cookie an authenticated caller needs before the frame loads. */
export function mintFrameCookie(userId: number, prefix: string): string {
const payload = `${userId}.${Date.now() + TTL_MS}`;
const value = `${payload}.${sign(payload)}`;
// 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
// the Gitea host, where the cookie would be third-party and dropped.
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. */
export function frameUser(cookieHeader: string | null): number | null {
if (!cookieHeader) return null;
const raw = cookieHeader
.split(';')
.map((p) => p.trim())
.find((p) => p.startsWith(`${COOKIE}=`));
if (!raw) return null;
const [id, expires, mac] = raw.slice(COOKIE.length + 1).split('.');
if (!id || !expires || !mac) return null;
const expected = sign(`${id}.${expires}`);
// Constant-time, because comparing a signature with `===` leaks how much of it was right.
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;
}