diff --git a/apps/web/__tests__/unit/proxy-self-hosted.test.ts b/apps/web/__tests__/unit/proxy-self-hosted.test.ts index ad20bffdb3..40b5cdf388 100644 --- a/apps/web/__tests__/unit/proxy-self-hosted.test.ts +++ b/apps/web/__tests__/unit/proxy-self-hosted.test.ts @@ -1,10 +1,81 @@ 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}`)); + +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"); expect(source).toContain('path.startsWith("/cli/")'); }); + + it.each([ + "/logos/browsers/google-chrome.svg", + "/illustrations/app.webp", + "/sounds/start-recording.ogg", + "/rive/main.riv", + "/fonts/Geist-Regular.woff2", + "/site.webmanifest", + "/.well-known/atproto-did", + ])("serves the public asset %s instead of redirecting", (path) => + expectServed(path), + ); + + it("still redirects page routes to /login", () => + expectLoginRedirect("/pricing")); + + it("still redirects extension-suffixed route handlers to /login", () => + expectLoginRedirect("/install-cli.sh")); + + it("does not let a missing file through", () => + expectLoginRedirect("/logos/missing.svg")); + + it("does not let a directory through", () => expectLoginRedirect("/logos")); + + 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 d5983c84dd..02eadcecf4 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,18 @@ const addHttps = (s?: string) => { return `https://${s}`; }; +const publicDir = resolve(process.cwd(), "public"); + +const isPublicAsset = (path: string) => { + try { + const file = resolve(publicDir, `.${decodeURIComponent(path)}`); + if (!file.startsWith(`${publicDir}${sep}`)) return false; + return statSync(file, { throwIfNoEntry: false })?.isFile() ?? false; + } catch { + return false; + } +}; + const mainOrigins = [ "https://cap.so", "https://cap.link", @@ -54,8 +68,11 @@ 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. if ( !( + isPublicAsset(path) || path.startsWith("/s/") || path.startsWith("/c/") || path.startsWith("/cli/") ||