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
103 changes: 101 additions & 2 deletions src/core/__tests__/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import YAML from "yaml";
import { hashTokenSync } from "../../mcp/config.ts";
import type { McpConfig } from "../../mcp/types.ts";
import { handleUiRequest, setPublicDir } from "../../ui/serve.ts";
import { startServer } from "../server.ts";

/**
Expand Down Expand Up @@ -92,4 +95,100 @@ describe("server routing", () => {
expect(body.agent).toBe("phantom");
});
});

// /public/* is the agent publishing surface: files under public/public/*
// on disk are served without auth so Googlebot and unauthenticated
// visitors can fetch them. These tests redirect publicDir at a tmp dir
// so they never mutate the repo's own public/ tree.
describe("GET /public/*", () => {
const realPublic = resolve(import.meta.dir, "../../../public");
let tmpDir: string;

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "phantom-public-"));
setPublicDir(tmpDir);
});

afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
setPublicDir(realPublic);
});

function write(rel: string, content: string): void {
const full = join(tmpDir, rel);
mkdirSync(full.substring(0, full.lastIndexOf("/")), { recursive: true });
writeFileSync(full, content, "utf-8");
}

test("GET /public/ serves public/public/index.html when present, no redirect to /ui/login", async () => {
write("public/index.html", "<!doctype html><title>Blog</title>");
const res = await fetch(`${baseUrl}/public/`, { redirect: "manual" });
expect(res.status).toBe(200);
expect(res.headers.get("Location")).toBeNull();
const body = await res.text();
expect(body).toContain("Blog");
});

test("GET /public/ returns 404 when index.html is missing, never 302", async () => {
const res = await fetch(`${baseUrl}/public/`, { redirect: "manual" });
expect(res.status).toBe(404);
expect(res.headers.get("Location")).toBeNull();
});

test("GET /public/blog/foo.html serves without cookie", async () => {
write("public/blog/foo.html", "<!doctype html><title>Post</title>");
const res = await fetch(`${baseUrl}/public/blog/foo.html`);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain("Post");
});

test("GET /public/blog/ falls back to index.html inside the directory", async () => {
write("public/blog/index.html", "<!doctype html><title>Blog Index</title>");
const res = await fetch(`${baseUrl}/public/blog/`);
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain("Blog Index");
});

test("traversal attempt /public/..%2Fsecret.html returns 403", async () => {
write("secret.html", "<!doctype html><title>secret</title>");
const res = await fetch(`${baseUrl}/public/..%2Fsecret.html`);
expect(res.status).toBe(403);
const body = await res.text();
expect(body).not.toContain("secret");
});

test("traversal to dashboard.js via /public/../dashboard/dashboard.js returns 403", async () => {
write("dashboard/dashboard.js", "console.log('priv');");
const res = await fetch(`${baseUrl}/public/..%2Fdashboard%2Fdashboard.js`);
expect(res.status).toBe(403);
const body = await res.text();
expect(body).not.toContain("console.log");
});

test("Cache-Control on /public/* responses is public, max-age=300", async () => {
write("public/post.html", "<!doctype html><title>Post</title>");
const res = await fetch(`${baseUrl}/public/post.html`);
expect(res.status).toBe(200);
expect(res.headers.get("Cache-Control")).toBe("public, max-age=300");
});

test("regression: GET /ui/foo.html without cookie still redirects to /ui/login", async () => {
write("foo.html", "<!doctype html><title>Private</title>");
const res = await handleUiRequest(
new Request("http://localhost/ui/foo.html", { headers: { Accept: "text/html" } }),
);
expect(res.status).toBe(302);
expect(res.headers.get("Location")).toBe("/ui/login");
});

test("regression: GET /ui/dashboard/dashboard.js without cookie still returns 401", async () => {
write("dashboard/dashboard.js", "console.log('priv');");
const res = await handleUiRequest(
new Request("http://localhost/ui/dashboard/dashboard.js", { headers: { Accept: "application/javascript" } }),
);
expect(res.status).toBe(401);
});
});
});
49 changes: 48 additions & 1 deletion src/core/server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolve as pathResolve } from "node:path";
import type { AgentRuntime } from "../agent/runtime.ts";
import type { SlackChannel } from "../channels/slack.ts";
import { handleEmailLogin } from "../chat/email-login.ts";
Expand All @@ -8,7 +9,7 @@ import type { PhantomMcpServer } from "../mcp/server.ts";
import type { MemoryHealth } from "../memory/types.ts";
import type { SchedulerHealthSummary } from "../scheduler/health.ts";
import { avatarUrlIfPresent, handleAvatarGet } from "../ui/api/identity.ts";
import { handleUiRequest } from "../ui/serve.ts";
import { getPublicDir, handleUiRequest } from "../ui/serve.ts";
import { type HealthPayload, renderHealthHtml } from "./health-page.ts";

const VERSION = "0.20.1";
Expand Down Expand Up @@ -195,6 +196,14 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp
if (response) return response;
}

// Public publishing surface. Agents drop HTML, XML, or assets
// under public/public/*. Served without auth so Googlebot,
// OpenGraph scrapers, and the open web can read them.
// Traversal-defended via path.resolve + containment check.
if (url.pathname === "/public" || url.pathname === "/public/" || url.pathname.startsWith("/public/")) {
return handlePublicRequest(url);
}

if (url.pathname.startsWith("/ui")) {
return handleUiRequest(req);
}
Expand All @@ -211,6 +220,44 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp
return server;
}

async function handlePublicRequest(url: URL): Promise<Response> {
const publicRoot = pathResolve(getPublicDir(), "public");
const isRoot = url.pathname === "/public" || url.pathname === "/public/";
const rawRel = isRoot ? "index.html" : url.pathname.slice("/public/".length);
// Decode percent-escapes so traversal sequences like ..%2F become visible
// to the containment check below. A malformed escape is rejected outright.
let rel: string;
try {
rel = decodeURIComponent(rawRel);
} catch {
return new Response("Forbidden", { status: 403 });
}
if (rel.includes("\0")) {
return new Response("Forbidden", { status: 403 });
}
const candidate = pathResolve(publicRoot, rel);
if (candidate !== publicRoot && !candidate.startsWith(`${publicRoot}/`)) {
return new Response("Forbidden", { status: 403 });
}
const file = Bun.file(candidate);
if (await file.exists()) {
return new Response(file, {
headers: { "Cache-Control": "public, max-age=300" },
});
}
// Directory-style index.html fallback (e.g. /public/blog/ -> public/public/blog/index.html)
const indexCandidate = pathResolve(candidate, "index.html");
if (indexCandidate !== candidate && indexCandidate.startsWith(`${publicRoot}/`)) {
const indexFile = Bun.file(indexCandidate);
if (await indexFile.exists()) {
return new Response(indexFile, {
headers: { "Cache-Control": "public, max-age=300" },
});
}
}
return new Response("Not found", { status: 404 });
}

async function handleTrigger(req: Request): Promise<Response> {
if (!triggerAuth) {
return Response.json({ status: "error", message: "Auth not initialized" }, { status: 503 });
Expand Down
13 changes: 13 additions & 0 deletions src/ui/api/__tests__/pages-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,19 @@ describe("GET /ui/api/pages", () => {
expect(body.pages[0].path).toBe("/ui/keep.html");
});

test("excludes public/ directory so agent-published pages never surface in recent-pages", async () => {
writePage("public/blog/post-1.html", "<title>Post 1</title>");
writePage("public/index.html", "<title>Public Root</title>");
writePage("keep.html", "<html><head><title>Keep</title></head></html>");
const res = await handleUiRequest(req());
const body = (await res.json()) as PagesResponse;
const paths = body.pages.map((p) => p.path);
expect(paths).not.toContain("/ui/public/blog/post-1.html");
expect(paths).not.toContain("/ui/public/index.html");
expect(paths).toContain("/ui/keep.html");
expect(body.pages.length).toBe(1);
});

test("walks up to depth 3", async () => {
writePage("a/b/c/deep.html", "<html><head><title>Deep</title></head></html>");
writePage("a/b/c/d/too-deep.html", "<html><head><title>Too Deep</title></head></html>");
Expand Down
2 changes: 1 addition & 1 deletion src/ui/api/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const EXCLUDED_FILENAMES = new Set([
"robots.txt",
]);

const EXCLUDED_ROOT_DIRS = new Set(["dashboard", "_examples", "chat"]);
const EXCLUDED_ROOT_DIRS = new Set(["dashboard", "_examples", "chat", "public"]);

const TITLE_REGEX = /<title[^>]*>([^<]*)<\/title>/i;
const MAX_TITLE_LEN = 120;
Expand Down
Loading