diff --git a/src/core/__tests__/server.test.ts b/src/core/__tests__/server.test.ts index fdd1178..d235e08 100644 --- a/src/core/__tests__/server.test.ts +++ b/src/core/__tests__/server.test.ts @@ -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"; /** @@ -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", "Blog"); + 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", "Post"); + 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", "Blog Index"); + 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", "secret"); + 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", "Post"); + 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", "Private"); + 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); + }); + }); }); diff --git a/src/core/server.ts b/src/core/server.ts index bf813b8..8479052 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -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"; @@ -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"; @@ -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); } @@ -211,6 +220,44 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp return server; } +async function handlePublicRequest(url: URL): Promise { + 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 { if (!triggerAuth) { return Response.json({ status: "error", message: "Auth not initialized" }, { status: 503 }); diff --git a/src/ui/api/__tests__/pages-api.test.ts b/src/ui/api/__tests__/pages-api.test.ts index dbf4eae..db15cbf 100644 --- a/src/ui/api/__tests__/pages-api.test.ts +++ b/src/ui/api/__tests__/pages-api.test.ts @@ -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", "Post 1"); + writePage("public/index.html", "Public Root"); + writePage("keep.html", "Keep"); + 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", "Deep"); writePage("a/b/c/d/too-deep.html", "Too Deep"); diff --git a/src/ui/api/pages.ts b/src/ui/api/pages.ts index 835b757..684ed97 100644 --- a/src/ui/api/pages.ts +++ b/src/ui/api/pages.ts @@ -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>/i; const MAX_TITLE_LEN = 120;