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}`; +}