Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions public/service-worker.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down
50 changes: 46 additions & 4 deletions src/node/orpc/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`<base href="${baseHref}"`);
expect(redirectIndex).toBeGreaterThanOrEqual(0);
expect(baseIndex).toBeGreaterThan(redirectIndex);
}

async function createStaticTestServer(
options: {
files?: Record<string, string>;
Expand Down Expand Up @@ -231,7 +238,7 @@ describe("createOrpcServer", () => {
expect(uiRes.status).toBe(200);
const uiText = await uiRes.text();
expect(uiText).toContain("mux");
expect(uiText).toContain('<base href="/"');
expect(uiText).toContain('<base href="./../../"');

const apiRes = await fetch(`${server.baseUrl}/api/not-a-real-route`);
expect(apiRes.status).toBe(404);
Expand All @@ -248,7 +255,23 @@ describe("createOrpcServer", () => {
const rootRes = await fetch(`${server.baseUrl}/`);
expect(rootRes.status).toBe(200);
const rootHtml = await rootRes.text();
expect(countOccurrences(rootHtml, '<base href="/" />')).toBe(1);
expect(countOccurrences(rootHtml, '<base href="./" />')).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('<base href="./../../" />');
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('<base href="./../../../" />');

const forwardedPrefixRes = await fetch(`${server.baseUrl}/some/spa/route`, {
headers: { "X-Forwarded-Prefix": APP_PROXY_BASE_PATH },
Expand Down Expand Up @@ -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(`<base href="${APP_PROXY_BASE_PATH}/" />`);
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('<base href="/@admin/mux-workspace-095801.main/apps/mux/" />');
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(`<base href="${APP_PROXY_BASE_PATH}/" />`);
Expand All @@ -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('<base href="/" />');
expect(await falsePositiveRes.text()).toContain('<base href="./../../" />');
} finally {
await close();
}
Expand Down Expand Up @@ -477,8 +517,10 @@ describe("createOrpcServer", () => {
expect(uiRes.status).toBe(200);
const uiText = await uiRes.text();

expect(rootHtml).toContain('<base href="./"');
expect(uiText).toContain('<base href="./../../"');

for (const html of [rootHtml, uiText]) {
expect(html).toContain('<base href="/"');
expect(html).toContain("window.__MUX_PROXY_URI_TEMPLATE__ =");
expect(html).toContain(
'window.__MUX_PROXY_URI_TEMPLATE__ = "https://proxy-{{port}}.example.test/path\\u003c/script>";'
Expand Down
45 changes: 41 additions & 4 deletions src/node/orpc/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,30 @@ function escapeHtmlAttribute(value: string): string {
.replaceAll(">", "&gt;");
}

function injectBaseHref(indexHtml: string, baseHref: string): string {
const SLASHLESS_ROOT_REDIRECT_SCRIPT =
'<script>(()=>{const pathname=location.pathname;if(pathname.startsWith("//")||pathname.endsWith("/"))return;location.replace(location.origin+pathname+"/"+location.search+location.hash);})();</script>';

function injectBaseHref(
indexHtml: string,
baseHref: string,
options: { includeSlashlessRootRedirect?: boolean } = {}
): string {
// Avoid double-injecting if the HTML already has a base tag.
if (/<base\b/i.test(indexHtml)) {
return indexHtml;
}

// The redirect must precede the base tag so slashless app-root URLs become
// directory URLs before the browser resolves relative assets.
const slashlessRootRedirect = options.includeSlashlessRootRedirect
? `\n ${SLASHLESS_ROOT_REDIRECT_SCRIPT}`
: "";

// Insert immediately after the opening <head> tag (supports <head> and <head ...attrs>).
const escapedBaseHref = escapeHtmlAttribute(baseHref);
return indexHtml.replace(
/<head[^>]*>/i,
(match) => `${match}\n <base href="${escapedBaseHref}" />`
(match) => `${match}${slashlessRootRedirect}\n <base href="${escapedBaseHref}" />`
);
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
Loading