feat: private hostname guard for authenticated/private mode

Reject requests from unrecognised Host headers when running
authenticated/private. Adds server middleware, CLI `allowed-hostname`
command, config-schema field, and prompt support for configuring
allowed hostnames during onboard/configure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-23 19:43:52 -06:00
parent 076092685e
commit 85c0b9a3dc
15 changed files with 385 additions and 8 deletions

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import express from "express";
import request from "supertest";
import { privateHostnameGuard } from "../middleware/private-hostname-guard.js";
function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) {
const app = express();
app.use(
privateHostnameGuard({
enabled: opts.enabled,
allowedHostnames: opts.allowedHostnames ?? [],
bindHost: opts.bindHost ?? "0.0.0.0",
}),
);
app.get("/api/health", (_req, res) => {
res.status(200).json({ status: "ok" });
});
app.get("/dashboard", (_req, res) => {
res.status(200).send("ok");
});
return app;
}
describe("privateHostnameGuard", () => {
it("allows requests when disabled", async () => {
const app = createApp({ enabled: false });
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
expect(res.status).toBe(200);
});
it("allows loopback hostnames", async () => {
const app = createApp({ enabled: true });
const res = await request(app).get("/api/health").set("Host", "localhost:3100");
expect(res.status).toBe(200);
});
it("allows explicitly configured hostnames", async () => {
const app = createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] });
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
expect(res.status).toBe(200);
});
it("blocks unknown hostnames with remediation command", async () => {
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100");
expect(res.status).toBe(403);
expect(res.body?.error).toContain("please run pnpm paperclip allowed-hostname dotta-macbook-pro");
});
it("blocks unknown hostnames on page routes with plain-text remediation command", async () => {
const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] });
const res = await request(app).get("/dashboard").set("Host", "dotta-macbook-pro:3100");
expect(res.status).toBe(403);
expect(res.text).toContain("please run pnpm paperclip allowed-hostname dotta-macbook-pro");
});
});

View File

@@ -8,6 +8,7 @@ import type { StorageService } from "./storage/types.js";
import { httpLogger, errorHandler } from "./middleware/index.js";
import { actorMiddleware } from "./middleware/auth.js";
import { boardMutationGuard } from "./middleware/board-mutation-guard.js";
import { privateHostnameGuard, resolvePrivateHostnameAllowSet } from "./middleware/private-hostname-guard.js";
import { healthRoutes } from "./routes/health.js";
import { companyRoutes } from "./routes/companies.js";
import { agentRoutes } from "./routes/agents.js";
@@ -34,6 +35,8 @@ export async function createApp(
storageService: StorageService;
deploymentMode: DeploymentMode;
deploymentExposure: DeploymentExposure;
allowedHostnames: string[];
bindHost: string;
authReady: boolean;
betterAuthHandler?: express.RequestHandler;
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
@@ -43,6 +46,19 @@ export async function createApp(
app.use(express.json());
app.use(httpLogger);
const privateHostnameGateEnabled =
opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private";
const privateHostnameAllowSet = resolvePrivateHostnameAllowSet({
allowedHostnames: opts.allowedHostnames,
bindHost: opts.bindHost,
});
app.use(
privateHostnameGuard({
enabled: privateHostnameGateEnabled,
allowedHostnames: opts.allowedHostnames,
bindHost: opts.bindHost,
}),
);
app.use(
actorMiddleware(db, {
deploymentMode: opts.deploymentMode,
@@ -98,6 +114,7 @@ export async function createApp(
appType: "spa",
server: {
middlewareMode: true,
allowedHosts: privateHostnameGateEnabled ? Array.from(privateHostnameAllowSet) : undefined,
},
});

View File

@@ -33,6 +33,7 @@ export interface Config {
deploymentExposure: DeploymentExposure;
host: string;
port: number;
allowedHostnames: string[];
authBaseUrlMode: AuthBaseUrlMode;
authPublicBaseUrl: string | undefined;
databaseMode: DatabaseMode;
@@ -131,12 +132,23 @@ export function loadConfig(): Config {
authBaseUrlModeFromEnv ??
fileConfig?.auth?.baseUrlMode ??
(authPublicBaseUrl ? "explicit" : "auto");
const allowedHostnamesFromEnvRaw = process.env.PAPERCLIP_ALLOWED_HOSTNAMES;
const allowedHostnamesFromEnv = allowedHostnamesFromEnvRaw
? allowedHostnamesFromEnvRaw
.split(",")
.map((value) => value.trim().toLowerCase())
.filter((value) => value.length > 0)
: null;
const allowedHostnames = Array.from(
new Set((allowedHostnamesFromEnv ?? fileConfig?.server.allowedHostnames ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean)),
);
return {
deploymentMode,
deploymentExposure,
host: process.env.HOST ?? fileConfig?.server.host ?? "127.0.0.1",
port: Number(process.env.PORT) || fileConfig?.server.port || 3100,
allowedHostnames,
authBaseUrlMode,
authPublicBaseUrl,
databaseMode: fileDatabaseMode,

View File

@@ -349,6 +349,8 @@ const app = await createApp(db as any, {
storageService,
deploymentMode: config.deploymentMode,
deploymentExposure: config.deploymentExposure,
allowedHostnames: config.allowedHostnames,
bindHost: config.host,
authReady,
betterAuthHandler,
resolveSession,

View File

@@ -0,0 +1,92 @@
import type { Request, RequestHandler } from "express";
function isLoopbackHostname(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
}
function extractHostname(req: Request): string | null {
const forwardedHost = req.header("x-forwarded-host")?.split(",")[0]?.trim();
const hostHeader = req.header("host")?.trim();
const raw = forwardedHost || hostHeader;
if (!raw) return null;
try {
return new URL(`http://${raw}`).hostname.trim().toLowerCase();
} catch {
return raw.trim().toLowerCase();
}
}
function normalizeAllowedHostnames(values: string[]): string[] {
const unique = new Set<string>();
for (const value of values) {
const trimmed = value.trim().toLowerCase();
if (!trimmed) continue;
unique.add(trimmed);
}
return Array.from(unique);
}
export function resolvePrivateHostnameAllowSet(opts: { allowedHostnames: string[]; bindHost: string }): Set<string> {
const configuredAllow = normalizeAllowedHostnames(opts.allowedHostnames);
const bindHost = opts.bindHost.trim().toLowerCase();
const allowSet = new Set<string>(configuredAllow);
if (bindHost && bindHost !== "0.0.0.0") {
allowSet.add(bindHost);
}
allowSet.add("localhost");
allowSet.add("127.0.0.1");
allowSet.add("::1");
return allowSet;
}
function blockedHostnameMessage(hostname: string): string {
return (
`Hostname '${hostname}' is not allowed for this Paperclip instance. ` +
`If you want to allow this hostname, please run pnpm paperclip allowed-hostname ${hostname}`
);
}
export function privateHostnameGuard(opts: {
enabled: boolean;
allowedHostnames: string[];
bindHost: string;
}): RequestHandler {
if (!opts.enabled) {
return (_req, _res, next) => next();
}
const allowSet = resolvePrivateHostnameAllowSet({
allowedHostnames: opts.allowedHostnames,
bindHost: opts.bindHost,
});
return (req, res, next) => {
const hostname = extractHostname(req);
const wantsJson = req.path.startsWith("/api") || req.accepts(["json", "html", "text"]) === "json";
if (!hostname) {
const error = "Missing Host header. If you want to allow a hostname, run pnpm paperclip allowed-hostname <host>.";
if (wantsJson) {
res.status(403).json({ error });
} else {
res.status(403).type("text/plain").send(error);
}
return;
}
if (isLoopbackHostname(hostname) || allowSet.has(hostname)) {
next();
return;
}
const error = blockedHostnameMessage(hostname);
if (wantsJson) {
res.status(403).json({ error });
} else {
res.status(403).type("text/plain").send(error);
}
};
}