Files
gitea/sidecar/rewrite.test.ts
officer fe67de0281 rewrite data-*url attributes, where Gitea hands URLs to its own JavaScript
The dashboard heatmap reads its endpoint from data-heatmap-url:
document.querySelector('#user-heatmap').getAttribute('data-heatmap-url').
Unprefixed it fetched from the PLATFORM's origin, got the SPA's index.html and
reported "Unexpected token '<'" while parsing JSON — the last error left after
everything visible was rendering correctly.

data-url carries the language switcher's links and had the same problem.

Matched on the `url` suffix rather than by listing attribute names, because the
list is Gitea's to change.

Found by reading Gitea's own bundle rather than by reasoning about what it
probably does — which is what the three previous attempts at this error were.
2026-08-19 09:38:08 +00:00

185 lines
8.8 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import {
rewriteHtml,
rewriteLocation,
rewriteSetCookie,
rewriteAttributes,
rewriteAppSubUrl,
rewriteAssetPaths,
} 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 = '<a href="/user/login"><img src="/assets/img/logo.svg"><form action="/repo/new">';
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"`);
});
// Gitea hands URLs to its own JavaScript through data attributes, which is where the dashboard heatmap
// gets its endpoint — `document.querySelector('#user-heatmap').getAttribute('data-heatmap-url')`.
// Unprefixed it fetched from the PLATFORM's origin, got the SPA's index.html, and reported
// "Unexpected token '<'" while parsing JSON. Found by reading Gitea's bundle, not by guessing.
test('data-*url attributes are rewritten — the heatmap reads its endpoint from one', () => {
const html = '<div id="user-heatmap" data-heatmap-url="/api/v1/users/x/heatmap">';
expect(rewriteAttributes(html, BASE)).toContain(`data-heatmap-url="${BASE}/api/v1/users/x/heatmap"`);
});
test('and plain data-url, which the language switcher uses', () => {
expect(rewriteAttributes('<a data-url="/?lang=en-US">', BASE)).toContain(`data-url="${BASE}/?lang=en-US"`);
});
test('a data attribute that is not a url is left alone', () => {
const html = '<div data-tooltip-content="/not a url">';
expect(rewriteAttributes(html, BASE)).toBe(html);
});
test('a relative href is already correct and must not be touched', () => {
expect(rewriteAttributes('<a href="settings">', BASE)).toBe('<a href="settings">');
});
test('mailto, data and protocol-relative are left alone', () => {
const html = '<a href="mailto:a@b.c"><img src="data:image/png;base64,AAA"><script src="//cdn.example/x.js">';
expect(rewriteAttributes(html, BASE)).toBe(html);
});
test('`..` is collapsed before prefixing, or the browser normalises the prefix back off', () => {
expect(rewriteAttributes('<img src="/../../assets/x.png">', BASE)).toContain(`src="${BASE}/assets/x.png"`);
});
});
describe('appSubUrl — the line that makes Gitea correct itself', () => {
test('is filled in, so Gitea client JS builds prefixed URLs natively', () => {
const html = '<script>window.config = {"appUrl":"http://localhost:3000/","appSubUrl":""};</script>';
expect(rewriteAppSubUrl(html, BASE)).toContain(`"appSubUrl":"${BASE}"`);
});
test('an existing subpath is replaced, not appended to', () => {
expect(rewriteAppSubUrl('{"appSubUrl":"/git"}', BASE)).toBe(`{"appSubUrl":"${BASE}"}`);
});
// The shape Gitea ACTUALLY emits — a JavaScript object literal, unquoted key, single quotes. The first
// version of this only matched the JSON form, so it silently did nothing on every real page while the
// attributes around it rewrote perfectly. Taken verbatim from a live response.
test('the JS object-literal form Gitea really emits', () => {
const real = "window.config = {\n appUrl: 'https://gitea.officer.dev/',\n appSubUrl: '',\n};";
const out = rewriteAppSubUrl(real, BASE);
expect(out).toContain(`appSubUrl: '${BASE}'`);
// The quote style is preserved, or the literal stops parsing.
expect(out).not.toContain(`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('<a href="https://gitea.officer.dev/owner/repo">', 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('<a href="http://localhost:3000/explore">', BASE, ORIGINS)).toContain(`${BASE}/explore`);
});
test('the JSON-ESCAPED form, which is how appUrl appears in Gitea\'s inline script', () => {
// `appUrl: 'https:\\/\\/gitea.officer.dev\\/'`. Missed at first, and the failure was quiet: the page
// rendered fine while every URL Gitea's JS built from appUrl went cross-origin to the real host and
// came back as its login HTML — so the heatmap reported "Unexpected token '<'" parsing JSON.
const real = String.raw`appUrl: 'https:\/\/gitea.officer.dev\/',`;
const out = rewriteHtml(real, BASE, ORIGINS);
expect(out).toContain(String.raw`'\/api\/gitea\/_web\/'`);
expect(out).not.toContain('gitea.officer.dev');
});
test('an unrelated host is left alone', () => {
const html = '<a href="https://github.com/some/repo">';
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('asset paths baked into the page before Gitea\'s own code runs', () => {
test('the JSON-escaped form — the SharedWorker URL that broke the dashboard', () => {
// Verbatim from a live page. Not an attribute, and its slashes are backslash-escaped, so neither the
// attribute rewrite nor appSubUrl touches it. Unprefixed, the browser resolves it against the
// PLATFORM's origin, gets the SPA's index.html, and reports `Unexpected token '<'` from a file that
// looks like it loaded.
const real = String.raw`'\/assets\/js\/eventsource.sharedworker.DV1Z2uJv.js',`;
const out = rewriteAssetPaths(real, BASE);
expect(out).toContain(String.raw`'\/api\/gitea\/_web\/assets\/js\/eventsource`);
// The escaping has to match the string it lands in, or the JS stops parsing.
expect(out).not.toContain(String.raw`\\/api`);
});
test('the plain quoted form too', () => {
expect(rewriteAssetPaths(`x = "/assets/img/logo.svg"`, BASE)).toContain(`"${BASE}/assets/img/logo.svg"`);
});
test('leaves a path that is not an asset root alone', () => {
expect(rewriteAssetPaths(`x = "/user/login"`, BASE)).toBe(`x = "/user/login"`);
});
});
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 = '<script>window.config={"appUrl":"https://git.example.com/","appSubUrl":""}</script>' +
'<a href="https://git.example.com/owner/repo">go</a>';
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('<a href="http://localhost:3000/x">', 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');
});
});