Files
gitea/api/router.ts
T
officer 00243b312a rewrite Gitea's URLs so the frame stays inside the proxy
Gitea believes it lives at /. Its pages are full of root-absolute links, and
inside the frame those resolve against OFFICER'S origin — the page renders
perfectly and falls apart on the first click.

Four places a URL hides, each failing differently: href/src/action attributes,
window.config.appSubUrl, Location headers, and absolute URLs built from
ROOT_URL.

appSubUrl is the highest-value line. Gitea already supports being hosted under
a subpath and its client JS prefixes every URL it builds with that value, so
filling it in makes Gitea correct ITSELF rather than by interception.

The ROOT_URL case needs Gitea's public origin, which the sidecar does not have
— it fetches from loopback. Rather than add a second setting that can disagree
with Gitea's own, appUrlOrigin reads it out of the page: Gitea emits "appUrl"
in the same config blob as appSubUrl.

Not rewritten, deliberately: JavaScript bodies, because a blanket rewrite of
absolute paths inside a script corrupts strings that are not URLs; and clone
URLs, which must keep working for whoever copies them.

/_web/* now forwards by hand rather than through createSidecarProxy. That
forwarder passes three headers and nothing else, on purpose — it is shared by
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.

Eighteen tests, the only part of this plugin verifiable without a running
Gitea. One caught a real bug: rewriteAbsolute turns a full URL into <base>/path
and the attribute pass then prefixed it again.
2026-08-19 08:31:28 +00:00

141 lines
6.4 KiB
TypeScript

import { randomBytes } from 'node:crypto';
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 });
/**
* Mint the frame cookie. Registered BEFORE the proxy's `/*`, because hono matches in registration order
* and the wildcard would otherwise swallow it and forward `/session` to the sidecar, which has no such
* route and would answer 404 — a 404 that looks exactly like the plugin being broken.
*/
proxy.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.
*/
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);
}
// 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 {
return ctx.text('gitea unreachable', 502);
}
});
export const router = proxy.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 ──
const COOKIE = 'officer_gitea_frame';
const TTL_MS = 12 * 60 * 60 * 1000;
/** token → { userId, expires }. In memory on purpose: a restart closing every frame is correct. */
const sessions = new Map<string, { userId: number; expires: number }>();
const sweep = () => {
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 {
sweep();
const token = randomBytes(32).toString('base64url');
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`
// 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.
return `${COOKIE}=${token}; 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 found = sessions.get(raw.slice(COOKIE.length + 1));
if (!found) return null;
if (found.expires < Date.now()) {
sessions.delete(raw.slice(COOKIE.length + 1));
return null;
}
return found.userId;
}