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.
178 lines
8.7 KiB
TypeScript
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;
|
|
}
|