Skip to content

Commit 775c3d7

Browse files
authored
🤖 fix: harden path-app shell loading (#3195)
> Mux worked on behalf of Mike. ## Summary Absorbs the small path-app hardening pieces from #3184 on top of the merged #3194 implementation. This keeps the existing server-side prefix detection, direct prefixed route handling, Scalar rewriting, and terminal popout path support intact. ## Background #3194 made mux work under Coder path-app iframe URLs. #3184 had a few defensive ideas worth keeping as a focused follow-up: tolerate slashless app-root URLs, avoid stale service worker caches, and keep static shell assets resolving when a proxy strips the app prefix without sending forwarding headers. ## Implementation - Uses the detected public base path when available, preserving absolute prefixed base hrefs for Coder path-app requests. - Falls back to a relative base href climb when no public prefix is detected, so root hosting and stripped-prefix proxy paths both resolve static assets from the app root. - Injects a slashless app-root redirect script before the base tag only for app-root shell responses, with a same-origin redirect target and a double-slash path guard. - Bumps the service worker cache name to `mux-v2` and precaches relative shell URLs. - Adds regression coverage for the exact Coder path shape with a token query, for example `/@admin/<workspace>.main/apps/mux/?token=...`, plus a double-slash redirect guard. ## Validation - `bun test src/node/orpc/server.test.ts` - `bun test src/common/appProxyBasePath.test.ts src/browser/utils/backendBaseUrl.test.ts` - `make static-check` ## Risks Low to moderate. This touches SPA shell HTML generation, but keeps detected Coder path-app base href behavior unchanged and adds tests for relative deep-route fallback, slashless app-root handling, direct prefixed requests, double-slash paths, and false-positive `/apps/` paths. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$78.40`_ <!-- mux-attribution: model=openai:gpt-5.5 thinking=xhigh costs=78.40 -->
1 parent f2a029d commit 775c3d7

3 files changed

Lines changed: 89 additions & 10 deletions

File tree

public/service-worker.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// mux Service Worker for PWA support
2-
const CACHE_NAME = "mux-v1";
3-
const urlsToCache = ["/", "/index.html"];
2+
const CACHE_NAME = "mux-v2";
3+
const urlsToCache = ["./", "./index.html"];
44

55
// Install event - cache core assets
66
self.addEventListener("install", (event) => {

src/node/orpc/server.test.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,13 @@ function countOccurrences(value: string, needle: string): number {
155155
return value.split(needle).length - 1;
156156
}
157157

158+
function expectSlashlessRootRedirectBeforeBase(html: string, baseHref: string): void {
159+
const redirectIndex = html.indexOf("location.replace(location.origin+pathname");
160+
const baseIndex = html.indexOf(`<base href="${baseHref}"`);
161+
expect(redirectIndex).toBeGreaterThanOrEqual(0);
162+
expect(baseIndex).toBeGreaterThan(redirectIndex);
163+
}
164+
158165
async function createStaticTestServer(
159166
options: {
160167
files?: Record<string, string>;
@@ -231,7 +238,7 @@ describe("createOrpcServer", () => {
231238
expect(uiRes.status).toBe(200);
232239
const uiText = await uiRes.text();
233240
expect(uiText).toContain("mux");
234-
expect(uiText).toContain('<base href="/"');
241+
expect(uiText).toContain('<base href="./../../"');
235242

236243
const apiRes = await fetch(`${server.baseUrl}/api/not-a-real-route`);
237244
expect(apiRes.status).toBe(404);
@@ -248,7 +255,23 @@ describe("createOrpcServer", () => {
248255
const rootRes = await fetch(`${server.baseUrl}/`);
249256
expect(rootRes.status).toBe(200);
250257
const rootHtml = await rootRes.text();
251-
expect(countOccurrences(rootHtml, '<base href="/" />')).toBe(1);
258+
expect(countOccurrences(rootHtml, '<base href="./" />')).toBe(1);
259+
expectSlashlessRootRedirectBeforeBase(rootHtml, "./");
260+
261+
const doubleSlashRes = await fetch(`${server.baseUrl}//attacker.example`);
262+
expect(doubleSlashRes.status).toBe(200);
263+
const doubleSlashHtml = await doubleSlashRes.text();
264+
expect(doubleSlashHtml).not.toContain("location.replace(location.origin+pathname");
265+
266+
const deepRouteRes = await fetch(`${server.baseUrl}/some/spa/route`);
267+
expect(deepRouteRes.status).toBe(200);
268+
const deepRouteHtml = await deepRouteRes.text();
269+
expect(deepRouteHtml).toContain('<base href="./../../" />');
270+
expect(deepRouteHtml).not.toContain("location.replace(location.pathname");
271+
272+
const directoryRouteRes = await fetch(`${server.baseUrl}/some/spa/route/`);
273+
expect(directoryRouteRes.status).toBe(200);
274+
expect(await directoryRouteRes.text()).toContain('<base href="./../../../" />');
252275

253276
const forwardedPrefixRes = await fetch(`${server.baseUrl}/some/spa/route`, {
254277
headers: { "X-Forwarded-Prefix": APP_PROXY_BASE_PATH },
@@ -297,6 +320,23 @@ describe("createOrpcServer", () => {
297320
expect(prefixedAssetRes.status).toBe(200);
298321
expect(await prefixedAssetRes.text()).toBe(await rootAssetRes.text());
299322

323+
const prefixedRootRes = await fetch(`${server.baseUrl}${APP_PROXY_BASE_PATH}`);
324+
expect(prefixedRootRes.status).toBe(200);
325+
const prefixedRootHtml = await prefixedRootRes.text();
326+
expect(prefixedRootHtml).toContain(`<base href="${APP_PROXY_BASE_PATH}/" />`);
327+
expectSlashlessRootRedirectBeforeBase(prefixedRootHtml, `${APP_PROXY_BASE_PATH}/`);
328+
329+
const coderUrlRes = await fetch(
330+
`${server.baseUrl}/@admin/mux-workspace-095801.main/apps/mux/?token=redacted`
331+
);
332+
expect(coderUrlRes.status).toBe(200);
333+
const coderUrlHtml = await coderUrlRes.text();
334+
expect(coderUrlHtml).toContain('<base href="/@admin/mux-workspace-095801.main/apps/mux/" />');
335+
expectSlashlessRootRedirectBeforeBase(
336+
coderUrlHtml,
337+
"/@admin/mux-workspace-095801.main/apps/mux/"
338+
);
339+
300340
const prefixedSpaRes = await fetch(`${server.baseUrl}${APP_PROXY_BASE_PATH}/settings`);
301341
expect(prefixedSpaRes.status).toBe(200);
302342
expect(await prefixedSpaRes.text()).toContain(`<base href="${APP_PROXY_BASE_PATH}/" />`);
@@ -323,7 +363,7 @@ describe("createOrpcServer", () => {
323363

324364
const falsePositiveRes = await fetch(`${server.baseUrl}/projects/apps/other`);
325365
expect(falsePositiveRes.status).toBe(200);
326-
expect(await falsePositiveRes.text()).toContain('<base href="/" />');
366+
expect(await falsePositiveRes.text()).toContain('<base href="./../../" />');
327367
} finally {
328368
await close();
329369
}
@@ -477,8 +517,10 @@ describe("createOrpcServer", () => {
477517
expect(uiRes.status).toBe(200);
478518
const uiText = await uiRes.text();
479519

520+
expect(rootHtml).toContain('<base href="./"');
521+
expect(uiText).toContain('<base href="./../../"');
522+
480523
for (const html of [rootHtml, uiText]) {
481-
expect(html).toContain('<base href="/"');
482524
expect(html).toContain("window.__MUX_PROXY_URI_TEMPLATE__ =");
483525
expect(html).toContain(
484526
'window.__MUX_PROXY_URI_TEMPLATE__ = "https://proxy-{{port}}.example.test/path\\u003c/script>";'

src/node/orpc/server.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,17 +123,30 @@ function escapeHtmlAttribute(value: string): string {
123123
.replaceAll(">", "&gt;");
124124
}
125125

126-
function injectBaseHref(indexHtml: string, baseHref: string): string {
126+
const SLASHLESS_ROOT_REDIRECT_SCRIPT =
127+
'<script>(()=>{const pathname=location.pathname;if(pathname.startsWith("//")||pathname.endsWith("/"))return;location.replace(location.origin+pathname+"/"+location.search+location.hash);})();</script>';
128+
129+
function injectBaseHref(
130+
indexHtml: string,
131+
baseHref: string,
132+
options: { includeSlashlessRootRedirect?: boolean } = {}
133+
): string {
127134
// Avoid double-injecting if the HTML already has a base tag.
128135
if (/<base\b/i.test(indexHtml)) {
129136
return indexHtml;
130137
}
131138

139+
// The redirect must precede the base tag so slashless app-root URLs become
140+
// directory URLs before the browser resolves relative assets.
141+
const slashlessRootRedirect = options.includeSlashlessRootRedirect
142+
? `\n ${SLASHLESS_ROOT_REDIRECT_SCRIPT}`
143+
: "";
144+
132145
// Insert immediately after the opening <head> tag (supports <head> and <head ...attrs>).
133146
const escapedBaseHref = escapeHtmlAttribute(baseHref);
134147
return indexHtml.replace(
135148
/<head[^>]*>/i,
136-
(match) => `${match}\n <base href="${escapedBaseHref}" />`
149+
(match) => `${match}${slashlessRootRedirect}\n <base href="${escapedBaseHref}" />`
137150
);
138151
}
139152

@@ -579,9 +592,31 @@ function getDirectAppProxyHandlerPrefix(
579592
: routePrefix;
580593
}
581594

595+
function getRoutePathnameForBaseHref(req: express.Request): string | null {
596+
return getPathnameFromRequestUrl(req.url);
597+
}
598+
599+
function getRelativeBaseHrefFromRoutePathname(routePathname: string): string {
600+
const pathname = routePathname.startsWith("/") ? routePathname : `/${routePathname}`;
601+
const segments = pathname.split("/").slice(1);
602+
const depth = Math.max(0, segments.length - 1);
603+
return depth === 0 ? "./" : `./${"../".repeat(depth)}`;
604+
}
605+
606+
function shouldInjectSlashlessRootRedirect(req: express.Request): boolean {
607+
return getRoutePathnameForBaseHref(req) === "/";
608+
}
609+
582610
function getPublicBaseHref(req: express.Request, res: express.Response): string {
583611
const publicBasePath = getPublicBasePathForRequest(req, res, { allowReferer: true });
584-
return publicBasePath === "/" ? "/" : `${publicBasePath}/`;
612+
if (publicBasePath !== "/") {
613+
return `${publicBasePath}/`;
614+
}
615+
616+
// User rationale: when a reverse proxy strips the app prefix without forwarding
617+
// headers, a relative climb still lets the browser resolve assets from the
618+
// public app root. Root-hosted deep links resolve correctly too.
619+
return getRelativeBaseHrefFromRoutePathname(getRoutePathnameForBaseHref(req) ?? "/");
585620
}
586621

587622
function getPublicAppRootPath(req: express.Request, res: express.Response): string {
@@ -1545,7 +1580,9 @@ export async function createOrpcServer({
15451580

15461581
if (rawSpaIndexHtml !== null) {
15471582
const spaIndexHtml = injectProxyUriTemplate(
1548-
injectBaseHref(rawSpaIndexHtml, getPublicBaseHref(req, res)),
1583+
injectBaseHref(rawSpaIndexHtml, getPublicBaseHref(req, res), {
1584+
includeSlashlessRootRedirect: shouldInjectSlashlessRootRedirect(req),
1585+
}),
15491586
getBrowserProxyUriTemplate()
15501587
);
15511588
varyPublicBasePathHeaders(res);

0 commit comments

Comments
 (0)