From 85c0b9a3dc3795bf33646ef128ad070181c7abec Mon Sep 17 00:00:00 2001 From: Forgotten Date: Mon, 23 Feb 2026 19:43:52 -0600 Subject: [PATCH] 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 --- cli/src/__tests__/allowed-hostname.test.ts | 71 ++++++++++++++ cli/src/commands/allowed-hostname.ts | 37 ++++++++ cli/src/commands/configure.ts | 6 +- cli/src/commands/onboard.ts | 1 + cli/src/config/hostnames.ts | 26 ++++++ cli/src/index.ts | 8 ++ cli/src/prompts/server.ts | 50 ++++++++-- doc/CLI.md | 8 +- doc/DEVELOPING.md | 6 ++ packages/shared/src/config-schema.ts | 1 + .../__tests__/private-hostname-guard.test.ts | 56 +++++++++++ server/src/app.ts | 17 ++++ server/src/config.ts | 12 +++ server/src/index.ts | 2 + .../src/middleware/private-hostname-guard.ts | 92 +++++++++++++++++++ 15 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 cli/src/__tests__/allowed-hostname.test.ts create mode 100644 cli/src/commands/allowed-hostname.ts create mode 100644 cli/src/config/hostnames.ts create mode 100644 server/src/__tests__/private-hostname-guard.test.ts create mode 100644 server/src/middleware/private-hostname-guard.ts diff --git a/cli/src/__tests__/allowed-hostname.test.ts b/cli/src/__tests__/allowed-hostname.test.ts new file mode 100644 index 00000000..2b07ba29 --- /dev/null +++ b/cli/src/__tests__/allowed-hostname.test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { PaperclipConfig } from "../config/schema.js"; +import { addAllowedHostname } from "../commands/allowed-hostname.js"; + +function createTempConfigPath() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-allowed-hostname-")); + return path.join(dir, "config.json"); +} + +function writeBaseConfig(configPath: string) { + const base: PaperclipConfig = { + $meta: { + version: 1, + updatedAt: new Date("2026-01-01T00:00:00.000Z").toISOString(), + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: "/tmp/paperclip-db", + embeddedPostgresPort: 54329, + }, + logging: { + mode: "file", + logDir: "/tmp/paperclip-logs", + }, + server: { + deploymentMode: "authenticated", + exposure: "private", + host: "0.0.0.0", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "auto", + }, + storage: { + provider: "local_disk", + localDisk: { baseDir: "/tmp/paperclip-storage" }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { keyFilePath: "/tmp/paperclip-secrets/master.key" }, + }, + }; + fs.writeFileSync(configPath, JSON.stringify(base, null, 2)); +} + +describe("allowed-hostname command", () => { + it("adds and normalizes hostnames", async () => { + const configPath = createTempConfigPath(); + writeBaseConfig(configPath); + + await addAllowedHostname("https://Dotta-MacBook-Pro:3100", { config: configPath }); + await addAllowedHostname("dotta-macbook-pro", { config: configPath }); + + const raw = JSON.parse(fs.readFileSync(configPath, "utf-8")) as PaperclipConfig; + expect(raw.server.allowedHostnames).toEqual(["dotta-macbook-pro"]); + }); +}); + diff --git a/cli/src/commands/allowed-hostname.ts b/cli/src/commands/allowed-hostname.ts new file mode 100644 index 00000000..942c464b --- /dev/null +++ b/cli/src/commands/allowed-hostname.ts @@ -0,0 +1,37 @@ +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { normalizeHostnameInput } from "../config/hostnames.js"; +import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; + +export async function addAllowedHostname(host: string, opts: { config?: string }): Promise { + const configPath = resolveConfigPath(opts.config); + const config = readConfig(opts.config); + + if (!config) { + p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`); + return; + } + + const normalized = normalizeHostnameInput(host); + const current = new Set((config.server.allowedHostnames ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean)); + const existed = current.has(normalized); + current.add(normalized); + + config.server.allowedHostnames = Array.from(current).sort(); + config.$meta.updatedAt = new Date().toISOString(); + config.$meta.source = "configure"; + writeConfig(config, opts.config); + + if (existed) { + p.log.info(`Hostname ${pc.cyan(normalized)} is already allowed.`); + } else { + p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`); + } + + if (!(config.server.deploymentMode === "authenticated" && config.server.exposure === "private")) { + p.log.message( + pc.dim("Note: allowed hostnames are enforced only in authenticated/private mode."), + ); + } +} + diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index 7d412e4c..b0c9f721 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -48,6 +48,7 @@ function defaultConfig(): PaperclipConfig { exposure: "private", host: "127.0.0.1", port: 3100, + allowedHostnames: [], serveUi: true, }, auth: { @@ -131,7 +132,10 @@ export async function configure(opts: { break; case "server": { - const { server, auth } = await promptServer(); + const { server, auth } = await promptServer({ + currentServer: config.server, + currentAuth: config.auth, + }); config.server = server; config.auth = auth; } diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 337e13b6..67bb2eab 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -163,6 +163,7 @@ export async function onboard(opts: { config?: string }): Promise { llm ? `LLM: ${llm.provider}` : "LLM: not configured", `Logging: ${logging.mode} → ${logging.logDir}`, `Server: ${server.deploymentMode}/${server.exposure} @ ${server.host}:${server.port}`, + `Allowed hosts: ${server.allowedHostnames.length > 0 ? server.allowedHostnames.join(", ") : "(loopback only)"}`, `Auth URL mode: ${auth.baseUrlMode}${auth.publicBaseUrl ? ` (${auth.publicBaseUrl})` : ""}`, `Storage: ${storage.provider}`, `Secrets: ${secrets.provider} (strict mode ${secrets.strictMode ? "on" : "off"})`, diff --git a/cli/src/config/hostnames.ts b/cli/src/config/hostnames.ts new file mode 100644 index 00000000..b788cbb6 --- /dev/null +++ b/cli/src/config/hostnames.ts @@ -0,0 +1,26 @@ +export function normalizeHostnameInput(raw: string): string { + const input = raw.trim(); + if (!input) { + throw new Error("Hostname is required"); + } + + try { + const url = input.includes("://") ? new URL(input) : new URL(`http://${input}`); + const hostname = url.hostname.trim().toLowerCase(); + if (!hostname) throw new Error("Hostname is required"); + return hostname; + } catch { + throw new Error(`Invalid hostname: ${raw}`); + } +} + +export function parseHostnameCsv(raw: string): string[] { + if (!raw.trim()) return []; + const unique = new Set(); + for (const part of raw.split(",")) { + const hostname = normalizeHostnameInput(part); + unique.add(hostname); + } + return Array.from(unique); +} + diff --git a/cli/src/index.ts b/cli/src/index.ts index c04b12d0..0a5424c0 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -4,6 +4,7 @@ import { onboard } from "./commands/onboard.js"; import { doctor } from "./commands/doctor.js"; import { envCommand } from "./commands/env.js"; import { configure } from "./commands/configure.js"; +import { addAllowedHostname } from "./commands/allowed-hostname.js"; import { heartbeatRun } from "./commands/heartbeat-run.js"; import { runCommand } from "./commands/run.js"; import { bootstrapCeoInvite } from "./commands/auth-bootstrap-ceo.js"; @@ -52,6 +53,13 @@ program .option("-s, --section
", "Section to configure (llm, database, logging, server, storage, secrets)") .action(configure); +program + .command("allowed-hostname") + .description("Allow a hostname for authenticated/private mode access") + .argument("", "Hostname to allow (for example dotta-macbook-pro)") + .option("-c, --config ", "Path to config file") + .action(addAllowedHostname); + program .command("run") .description("Bootstrap local setup (onboard + doctor) and run Paperclip") diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts index 717ab0c2..1b271316 100644 --- a/cli/src/prompts/server.ts +++ b/cli/src/prompts/server.ts @@ -1,7 +1,14 @@ import * as p from "@clack/prompts"; import type { AuthConfig, ServerConfig } from "../config/schema.js"; +import { parseHostnameCsv } from "../config/hostnames.js"; + +export async function promptServer(opts?: { + currentServer?: Partial; + currentAuth?: Partial; +}): Promise<{ server: ServerConfig; auth: AuthConfig }> { + const currentServer = opts?.currentServer; + const currentAuth = opts?.currentAuth; -export async function promptServer(): Promise<{ server: ServerConfig; auth: AuthConfig }> { const deploymentModeSelection = await p.select({ message: "Deployment mode", options: [ @@ -16,7 +23,7 @@ export async function promptServer(): Promise<{ server: ServerConfig; auth: Auth hint: "Login required; use for private network or public hosting", }, ], - initialValue: "local_trusted", + initialValue: currentServer?.deploymentMode ?? "local_trusted", }); if (p.isCancel(deploymentModeSelection)) { @@ -24,6 +31,7 @@ export async function promptServer(): Promise<{ server: ServerConfig; auth: Auth process.exit(0); } const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"]; + let exposure: ServerConfig["exposure"] = "private"; if (deploymentMode === "authenticated") { const exposureSelection = await p.select({ @@ -40,7 +48,7 @@ export async function promptServer(): Promise<{ server: ServerConfig; auth: Auth hint: "Internet-facing deployment with stricter requirements", }, ], - initialValue: "private", + initialValue: currentServer?.exposure ?? "private", }); if (p.isCancel(exposureSelection)) { p.cancel("Setup cancelled."); @@ -52,7 +60,7 @@ export async function promptServer(): Promise<{ server: ServerConfig; auth: Auth const hostDefault = deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0"; const hostStr = await p.text({ message: "Bind host", - defaultValue: hostDefault, + defaultValue: currentServer?.host ?? hostDefault, placeholder: hostDefault, validate: (val) => { if (!val.trim()) return "Host is required"; @@ -66,7 +74,7 @@ export async function promptServer(): Promise<{ server: ServerConfig; auth: Auth const portStr = await p.text({ message: "Server port", - defaultValue: "3100", + defaultValue: String(currentServer?.port ?? 3100), placeholder: "3100", validate: (val) => { const n = Number(val); @@ -81,11 +89,35 @@ export async function promptServer(): Promise<{ server: ServerConfig; auth: Auth process.exit(0); } + let allowedHostnames: string[] = []; + if (deploymentMode === "authenticated" && exposure === "private") { + const allowedHostnamesInput = await p.text({ + message: "Allowed hostnames (comma-separated, optional)", + defaultValue: (currentServer?.allowedHostnames ?? []).join(", "), + placeholder: "dotta-macbook-pro, your-host.tailnet.ts.net", + validate: (val) => { + try { + parseHostnameCsv(val); + return; + } catch (err) { + return err instanceof Error ? err.message : "Invalid hostname list"; + } + }, + }); + + if (p.isCancel(allowedHostnamesInput)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + allowedHostnames = parseHostnameCsv(allowedHostnamesInput); + } + const port = Number(portStr) || 3100; let auth: AuthConfig = { baseUrlMode: "auto" }; if (deploymentMode === "authenticated" && exposure === "public") { const urlInput = await p.text({ message: "Public base URL", + defaultValue: currentAuth?.publicBaseUrl ?? "", placeholder: "https://paperclip.example.com", validate: (val) => { const candidate = val.trim(); @@ -109,10 +141,16 @@ export async function promptServer(): Promise<{ server: ServerConfig; auth: Auth baseUrlMode: "explicit", publicBaseUrl: urlInput.trim().replace(/\/+$/, ""), }; + } else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) { + auth = { + baseUrlMode: "explicit", + publicBaseUrl: currentAuth.publicBaseUrl, + }; } return { - server: { deploymentMode, exposure, host: hostStr.trim(), port, serveUi: true }, + server: { deploymentMode, exposure, host: hostStr.trim(), port, allowedHostnames, serveUi: true }, auth, }; } + diff --git a/doc/CLI.md b/doc/CLI.md index ac6eb736..05dbda02 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -2,7 +2,7 @@ Paperclip CLI now supports both: -- instance setup/diagnostics (`onboard`, `doctor`, `configure`, `env`) +- instance setup/diagnostics (`onboard`, `doctor`, `configure`, `env`, `allowed-hostname`) - control-plane client operations (issues, approvals, agents, activity, dashboard) ## Base Usage @@ -37,6 +37,12 @@ Current CLI behavior: Target behavior (planned) is documented in `doc/DEPLOYMENT-MODES.md` section 5. +Allow an authenticated/private hostname (for example custom Tailscale DNS): + +```sh +pnpm paperclip allowed-hostname dotta-macbook-pro +``` + All client commands support: - `--api-base ` diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index e1552103..5fc28503 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -37,6 +37,12 @@ pnpm dev --tailscale-auth This runs dev as `authenticated/private` and binds the server to `0.0.0.0` for private-network access. +Allow additional private hostnames (for example custom Tailscale hostnames): + +```sh +pnpm paperclip allowed-hostname dotta-macbook-pro +``` + ## One-Command Local Run For a first-time local install, you can bootstrap and run in one command: diff --git a/packages/shared/src/config-schema.ts b/packages/shared/src/config-schema.ts index d8b7d26d..15e6d716 100644 --- a/packages/shared/src/config-schema.ts +++ b/packages/shared/src/config-schema.ts @@ -35,6 +35,7 @@ export const serverConfigSchema = z.object({ exposure: z.enum(DEPLOYMENT_EXPOSURES).default("private"), host: z.string().default("127.0.0.1"), port: z.number().int().min(1).max(65535).default(3100), + allowedHostnames: z.array(z.string().min(1)).default([]), serveUi: z.boolean().default(true), }); diff --git a/server/src/__tests__/private-hostname-guard.test.ts b/server/src/__tests__/private-hostname-guard.test.ts new file mode 100644 index 00000000..8aeddb69 --- /dev/null +++ b/server/src/__tests__/private-hostname-guard.test.ts @@ -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"); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index a85fbaa2..ca67a519 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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; @@ -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, }, }); diff --git a/server/src/config.ts b/server/src/config.ts index e9f0a0eb..3ff7edd3 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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, diff --git a/server/src/index.ts b/server/src/index.ts index a22fc97c..ca222d35 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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, diff --git a/server/src/middleware/private-hostname-guard.ts b/server/src/middleware/private-hostname-guard.ts new file mode 100644 index 00000000..bc83462b --- /dev/null +++ b/server/src/middleware/private-hostname-guard.ts @@ -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(); + 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 { + const configuredAllow = normalizeAllowedHostnames(opts.allowedHostnames); + const bindHost = opts.bindHost.trim().toLowerCase(); + const allowSet = new Set(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 ."; + 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); + } + }; +}