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.
This commit is contained in:
+43
-13
@@ -43,24 +43,54 @@ proxy.router.post('/session', (ctx) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* The iframe door: verify the cookie, and turn it back into a user.
|
||||
* The iframe door, forwarded by hand rather than through `createSidecarProxy`.
|
||||
*
|
||||
* `use` rather than `all`, so it runs ahead of the proxy's `/*` regardless of registration order.
|
||||
* 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.
|
||||
*
|
||||
* Setting `user` is not a convenience — `createSidecarProxy` reads `ctx.get('user').id` to fill
|
||||
* `X-Officer-User`, and the sidecar refuses a request without it. On this path the platform's gate never
|
||||
* ran, so nothing has set it. The cookie IS the authentication here, and this is where it resolves to
|
||||
* the account it was minted for.
|
||||
* 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.use('/_web/*', async (ctx, next) => {
|
||||
proxy.router.all('/_web/*', async (ctx) => {
|
||||
const userId = frameUser(ctx.req.header('cookie') ?? null);
|
||||
if (userId === null) {
|
||||
// Deliberately terse. Whoever reaches this without a cookie is a stray request, not a person — the
|
||||
// panel mints one before the frame loads.
|
||||
return ctx.text('no gitea frame session', 401);
|
||||
// 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);
|
||||
}
|
||||
ctx.set('user', { id: userId } as never);
|
||||
return next();
|
||||
});
|
||||
|
||||
export const router = proxy.router;
|
||||
|
||||
Reference in New Issue
Block a user