rewrite asset paths baked into the page, including JSON-escaped ones

Gitea inlines a script naming its own worker:
'\/assets\/js\/eventsource.sharedworker.…js'. That is not an attribute and its
slashes are backslash-escaped, so neither the attribute rewrite nor appSubUrl
saw it. Unprefixed, the browser resolved it against the PLATFORM's origin, got
the SPA's index.html, and reported "Uncaught SyntaxError: Unexpected token '<'"
from a file that appeared to load — along with a dead SharedWorker and a
heatmap parsing HTML as JSON.

appSubUrl covers everything Gitea's code BUILDS at runtime. This covers what is
already in the page before that code runs.

Narrow on purpose, like dev-proxy's /_bun/ rule: a blanket rewrite of absolute
paths inside JavaScript corrupts strings that are not URLs. /assets/ is Gitea's
own asset root and specific enough to match safely.

The escaping has to match the string it lands in — \/api\/gitea\/_web, not
\\/api — or the JS stops parsing. Both forms are tested against a live sample.
This commit is contained in:
officer
2026-08-19 09:28:04 +00:00
parent 01e4210c62
commit 47e3983f7e
2 changed files with 54 additions and 2 deletions
+30 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, test } from 'bun:test';
import { rewriteHtml, rewriteLocation, rewriteSetCookie, rewriteAttributes, rewriteAppSubUrl } from './rewrite';
import {
rewriteHtml,
rewriteLocation,
rewriteSetCookie,
rewriteAttributes,
rewriteAppSubUrl,
rewriteAssetPaths,
} from './rewrite';
const BASE = '/api/gitea/_web';
const ORIGINS = ['http://localhost:3000', 'https://gitea.officer.dev'];
@@ -90,6 +97,28 @@ describe('redirects — the most visible failure, because the frame just leaves'
});
});
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
+24 -1
View File
@@ -103,6 +103,29 @@ export function rewriteAbsolute(html: string, base: string, origins: string[]):
return out;
}
/**
* Asset paths embedded in JavaScript, including JSON-escaped ones.
*
* Gitea inlines a script that names its own worker: `'\\/assets\\/js\\/eventsource.sharedworker.…js'`.
* That is not an attribute and its slashes are backslash-escaped, so neither of the rewrites above sees
* it. Unprefixed, the browser resolves it against the PLATFORM's origin, gets the SPA's index.html, and
* reports `Uncaught SyntaxError: Unexpected token '<'` from a file that looks like it loaded.
*
* NARROW on purpose, like dev-proxy's `/_bun/` rule. A blanket rewrite of absolute paths inside
* JavaScript corrupts strings that are not URLs; `/assets/` is Gitea's own asset root and specific
* enough to match safely.
*
* `appSubUrl` covers everything Gitea's code BUILDS at runtime. This covers what is already baked into
* the page before that code runs.
*/
export function rewriteAssetPaths(html: string, base: string): string {
// `/api/gitea/_web` becomes `\/api\/gitea\/_web`, matching the escaping of the string it is going into.
const escapedBase = base.split('/').join('\\/');
return html
.replace(/\\\/assets\\\//g, `${escapedBase}\\/assets\\/`)
.replace(/(["'`])\/assets\//g, (_m, q: string) => `${q}${base}/assets/`);
}
/**
* The public URL Gitea believes it has, read out of its own page.
*
@@ -128,7 +151,7 @@ export function rewriteHtml(html: string, base: string, origins: string[]): stri
// 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);
return rewriteAssetPaths(rewriteAttributes(rewriteAppSubUrl(rewriteAbsolute(html, base, all), base), base), base);
}
/**