From b166979d06926c3dcfc5534c34e9ca1b6da968e6 Mon Sep 17 00:00:00 2001 From: Jeff Klassen Date: Tue, 31 Mar 2026 15:07:05 +0300 Subject: [PATCH] fix: require bearer token auth on /trigger endpoint The /trigger endpoint accepted unauthenticated requests, allowing anyone with network access to port 3100 to submit arbitrary tasks and deliver responses to any Slack channel or user. Reuse the existing MCP AuthMiddleware to require operator scope, consistent with how /mcp and /webhook are protected. Closes #9 --- src/core/__tests__/trigger-auth.test.ts | 129 ++++++++++++++++++++++++ src/core/server.ts | 20 ++++ 2 files changed, 149 insertions(+) create mode 100644 src/core/__tests__/trigger-auth.test.ts diff --git a/src/core/__tests__/trigger-auth.test.ts b/src/core/__tests__/trigger-auth.test.ts new file mode 100644 index 0000000..39f89ff --- /dev/null +++ b/src/core/__tests__/trigger-auth.test.ts @@ -0,0 +1,129 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import YAML from "yaml"; +import { hashTokenSync } from "../../mcp/config.ts"; +import type { McpConfig } from "../../mcp/types.ts"; +import { setTriggerDeps, startServer } from "../server.ts"; + +/** + * Tests that the /trigger endpoint requires bearer token auth + * with operator scope. Closes ghostwright/phantom#9. + */ +describe("/trigger endpoint auth", () => { + const adminToken = "test-trigger-admin-token"; + const readToken = "test-trigger-read-token"; + const operatorToken = "test-trigger-operator-token"; + + const mcpConfigPath = "config/mcp.yaml"; + let originalMcpYaml: string | null = null; + let server: ReturnType; + let baseUrl: string; + + beforeAll(() => { + // Back up the existing mcp.yaml so we can restore it after tests + if (existsSync(mcpConfigPath)) { + originalMcpYaml = readFileSync(mcpConfigPath, "utf-8"); + } + + // Write test tokens to mcp.yaml so loadMcpConfig picks them up + const mcpConfig: McpConfig = { + tokens: [ + { name: "admin", hash: hashTokenSync(adminToken), scopes: ["read", "operator", "admin"] }, + { name: "reader", hash: hashTokenSync(readToken), scopes: ["read"] }, + { name: "operator", hash: hashTokenSync(operatorToken), scopes: ["read", "operator"] }, + ], + rate_limit: { requests_per_minute: 60, burst: 10 }, + }; + + mkdirSync("config", { recursive: true }); + writeFileSync(mcpConfigPath, YAML.stringify(mcpConfig), "utf-8"); + + // Start server with a random port + server = startServer({ name: "test", port: 0, role: "base" } as never, Date.now()); + baseUrl = `http://localhost:${server.port}`; + + // Wire trigger deps with a mock runtime + setTriggerDeps({ + runtime: { + handleMessage: async () => ({ + text: "ok", + cost: { totalUsd: 0 }, + durationMs: 0, + }), + } as never, + }); + }); + + afterAll(() => { + server?.stop(true); + // Restore the original mcp.yaml + if (originalMcpYaml !== null) { + writeFileSync(mcpConfigPath, originalMcpYaml, "utf-8"); + } + }); + + const triggerBody = JSON.stringify({ task: "hello" }); + + test("rejects request with no Authorization header", async () => { + const res = await fetch(`${baseUrl}/trigger`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: triggerBody, + }); + expect(res.status).toBe(401); + const json = (await res.json()) as { status: string; message: string }; + expect(json.message).toContain("Missing"); + }); + + test("rejects request with invalid token", async () => { + const res = await fetch(`${baseUrl}/trigger`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer wrong-token", + }, + body: triggerBody, + }); + expect(res.status).toBe(401); + }); + + test("rejects read-only token (insufficient scope)", async () => { + const res = await fetch(`${baseUrl}/trigger`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${readToken}`, + }, + body: triggerBody, + }); + expect(res.status).toBe(403); + const json = (await res.json()) as { status: string; message: string }; + expect(json.message).toContain("operator"); + }); + + test("accepts operator token", async () => { + const res = await fetch(`${baseUrl}/trigger`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${operatorToken}`, + }, + body: triggerBody, + }); + expect(res.status).toBe(200); + const json = (await res.json()) as { status: string }; + expect(json.status).toBe("ok"); + }); + + test("accepts admin token", async () => { + const res = await fetch(`${baseUrl}/trigger`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${adminToken}`, + }, + body: triggerBody, + }); + expect(res.status).toBe(200); + }); +}); diff --git a/src/core/server.ts b/src/core/server.ts index 5308163..80d62a8 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -1,6 +1,8 @@ import type { AgentRuntime } from "../agent/runtime.ts"; import type { SlackChannel } from "../channels/slack.ts"; import type { PhantomConfig } from "../config/types.ts"; +import { AuthMiddleware } from "../mcp/auth.ts"; +import { loadMcpConfig } from "../mcp/config.ts"; import type { PhantomMcpServer } from "../mcp/server.ts"; import type { MemoryHealth } from "../memory/types.ts"; import { handleUiRequest } from "../ui/serve.ts"; @@ -67,7 +69,12 @@ export function setTriggerDeps(deps: TriggerDeps): void { triggerDeps = deps; } +let triggerAuth: AuthMiddleware | null = null; + export function startServer(config: PhantomConfig, startedAt: number): ReturnType { + const mcpConfig = loadMcpConfig(); + triggerAuth = new AuthMiddleware(mcpConfig); + const server = Bun.serve({ port: config.port, async fetch(req) { @@ -142,6 +149,19 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp } async function handleTrigger(req: Request): Promise { + if (!triggerAuth) { + return Response.json({ status: "error", message: "Auth not initialized" }, { status: 503 }); + } + + const auth = await triggerAuth.authenticate(req); + if (!auth.authenticated) { + return Response.json({ status: "error", message: auth.error }, { status: 401 }); + } + + if (!triggerAuth.hasScope(auth, "operator")) { + return Response.json({ status: "error", message: "Insufficient scope: operator required" }, { status: 403 }); + } + if (!triggerDeps) { return Response.json({ status: "error", message: "Trigger not configured" }, { status: 503 }); }