Root cause
Authentication & access control introduced
Add server routes for companies, approvals, costs, and dashboard
AI-assisted change: Claude
How AI contributed: Direct introductionPublished Jul 22, 2026 in
paperclipai/paperclipAll 8 findings in this repository
Paperclip before 0.3.1 in default local_trusted mode fails to validate Host headers, allowing attackers to execute arbitrary commands via DNS rebinding. An attacker can craft a malicious webpage that, when visited by a developer running Paperclip locally, uses DNS rebinding to make authenticated API requests and execute commands through the process adapter.
A page served from an attacker-controlled DNS name can retain its browser origin while DNS rebinding moves the connection to a victim's loopback Paperclip server. Paperclip's missing Host-header gate in default private local_trusted mode allowed that request to receive the implicit instance-admin board actor, create/configure a process-adapter agent with attacker-controlled command and arguments, and invoke its heartbeat/wakeup path, which passed the stored configuration to child_process.spawn as the Paperclip OS user.
Root cause
Add server routes for companies, approvals, costs, and dashboard
AI-assisted change: Claude
Fix
Enables the private-hostname guard for local_trusted deployments as well as authenticated private deployments, so requests with unapproved Host headers are rejected before actor assignment.
Case facts
@@ -0,0 +1,57 @@+import { createHash } from "node:crypto";+import type { RequestHandler } from "express";+import { and, eq, isNull } from "drizzle-orm";+import type { Db } from "@paperclip/db";+import { agentApiKeys } from "@paperclip/db";++function hashToken(token: string) {+ return createHash("sha256").update(token).digest("hex");+}++export function actorMiddleware(db: Db): RequestHandler {+ return async (req, _res, next) => {+ req.actor = { type: "board", userId: "board" };++ const authHeader = req.header("authorization");+ if (!authHeader?.toLowerCase().startsWith("bearer ")) {+ next();+ return;+ }++ const token = authHeader.slice("bearer ".length).trim();+ if (!token) {+ next();+ return;+ }++ const tokenHash = hashToken(token);+ const key = await db+ .select()+ .from(agentApiKeys)+ .where(and(eq(agentApiKeys.keyHash, tokenHash), isNull(agentApiKeys.revokedAt)))+ .then((rows) => rows[0] ?? null);++ if (!key) {+ next();+ return;+ }++ await db+ .update(agentApiKeys)+ .set({ lastUsedAt: new Date() })+ .where(eq(agentApiKeys.id, key.id));++ req.actor = {+ type: "agent",+ agentId: key.agentId,+ companyId: key.companyId,+ keyId: key.id,+ };++ next();+ };+}++export function requireBoard(req: Express.Request) {+ return req.actor.type === "board";+}@@ -0,0 +1,354 @@+import { spawn, type ChildProcess } from "node:child_process";+import { and, eq, inArray } from "drizzle-orm";+import type { Db } from "@paperclip/db";+import { agents, heartbeatRuns } from "@paperclip/db";+import { conflict, notFound } from "../errors.js";+import { logger } from "../middleware/logger.js";++interface RunningProcess {+ child: ChildProcess;+ graceSec: number;+}++const runningProcesses = new Map<string, RunningProcess>();++function parseObject(value: unknown): Record<string, unknown> {+ if (typeof value !== "object" || value === null || Array.isArray(value)) {+ return {};+ }+ return value as Record<string, unknown>;+}++function asString(value: unknown, fallback: string): string {+ return typeof value === "string" && value.length > 0 ? value : fallback;+}++function asNumber(value: unknown, fallback: number): number {+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;+}++function asStringArray(value: unknown): string[] {+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];+}++export function heartbeatService(db: Db) {+ async function getAgent(agentId: string) {+ return db+ .select()+ .from(agents)+ .where(eq(agents.id, agentId))+ .then((rows) => rows[0] ?? null);+ }++ async function setRunStatus(+ runId: string,+ status: string,+ patch?: Partial<typeof heartbeatRuns.$inferInsert>,+ ) {+ return db+ .update(heartbeatRuns)+ .set({ status, ...patch, updatedAt: new Date() })+ .where(eq(heartbeatRuns.id, runId))+ .returning()+ .then((rows) => rows[0] ?? null);+ }++ async function finalizeAgentStatus(agentId: string, ok: boolean) {+ const existing = await getAgent(agentId);+ if (!existing) return;++ if (existing.status === "paused" || existing.status === "terminated") {+ return;+ }++ await db+ .update(agents)+ .set({+ status: ok ? "idle" : "error",+ lastHeartbeatAt: new Date(),+ updatedAt: new Date(),+ })+ .where(eq(agents.id, agentId));+ }++ async function executeHttpRun(runId: string, agentId: string, config: Record<string, unknown>, context: Record<string, unknown>) {+ const url = asString(config.url, "");+ if (!url) throw new Error("HTTP adapter missing url");++ const method = asString(config.method, "POST");+ const timeoutMs = asNumber(config.timeoutMs, 15000);+ const headers = parseObject(config.headers) as Record<string, string>;+ const payloadTemplate = parseObject(config.payloadTemplate);+ const body = { ...payloadTemplate, agentId, runId, context };++ const controller = new AbortController();+ const timer = setTimeout(() => controller.abort(), timeoutMs);++ try {+ const res = await fetch(url, {+ method,@@ -3,27 +3,37 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Db } from "@paperclip/db"; import { httpLogger, errorHandler } from "./middleware/index.js";+import { actorMiddleware } from "./middleware/auth.js"; import { healthRoutes } from "./routes/health.js";+import { companyRoutes } from "./routes/companies.js"; import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; import { issueRoutes } from "./routes/issues.js"; import { goalRoutes } from "./routes/goals.js";+import { approvalRoutes } from "./routes/approvals.js";+import { costRoutes } from "./routes/costs.js"; import { activityRoutes } from "./routes/activity.js";+import { dashboardRoutes } from "./routes/dashboard.js"; export function createApp(db: Db, opts: { serveUi: boolean }) { const app = express(); app.use(express.json()); app.use(httpLogger);+ app.use(actorMiddleware(db)); // Mount API routes const api = Router(); api.use("/health", healthRoutes());- api.use("/agents", agentRoutes(db));- api.use("/projects", projectRoutes(db));- api.use("/issues", issueRoutes(db));- api.use("/goals", goalRoutes(db));- api.use("/activity", activityRoutes(db));+ api.use("/companies", companyRoutes(db));+ api.use(agentRoutes(db));+ api.use(projectRoutes(db));+ api.use(issueRoutes(db));+ api.use(goalRoutes(db));+ api.use(approvalRoutes(db));+ api.use(costRoutes(db));+ api.use(activityRoutes(db));+ api.use(dashboardRoutes(db)); app.use("/api", api); // SPA fallback for serving the UI build@@ -86,6 +86,14 @@ function shouldServeViteDevHtml(req: ExpressRequest): boolean { return req.accepts(["html"]) === "html"; } +export function shouldEnablePrivateHostnameGuard(opts: {+ deploymentMode: DeploymentMode;+ deploymentExposure: DeploymentExposure;+}): boolean {+ return opts.deploymentMode === "local_trusted" ||+ (opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private");+}+ export async function createApp( db: Db, opts: {@@ -123,8 +131,10 @@ export async function createApp( }, })); app.use(httpLogger);- const privateHostnameGateEnabled =- opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private";+ const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({+ deploymentMode: opts.deploymentMode,+ deploymentExposure: opts.deploymentExposure,+ }); const privateHostnameAllowSet = resolvePrivateHostnameAllowSet({ allowedHostnames: opts.allowedHostnames, bindHost: opts.bindHost,AI-assisted change bb8aef3d31698d645bfff963826c487ea59eb7e6fd06e3ddbdd3e29a116b85df · Fix 5eb60c1f2a5321d071dc1f8da9337f57ceb1813b36b4c67d803edf2801db99d4