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 <base>/path and the attribute pass then prefixed it again.
117 lines
5.3 KiB
TypeScript
117 lines
5.3 KiB
TypeScript
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 = '<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}"}`);
|
|
});
|
|
});
|
|
|
|
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('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('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');
|
|
});
|
|
});
|