Gitea's inline script carries `appUrl: 'https:\/\/gitea.example.com\/'`, and rewriteAbsolute only matched the literal `https://` form. So appUrl survived pointing at the real host while every visible link was rewritten correctly. The failure was quiet rather than obvious: the page rendered, and only the URLs Gitea's JS BUILDS from appUrl went wrong — cross-origin to the real host, answered with its login HTML. The heatmap reported "Unexpected token '<'" while parsing JSON, and the event worker failed the same way. Third instance of the same class today, after the asset paths and appSubUrl: a URL hiding in a shape the rewriter did not think to look for. All three were found by loading the page, none by the tests — and all three now have a test written against a live sample rather than an imagined one.
167 lines
7.8 KiB
TypeScript
167 lines
7.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"`);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|