diff --git a/public/service-worker.js b/public/service-worker.js index 5bc7122d68..49c99e6684 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,6 +1,6 @@ // mux Service Worker for PWA support -const CACHE_NAME = "mux-v1"; -const urlsToCache = ["/", "/index.html"]; +const CACHE_NAME = "mux-v2"; +const urlsToCache = ["./", "./index.html"]; // Install event - cache core assets self.addEventListener("install", (event) => { diff --git a/src/node/orpc/server.test.ts b/src/node/orpc/server.test.ts index 03057bb92d..10eeae2d4c 100644 --- a/src/node/orpc/server.test.ts +++ b/src/node/orpc/server.test.ts @@ -155,6 +155,13 @@ function countOccurrences(value: string, needle: string): number { return value.split(needle).length - 1; } +function expectSlashlessRootRedirectBeforeBase(html: string, baseHref: string): void { + const redirectIndex = html.indexOf("location.replace(location.origin+pathname"); + const baseIndex = html.indexOf(`; @@ -231,7 +238,7 @@ describe("createOrpcServer", () => { expect(uiRes.status).toBe(200); const uiText = await uiRes.text(); expect(uiText).toContain("mux"); - expect(uiText).toContain(' { const rootRes = await fetch(`${server.baseUrl}/`); expect(rootRes.status).toBe(200); const rootHtml = await rootRes.text(); - expect(countOccurrences(rootHtml, '')).toBe(1); + expect(countOccurrences(rootHtml, '')).toBe(1); + expectSlashlessRootRedirectBeforeBase(rootHtml, "./"); + + const doubleSlashRes = await fetch(`${server.baseUrl}//attacker.example`); + expect(doubleSlashRes.status).toBe(200); + const doubleSlashHtml = await doubleSlashRes.text(); + expect(doubleSlashHtml).not.toContain("location.replace(location.origin+pathname"); + + const deepRouteRes = await fetch(`${server.baseUrl}/some/spa/route`); + expect(deepRouteRes.status).toBe(200); + const deepRouteHtml = await deepRouteRes.text(); + expect(deepRouteHtml).toContain(''); + expect(deepRouteHtml).not.toContain("location.replace(location.pathname"); + + const directoryRouteRes = await fetch(`${server.baseUrl}/some/spa/route/`); + expect(directoryRouteRes.status).toBe(200); + expect(await directoryRouteRes.text()).toContain(''); const forwardedPrefixRes = await fetch(`${server.baseUrl}/some/spa/route`, { headers: { "X-Forwarded-Prefix": APP_PROXY_BASE_PATH }, @@ -297,6 +320,23 @@ describe("createOrpcServer", () => { expect(prefixedAssetRes.status).toBe(200); expect(await prefixedAssetRes.text()).toBe(await rootAssetRes.text()); + const prefixedRootRes = await fetch(`${server.baseUrl}${APP_PROXY_BASE_PATH}`); + expect(prefixedRootRes.status).toBe(200); + const prefixedRootHtml = await prefixedRootRes.text(); + expect(prefixedRootHtml).toContain(``); + expectSlashlessRootRedirectBeforeBase(prefixedRootHtml, `${APP_PROXY_BASE_PATH}/`); + + const coderUrlRes = await fetch( + `${server.baseUrl}/@admin/mux-workspace-095801.main/apps/mux/?token=redacted` + ); + expect(coderUrlRes.status).toBe(200); + const coderUrlHtml = await coderUrlRes.text(); + expect(coderUrlHtml).toContain(''); + expectSlashlessRootRedirectBeforeBase( + coderUrlHtml, + "/@admin/mux-workspace-095801.main/apps/mux/" + ); + const prefixedSpaRes = await fetch(`${server.baseUrl}${APP_PROXY_BASE_PATH}/settings`); expect(prefixedSpaRes.status).toBe(200); expect(await prefixedSpaRes.text()).toContain(``); @@ -323,7 +363,7 @@ describe("createOrpcServer", () => { const falsePositiveRes = await fetch(`${server.baseUrl}/projects/apps/other`); expect(falsePositiveRes.status).toBe(200); - expect(await falsePositiveRes.text()).toContain(''); + expect(await falsePositiveRes.text()).toContain(''); } finally { await close(); } @@ -477,8 +517,10 @@ describe("createOrpcServer", () => { expect(uiRes.status).toBe(200); const uiText = await uiRes.text(); + expect(rootHtml).toContain('";' diff --git a/src/node/orpc/server.ts b/src/node/orpc/server.ts index 61bc2fa98b..c879acf922 100644 --- a/src/node/orpc/server.ts +++ b/src/node/orpc/server.ts @@ -123,17 +123,30 @@ function escapeHtmlAttribute(value: string): string { .replaceAll(">", ">"); } -function injectBaseHref(indexHtml: string, baseHref: string): string { +const SLASHLESS_ROOT_REDIRECT_SCRIPT = + ''; + +function injectBaseHref( + indexHtml: string, + baseHref: string, + options: { includeSlashlessRootRedirect?: boolean } = {} +): string { // Avoid double-injecting if the HTML already has a base tag. if (/ tag (supports and ). const escapedBaseHref = escapeHtmlAttribute(baseHref); return indexHtml.replace( /]*>/i, - (match) => `${match}\n ` + (match) => `${match}${slashlessRootRedirect}\n ` ); } @@ -579,9 +592,31 @@ function getDirectAppProxyHandlerPrefix( : routePrefix; } +function getRoutePathnameForBaseHref(req: express.Request): string | null { + return getPathnameFromRequestUrl(req.url); +} + +function getRelativeBaseHrefFromRoutePathname(routePathname: string): string { + const pathname = routePathname.startsWith("/") ? routePathname : `/${routePathname}`; + const segments = pathname.split("/").slice(1); + const depth = Math.max(0, segments.length - 1); + return depth === 0 ? "./" : `./${"../".repeat(depth)}`; +} + +function shouldInjectSlashlessRootRedirect(req: express.Request): boolean { + return getRoutePathnameForBaseHref(req) === "/"; +} + function getPublicBaseHref(req: express.Request, res: express.Response): string { const publicBasePath = getPublicBasePathForRequest(req, res, { allowReferer: true }); - return publicBasePath === "/" ? "/" : `${publicBasePath}/`; + if (publicBasePath !== "/") { + return `${publicBasePath}/`; + } + + // User rationale: when a reverse proxy strips the app prefix without forwarding + // headers, a relative climb still lets the browser resolve assets from the + // public app root. Root-hosted deep links resolve correctly too. + return getRelativeBaseHrefFromRoutePathname(getRoutePathnameForBaseHref(req) ?? "/"); } function getPublicAppRootPath(req: express.Request, res: express.Response): string { @@ -1545,7 +1580,9 @@ export async function createOrpcServer({ if (rawSpaIndexHtml !== null) { const spaIndexHtml = injectProxyUriTemplate( - injectBaseHref(rawSpaIndexHtml, getPublicBaseHref(req, res)), + injectBaseHref(rawSpaIndexHtml, getPublicBaseHref(req, res), { + includeSlashlessRootRedirect: shouldInjectSlashlessRootRedirect(req), + }), getBrowserProxyUriTemplate() ); varyPublicBasePathHeaders(res);