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 = '