From 00243b312a581383ed69e1377efd78ffbf3b7a38 Mon Sep 17 00:00:00 2001 From: officer Date: Wed, 19 Aug 2026 08:31:28 +0000 Subject: [PATCH] rewrite Gitea's URLs so the frame stays inside the proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /path and the attribute pass then prefixed it again. --- PLUGIN.md | 39 ++++++++++ api/router.ts | 56 +++++++++++---- sidecar/index.ts | 29 ++++++++ sidecar/rewrite.test.ts | 116 ++++++++++++++++++++++++++++++ sidecar/rewrite.ts | 155 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 382 insertions(+), 13 deletions(-) create mode 100644 sidecar/rewrite.test.ts create mode 100644 sidecar/rewrite.ts diff --git a/PLUGIN.md b/PLUGIN.md index acf7ed1..dbb9284 100644 --- a/PLUGIN.md +++ b/PLUGIN.md @@ -61,6 +61,45 @@ What that protects is narrow, and worth stating plainly: Gitea authenticates the unauthenticated caller reaching `/_web/` sees a login page. This stops Officer being used as an open proxy to it. A manners problem, cheaply fixed. +## The rewriter + +Gitea believes it lives at `/`. Its pages are full of root-absolute links — `/user/login`, +`/assets/index.js` — and inside the frame those resolve against **Officer's** origin. The page renders +perfectly and falls apart on the first click. + +`sidecar/rewrite.ts` handles four places a URL hides, and they fail differently: + +| | why it matters | +| --- | --- | +| `href` / `src` / `action` attributes | the ordinary case: clicks and assets | +| `window.config.appSubUrl` | the highest-value line here — Gitea's own client JS prefixes every URL it builds with this, so filling it in makes Gitea correct **itself** rather than by interception | +| `Location` headers | the most visible failure: Gitea redirects after every login and form post, and an unrewritten one navigates the frame out of the proxy | +| absolute URLs built from `ROOT_URL` | the most damaging: they leave for a third-party origin where the session cookie is dropped, so the user is silently signed out | + +That last one needs to know 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 — `appSubUrl` removes the need. And clone URLs, which must keep +working for whoever copies them. + +Cookies get their `Path` narrowed to the frame so Gitea's session stays out of the platform's API. +`Secure` is left exactly as Gitea set it: adding it breaks a plain-http install, removing it weakens one +that meant it. + +Eighteen tests cover it, and they are the only part of this plugin that can be verified without a running +Gitea. One caught a real bug: `rewriteAbsolute` turns a full URL into `/path`, and the attribute +pass then prefixed it **again**. + +## The frame is forwarded by hand + +`/_web/*` does NOT go through `createSidecarProxy`. That forwarder passes exactly three headers — +`content-type`, `range`, `if-none-match` — and its restraint is what makes it safe to share between every +sidecar. **It strips cookies**, and this path is nothing but cookies. Loosening it for gitea would loosen +it for the wallet, so this one route forwards the request itself. Everything else still uses the shared +proxy. + ## What moved | Was | Is now | diff --git a/api/router.ts b/api/router.ts index 70bacd6..458521f 100644 --- a/api/router.ts +++ b/api/router.ts @@ -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; diff --git a/sidecar/index.ts b/sidecar/index.ts index 6887923..2dfea50 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -1,5 +1,6 @@ import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; import { createSidecarConnector } from '@@/sidecar/connect'; +import { rewriteHtml, rewriteLocation, rewriteSetCookie } from './rewrite'; import { getServiceConnection, saveServiceConnection, @@ -265,6 +266,34 @@ const server = Bun.serve({ out.delete('x-frame-options'); out.delete('content-security-policy'); for (const h of ['connection', 'keep-alive', 'transfer-encoding']) out.delete(h); + + // Where the browser thinks this is. Passed by the plugin's router rather than hardcoded — the + // sidecar has no business knowing how the platform routes to it, and `mountPrefix` is the only + // thing entitled to decide. + const base = req.headers.get('X-Officer-Frame-Base'); + const origins = [config.base]; + + if (base) { + const location = out.get('location'); + if (location) out.set('location', rewriteLocation(location, base, origins)); + + // Every Set-Cookie, not just the first: Gitea sets a session and a CSRF cookie together, and + // `Headers.get` would concatenate them into one broken value. + const cookies = (out as unknown as { getSetCookie?: () => string[] }).getSetCookie?.() ?? []; + if (cookies.length) { + out.delete('set-cookie'); + for (const c of cookies) out.append('set-cookie', rewriteSetCookie(c, base)); + } + + // HTML only. Rewriting JavaScript hits strings that are not URLs and corrupts the file — + // `appSubUrl`, patched below by the same pass, is what makes Gitea's own scripts correct. + if ((out.get('content-type') ?? '').includes('text/html')) { + const body = rewriteHtml(await upstream.text(), base, origins); + out.delete('content-length'); // the body just changed length + return new Response(body, { status: upstream.status, headers: out }); + } + } + return new Response(upstream.body, { status: upstream.status, headers: out }); } diff --git a/sidecar/rewrite.test.ts b/sidecar/rewrite.test.ts new file mode 100644 index 0000000..7119c6b --- /dev/null +++ b/sidecar/rewrite.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from 'bun:test'; +import { rewriteHtml, rewriteLocation, rewriteSetCookie, rewriteAttributes, rewriteAppSubUrl } from './rewrite'; + +const BASE = '/api/gitea/_web'; +const ORIGINS = ['http://localhost:3000', 'https://gitea.officer.dev']; + +// Every test here is a way the frame breaks in a manner that looks like Gitea being broken rather than +// the proxy. That is the whole difficulty: the page renders, and then a click, a login or an asset goes +// somewhere Officer knows nothing about. + +describe('attributes', () => { + test('root-absolute href, src and action are prefixed', () => { + const html = '
'; + const out = rewriteAttributes(html, BASE); + expect(out).toContain(`href="${BASE}/user/login"`); + expect(out).toContain(`src="${BASE}/assets/img/logo.svg"`); + expect(out).toContain(`action="${BASE}/repo/new"`); + }); + + test('a relative href is already correct and must not be touched', () => { + expect(rewriteAttributes('', BASE)).toBe(''); + }); + + test('mailto, data and protocol-relative are left alone', () => { + const html = ''; + expect(rewriteAppSubUrl(html, BASE)).toContain(`"appSubUrl":"${BASE}"`); + }); + + test('an existing subpath is replaced, not appended to', () => { + expect(rewriteAppSubUrl('{"appSubUrl":"/git"}', BASE)).toBe(`{"appSubUrl":"${BASE}"}`); + }); +}); + +describe('absolute URLs built from ROOT_URL', () => { + test('the upstream origin becomes the proxy base — these escape to a third-party origin otherwise', () => { + const out = rewriteHtml('', BASE, ORIGINS); + expect(out).toContain(`href="${BASE}/owner/repo"`); + expect(out).not.toContain('gitea.officer.dev'); + }); + + test('the loopback origin is rewritten too, since that is what the sidecar actually fetches', () => { + expect(rewriteHtml('', BASE, ORIGINS)).toContain(`${BASE}/explore`); + }); + + test('an unrelated host is left alone', () => { + const html = ''; + expect(rewriteHtml(html, BASE, ORIGINS)).toBe(html); + }); +}); + +describe('redirects — the most visible failure, because the frame just leaves', () => { + test('a root-absolute Location is prefixed', () => { + expect(rewriteLocation('/user/login', BASE, ORIGINS)).toBe(`${BASE}/user/login`); + }); + + test('a full URL on the upstream origin is brought back inside', () => { + expect(rewriteLocation('https://gitea.officer.dev/dashboard', BASE, ORIGINS)).toBe(`${BASE}/dashboard`); + }); + + test('an already-prefixed Location is not prefixed twice', () => { + expect(rewriteLocation(`${BASE}/dashboard`, BASE, ORIGINS)).toBe(`${BASE}/dashboard`); + }); + + test('an external redirect is left alone — OAuth goes where it goes', () => { + expect(rewriteLocation('https://accounts.google.com/o/oauth2/auth', BASE, ORIGINS)).toBe( + 'https://accounts.google.com/o/oauth2/auth', + ); + }); +}); + +describe('the origin Gitea declares about itself', () => { + test('is discovered from window.config, so ROOT_URL needs no second setting', () => { + // The sidecar only knows where it FETCHES from — usually loopback. The public origin lives in the + // page, and an unrewritten absolute link on it navigates the frame off to a third-party origin where + // the session cookie is dropped. + const html = '' + + 'go'; + const out = rewriteHtml(html, BASE, ['http://localhost:3000']); + expect(out).toContain(`href="${BASE}/owner/repo"`); + expect(out).not.toContain('git.example.com/owner'); + }); + + test('a page without one still rewrites what it was told about', () => { + expect(rewriteHtml('', BASE, ['http://localhost:3000'])).toContain( + `href="${BASE}/x"`, + ); + }); +}); + +describe('cookies', () => { + test('Path is narrowed to the frame, so Gitea session cookies stay out of the platform API', () => { + expect(rewriteSetCookie('i_like_gitea=abc; Path=/; HttpOnly', BASE)).toBe( + `i_like_gitea=abc; Path=${BASE}; HttpOnly`, + ); + }); + + test('a cookie with no Path gets one', () => { + expect(rewriteSetCookie('csrf=xyz; HttpOnly', BASE)).toBe(`csrf=xyz; HttpOnly; Path=${BASE}`); + }); + + test('Secure is left exactly as Gitea set it — adding it breaks http, removing it weakens https', () => { + expect(rewriteSetCookie('s=1; Path=/; Secure; SameSite=Lax', BASE)).toContain('Secure'); + expect(rewriteSetCookie('s=1; Path=/', BASE)).not.toContain('Secure'); + }); +}); diff --git a/sidecar/rewrite.ts b/sidecar/rewrite.ts new file mode 100644 index 0000000..d14faf7 --- /dev/null +++ b/sidecar/rewrite.ts @@ -0,0 +1,155 @@ +// Making Gitea, which believes it lives at `/`, work when it is served under `/api/gitea/_web`. +// +// ── Why this is needed at all ── +// +// Gitea is server-rendered. Its pages are full of ROOT-ABSOLUTE links — `/user/login`, `/owner/repo`, +// `/assets/index.js` — and inside the frame those resolve against OFFICER'S origin. The first click +// leaves the proxy and lands on Officer's SPA, which knows nothing about them. The page renders +// perfectly and then falls apart on contact. +// +// ── Four different places a URL hides ── +// +// 1. Attributes in the HTML — `href`, `src`, `action`. Ordinary rewriting. +// 2. `window.config.appSubUrl`, which Gitea's own client JS builds URLs from. Setting this is the single +// highest-value line here: Gitea already supports running under a subpath, so telling it the subpath +// makes its JavaScript correct by itself rather than by interception. +// 3. `Location` headers. Gitea redirects constantly — after login, after every form post — and an +// unrewritten one navigates the frame out of the proxy. +// 4. Absolute URLs built from ROOT_URL, which point at the real host. Those escape to a third-party +// origin where the session cookie is dropped, so they are the most damaging of the four. +// +// ── What is deliberately NOT rewritten ── +// +// JavaScript bodies. A blanket rewrite of every absolute path inside a script hits strings that are not +// URLs — a regex, a route table, an i18n key — and corrupts the file it is trying to fix. `appSubUrl` +// removes the need. +// +// Clone URLs. `git clone` must keep working against the real host, and a clone URL rewritten into the +// proxy path is a URL that fails for everyone who copies it. They are left alone deliberately, which is +// why `rewriteAbsolute` only touches the HTML around them and never `.git` endpoints. + +/** Collapse `..` before prefixing, or the browser normalises the prefix straight back off. */ +const normalise = (path: string): string => { + try { + const url = new URL(path, 'http://placeholder/'); + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return path; + } +}; + +const prefixed = (base: string, path: string): string => `${base}${normalise(path)}`; + +/** + * Paths that must NOT be prefixed. + * + * `//host` is protocol-relative and belongs to someone else. Everything else here is a scheme, and a + * `mailto:` or `data:` that gained a path prefix would simply be broken. + */ +const isForeign = (path: string): boolean => path.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(path); + +/** + * Rewrite root-absolute `href`/`src`/`action` attributes, and `url()` in inline CSS. + * + * Skips anything already under `base`. `rewriteAbsolute` runs first and has already turned + * `https://gitea…/owner/repo` into `/owner/repo`, and prefixing that a second time produces + * `/owner/repo` — a 404 that looks nothing like its cause. + */ +export function rewriteAttributes(html: string, base: string): string { + const skip = (path: string) => isForeign(path) || path === base || path.startsWith(`${base}/`); + return html + .replace(/(href|src|action)=("|')(\/[^"']*)\2/gi, (match, attr: string, quote: string, path: string) => + skip(path) ? match : `${attr}=${quote}${prefixed(base, path)}${quote}`, + ) + .replace(/url\((\/[^)"']*)\)/g, (match, path: string) => (skip(path) ? match : `url(${prefixed(base, path)})`)); +} + +/** + * Point Gitea's own JavaScript at the subpath. + * + * Gitea emits `window.config = {…,"appSubUrl":"",…}` and its client code prefixes every URL it builds + * with that value — it is how Gitea supports being hosted under a subdirectory. Filling it in is + * therefore not a hack around Gitea, it is the mechanism Gitea provides. + * + * Matched narrowly on the key so a repository whose CONTENT contains the string is untouched. + */ +export function rewriteAppSubUrl(html: string, base: string): string { + return html.replace(/"appSubUrl"\s*:\s*"[^"]*"/g, `"appSubUrl":"${base}"`); +} + +/** + * Rewrite absolute URLs that point at the upstream host. + * + * These come from `ROOT_URL`, and they are the worst kind: they navigate the frame to a third-party + * origin, where the session cookie is dropped and the user is silently signed out. + * + * `upstreamOrigin` is where the sidecar actually fetches from — normally `http://localhost:3000`. A Gitea + * configured with a public `ROOT_URL` emits THAT instead, so both are rewritten when they differ. + */ +export function rewriteAbsolute(html: string, base: string, origins: string[]): string { + let out = html; + for (const origin of origins.filter(Boolean)) { + // Escaped, because an origin is user-supplied config and a stray `.` or `?` would match too much. + const escaped = origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + out = out.replace(new RegExp(`${escaped}(?=/|"|'|\\s|$)`, 'g'), base); + } + return out; +} + +/** + * The public URL Gitea believes it has, read out of its own page. + * + * Gitea emits `window.config = {"appUrl":"https://gitea.example/",…}`, built from `ROOT_URL`. That is the + * origin its absolute links use, and it is not otherwise known here: the sidecar only knows where it + * FETCHES from, which is usually loopback. Taking it from the page means no second setting to configure + * and no way for the two to disagree. + */ +export function appUrlOrigin(html: string): string | null { + const match = html.match(/"appUrl"\s*:\s*"([^"]+)"/); + if (!match) return null; + try { + return new URL(match[1]!).origin; + } catch { + return null; + } +} + +/** Everything, in the order that makes each step see what the last one left. */ +export function rewriteHtml(html: string, base: string, origins: string[]): string { + // Gitea's own declared origin joins the list, so a ROOT_URL nobody told the platform about is still + // rewritten. Absolute first: it turns `https://gitea…/user/login` into `/user/login`, which the + // attribute pass then skips as already-prefixed. + const declared = appUrlOrigin(html); + const all = declared ? [...origins, declared] : origins; + return rewriteAttributes(rewriteAppSubUrl(rewriteAbsolute(html, base, all), base), base); +} + +/** + * Rewrite a `Location` header so a redirect stays inside the frame. + * + * Handles both shapes Gitea emits: a root-absolute path, and a full URL built from ROOT_URL. A redirect + * that escapes is the single most visible failure of a proxy like this — the frame simply leaves. + */ +export function rewriteLocation(location: string, base: string, origins: string[]): string { + for (const origin of origins.filter(Boolean)) { + if (location.startsWith(origin)) return `${base}${normalise(location.slice(origin.length) || '/')}`; + } + if (location.startsWith('/') && !location.startsWith('//') && !location.startsWith(base)) { + return prefixed(base, location); + } + return location; +} + +/** + * Scope Gitea's cookies to the frame. + * + * Gitea sets `Path=/`, which on this origin means its session cookie is attached to every request to the + * platform — including the API, where it is noise at best. Narrowing it keeps Gitea's session inside + * Gitea's mount. + * + * `Secure` is left exactly as Gitea set it. Adding it would break a plain-http install; removing it would + * weaken one that meant it. + */ +export function rewriteSetCookie(value: string, base: string): string { + return /;\s*path=/i.test(value) ? value.replace(/;\s*path=[^;]*/i, `; Path=${base}`) : `${value}; Path=${base}`; +}