From e3de7f7441e2e4b8e0fcd34e78c44a7c91a98ecd Mon Sep 17 00:00:00 2001 From: Shae Feltz Date: Wed, 9 Sep 2026 19:39:50 -0500 Subject: [PATCH 1/4] fix: serve public assets on self-hosted deployments When NEXT_PUBLIC_IS_CAP is not "true", proxy.ts redirects every path outside its allowlist to /login, and the matcher only exempts favicon.ico, robots.txt and sitemap.xml. Every other file under apps/web/public therefore 307s to /login on a self-hosted instance and renders broken. Let any path with a file extension through the self-hosted redirect. Such a path is either a static file or a 404, never a page, so the login gate has nothing to protect there, and new asset types work without a matcher edit. --- .../__tests__/unit/proxy-self-hosted.test.ts | 54 ++++++++++++++++++- apps/web/proxy.ts | 4 ++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/apps/web/__tests__/unit/proxy-self-hosted.test.ts b/apps/web/__tests__/unit/proxy-self-hosted.test.ts index ad20bffdb38..e1d3019fefe 100644 --- a/apps/web/__tests__/unit/proxy-self-hosted.test.ts +++ b/apps/web/__tests__/unit/proxy-self-hosted.test.ts @@ -1,10 +1,62 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { NextRequest } from "next/server"; +import { describe, expect, it, vi } from "vitest"; +import { proxy } from "../../proxy"; + +vi.mock("@cap/database", () => ({ + db: () => { + throw new Error("Database should not be reached on self-hosted routes"); + }, +})); + +vi.mock("@cap/database/schema", () => ({ organizations: {} })); + +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_IS_CAP: "false" }, + serverEnv: () => ({ + WEB_URL: "https://cap.example.com", + VERCEL_URL_HOST: undefined, + VERCEL_BRANCH_URL_HOST: undefined, + VERCEL_PROJECT_PRODUCTION_URL_HOST: undefined, + }), +})); + +const request = (path: string) => + proxy(new NextRequest(`https://cap.example.com${path}`)); describe("self-hosted proxy routes", () => { it("allows browser-based CLI authorization pages", () => { const source = readFileSync(join(process.cwd(), "proxy.ts"), "utf8"); expect(source).toContain('path.startsWith("/cli/")'); }); + + it.each([ + "/logos/browsers/google-chrome.svg", + "/illustrations/mask-bg.webp", + "/sounds/recording-start.mp3", + "/rive/main.riv", + "/fonts/Inter.woff2", + ])("serves the public asset %s instead of redirecting", async (path) => { + const response = await request(path); + + expect(response.status).toBe(200); + expect(response.headers.get("location")).toBeNull(); + }); + + it("still redirects unauthenticated page routes to /login", async () => { + const response = await request("/pricing"); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "https://cap.example.com/login", + ); + }); + + it("does not treat a share link as a static asset", async () => { + const response = await request("/s/video123"); + + expect(response.status).toBe(200); + expect(response.headers.get("location")).toBeNull(); + }); }); diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index d5983c84dd6..85d40460ea9 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -54,8 +54,12 @@ export async function proxy(request: NextRequest) { const hostname = url.hostname; if (buildEnv.NEXT_PUBLIC_IS_CAP !== "true") { + // Files under public/ have no route of their own, so without this every + // on a self-hosted instance redirects to /login. + const isStaticAsset = /\.[a-z0-9]+$/i.test(path); if ( !( + isStaticAsset || path.startsWith("/s/") || path.startsWith("/c/") || path.startsWith("/cli/") || From c0b8a60ac2b8eb2dacc29e002625b82bb2227434 Mon Sep 17 00:00:00 2001 From: Shae Feltz Date: Wed, 9 Sep 2026 19:43:40 -0500 Subject: [PATCH 2/4] fix: only bypass the self-hosted redirect for real files under public/ An extension alone is not proof of a static asset: install-cli.sh and the docs catch-all are extension-suffixed routes. Resolve the path under public/ and require it to be an existing file, rejecting traversal out of the directory. --- .../__tests__/unit/proxy-self-hosted.test.ts | 53 ++++++++++++------- apps/web/proxy.ts | 19 ++++++- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/apps/web/__tests__/unit/proxy-self-hosted.test.ts b/apps/web/__tests__/unit/proxy-self-hosted.test.ts index e1d3019fefe..f20abc7d95a 100644 --- a/apps/web/__tests__/unit/proxy-self-hosted.test.ts +++ b/apps/web/__tests__/unit/proxy-self-hosted.test.ts @@ -25,6 +25,20 @@ vi.mock("@cap/env", () => ({ const request = (path: string) => proxy(new NextRequest(`https://cap.example.com${path}`)); +const expectServed = async (path: string) => { + const response = await request(path); + expect(response.status).toBe(200); + expect(response.headers.get("location")).toBeNull(); +}; + +const expectLoginRedirect = async (path: string) => { + const response = await request(path); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe( + "https://cap.example.com/login", + ); +}; + describe("self-hosted proxy routes", () => { it("allows browser-based CLI authorization pages", () => { const source = readFileSync(join(process.cwd(), "proxy.ts"), "utf8"); @@ -33,30 +47,29 @@ describe("self-hosted proxy routes", () => { it.each([ "/logos/browsers/google-chrome.svg", - "/illustrations/mask-bg.webp", - "/sounds/recording-start.mp3", + "/illustrations/app.webp", + "/sounds/start-recording.ogg", "/rive/main.riv", - "/fonts/Inter.woff2", - ])("serves the public asset %s instead of redirecting", async (path) => { - const response = await request(path); + "/fonts/Geist-Regular.woff2", + "/site.webmanifest", + ])("serves the public asset %s instead of redirecting", (path) => + expectServed(path), + ); - expect(response.status).toBe(200); - expect(response.headers.get("location")).toBeNull(); - }); + it("still redirects page routes to /login", () => + expectLoginRedirect("/pricing")); - it("still redirects unauthenticated page routes to /login", async () => { - const response = await request("/pricing"); + it("still redirects extension-suffixed route handlers to /login", () => + expectLoginRedirect("/install-cli.sh")); - expect(response.status).toBe(307); - expect(response.headers.get("location")).toBe( - "https://cap.example.com/login", - ); - }); + it("does not let a missing file through", () => + expectLoginRedirect("/logos/missing.svg")); - it("does not treat a share link as a static asset", async () => { - const response = await request("/s/video123"); + it("does not let a directory through", () => expectLoginRedirect("/logos")); - expect(response.status).toBe(200); - expect(response.headers.get("location")).toBeNull(); - }); + it("rejects path traversal out of public/", () => + expectLoginRedirect("/logos/..%2F..%2Fproxy.ts")); + + it("does not treat a share link as an asset", () => + expectServed("/s/video123")); }); diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 85d40460ea9..be53d84c3ae 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -1,3 +1,5 @@ +import { statSync } from "node:fs"; +import { resolve, sep } from "node:path"; import { db } from "@cap/database"; import { organizations } from "@cap/database/schema"; import { buildEnv, serverEnv } from "@cap/env"; @@ -11,6 +13,20 @@ const addHttps = (s?: string) => { return `https://${s}`; }; +const publicDir = resolve(process.cwd(), "public"); + +const isPublicAsset = (path: string) => { + let decoded: string; + try { + decoded = decodeURIComponent(path); + } catch { + return false; + } + const file = resolve(publicDir, `.${decoded}`); + if (!file.startsWith(`${publicDir}${sep}`)) return false; + return statSync(file, { throwIfNoEntry: false })?.isFile() ?? false; +}; + const mainOrigins = [ "https://cap.so", "https://cap.link", @@ -56,10 +72,9 @@ export async function proxy(request: NextRequest) { if (buildEnv.NEXT_PUBLIC_IS_CAP !== "true") { // Files under public/ have no route of their own, so without this every // on a self-hosted instance redirects to /login. - const isStaticAsset = /\.[a-z0-9]+$/i.test(path); if ( !( - isStaticAsset || + isPublicAsset(path) || path.startsWith("/s/") || path.startsWith("/c/") || path.startsWith("/cli/") || From 89902a7d4a11a0333e03fc0e25cd27d87a122845 Mon Sep 17 00:00:00 2001 From: Shae Feltz Date: Wed, 9 Sep 2026 19:48:23 -0500 Subject: [PATCH 3/4] test: cover the extensionless .well-known/atproto-did public file --- apps/web/__tests__/unit/proxy-self-hosted.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/__tests__/unit/proxy-self-hosted.test.ts b/apps/web/__tests__/unit/proxy-self-hosted.test.ts index f20abc7d95a..7026bea4686 100644 --- a/apps/web/__tests__/unit/proxy-self-hosted.test.ts +++ b/apps/web/__tests__/unit/proxy-self-hosted.test.ts @@ -52,6 +52,7 @@ describe("self-hosted proxy routes", () => { "/rive/main.riv", "/fonts/Geist-Regular.woff2", "/site.webmanifest", + "/.well-known/atproto-did", ])("serves the public asset %s instead of redirecting", (path) => expectServed(path), ); From c17e012b4403db1fedc778b749076dc45e385bd7 Mon Sep 17 00:00:00 2001 From: Shae Feltz Date: Wed, 9 Sep 2026 19:51:24 -0500 Subject: [PATCH 4/4] fix: treat filesystem lookup failures as not a public asset statSync still throws for ENOTDIR, ENAMETOOLONG and an encoded NUL byte, and that escaped proxy() as a 500 instead of the /login redirect. --- apps/web/__tests__/unit/proxy-self-hosted.test.ts | 5 +++++ apps/web/proxy.ts | 8 +++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/__tests__/unit/proxy-self-hosted.test.ts b/apps/web/__tests__/unit/proxy-self-hosted.test.ts index 7026bea4686..40b5cdf3885 100644 --- a/apps/web/__tests__/unit/proxy-self-hosted.test.ts +++ b/apps/web/__tests__/unit/proxy-self-hosted.test.ts @@ -71,6 +71,11 @@ describe("self-hosted proxy routes", () => { it("rejects path traversal out of public/", () => expectLoginRedirect("/logos/..%2F..%2Fproxy.ts")); + it.each(["/%00", "/favicon.ico/nested.svg", `/${"a".repeat(5000)}.svg`])( + "treats a filesystem lookup failure for %s as not an asset", + (path) => expectLoginRedirect(path), + ); + it("does not treat a share link as an asset", () => expectServed("/s/video123")); }); diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index be53d84c3ae..02eadcecf44 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -16,15 +16,13 @@ const addHttps = (s?: string) => { const publicDir = resolve(process.cwd(), "public"); const isPublicAsset = (path: string) => { - let decoded: string; try { - decoded = decodeURIComponent(path); + const file = resolve(publicDir, `.${decodeURIComponent(path)}`); + if (!file.startsWith(`${publicDir}${sep}`)) return false; + return statSync(file, { throwIfNoEntry: false })?.isFile() ?? false; } catch { return false; } - const file = resolve(publicDir, `.${decoded}`); - if (!file.startsWith(`${publicDir}${sep}`)) return false; - return statSync(file, { throwIfNoEntry: false })?.isFile() ?? false; }; const mainOrigins = [