Skip to content
Open
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
73 changes: 72 additions & 1 deletion apps/web/__tests__/unit/proxy-self-hosted.test.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
17 changes: 17 additions & 0 deletions apps/web/proxy.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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",
Expand Down Expand Up @@ -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
// <img src="/logos/..."> on a self-hosted instance redirects to /login.
if (
!(
isPublicAsset(path) ||
path.startsWith("/s/") ||
path.startsWith("/c/") ||
path.startsWith("/cli/") ||
Expand Down