Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions agentchatbus-ts/src/core/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { isIPv4 } from "node:net";

export interface AppConfig {
host: string;
port: number;
dbPath: string;
adminToken: string | null;
showAd: boolean;
allowedHosts: string[];
agentHeartbeatTimeout: number;
msgWaitTimeout: number;
// TS-only improvement: minimum wait timeout clamp (ms) for msg_wait blocking path.
Expand Down Expand Up @@ -66,6 +69,65 @@ function pickEnvOrPersisted(
// Admin token for settings endpoint (optional — if unset, PUT /api/settings is unprotected)
export const ADMIN_TOKEN: string | null = process.env.AGENTCHATBUS_ADMIN_TOKEN || null;

/**
* Returns true when the server is operating in a non-localhost (public/network) mode.
* Triggered by HOST != 127.0.0.1 or SHOW_AD=true.
*/
export function isNonLocalhostDeployment(config: Pick<AppConfig, "host" | "showAd">): boolean {
return config.showAd || (config.host !== "127.0.0.1" && config.host !== "::1" && config.host !== "localhost");
}

/**
* Parse comma-separated list of allowed IPs/CIDRs from env var.
* Supports: exact IPv4 (e.g. "1.2.3.4"), IPv4 CIDR (e.g. "10.0.0.0/8"), exact IPv6.
* Loopback (127.0.0.1, ::1) is always implicitly allowed regardless of this list.
*/
export function parseAllowedHosts(raw: string | undefined): string[] {
if (!raw || !raw.trim()) return [];
return raw.split(",").map(s => s.trim()).filter(Boolean);
}

/**
* Check whether a source IP is permitted by the allowlist.
* Supports exact match and IPv4 CIDR notation.
* Returns true when allowedHosts is empty (feature disabled).
*/
export function isIpAllowed(ip: string, allowedHosts: string[]): boolean {
if (allowedHosts.length === 0) return true;

// Normalize IPv4-mapped IPv6 addresses (e.g. ::ffff:1.2.3.4 -> 1.2.3.4)
const normalized = ip.startsWith("::ffff:") ? ip.slice(7) : ip;

for (const entry of allowedHosts) {
if (entry.includes("/")) {
// IPv4 CIDR matching
const [networkAddr, prefixLenStr] = entry.split("/");
const prefixLen = Number(prefixLenStr);
if (!isIPv4(networkAddr) || !isIPv4(normalized) || isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) {
continue;
}
const mask = prefixLen === 0 ? 0 : (~0 << (32 - prefixLen)) >>> 0;
const ipInt = ipToInt(normalized);
const netInt = ipToInt(networkAddr);
if (ipInt !== null && netInt !== null && (ipInt & mask) === (netInt & mask)) {
return true;
}
} else {
// Exact match (IPv4 or IPv6)
if (normalized === entry || ip === entry) return true;
}
}
return false;
}

function ipToInt(ip: string): number | null {
const parts = ip.split(".");
if (parts.length !== 4) return null;
const nums = parts.map(Number);
if (nums.some(n => isNaN(n) || n < 0 || n > 255)) return null;
return ((nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3]) >>> 0;
}

function getAppDir(): string {
const configured = process.env.AGENTCHATBUS_APP_DIR;
if (configured && configured.trim().length > 0) {
Expand Down Expand Up @@ -220,11 +282,14 @@ export function getConfig(): AppConfig {
const persisted = getPersistedConfig();
const host = pickEnvOrPersisted(process.env.AGENTCHATBUS_HOST, persisted.HOST, "127.0.0.1");
const appDir = getAppDir();
const showAd = parseBoolLike(process.env.AGENTCHATBUS_SHOW_AD, false);
return {
host,
port: Number(pickEnvOrPersisted(process.env.AGENTCHATBUS_PORT, persisted.PORT, "39765")),
dbPath: process.env.AGENTCHATBUS_DB || join(appDir, "bus-ts.db"),
adminToken: ADMIN_TOKEN,
showAd,
allowedHosts: parseAllowedHosts(process.env.AGENTCHATBUS_ALLOWED_HOSTS),
agentHeartbeatTimeout: Number(
pickEnvOrPersisted(process.env.AGENTCHATBUS_HEARTBEAT_TIMEOUT, persisted.AGENT_HEARTBEAT_TIMEOUT, "60")
),
Expand Down
77 changes: 75 additions & 2 deletions agentchatbus-ts/src/transports/http/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import type { FastifyReply, FastifyRequest } from "fastify";
import { callTool, listTools, withToolCallContext } from "../../adapters/mcp/tools.js";
import { SeqMismatchError, MissingSyncFieldsError, ReplyTokenInvalidError, ReplyTokenExpiredError, ReplyTokenReplayError, BusError } from "../../core/types/errors.js";
import { getConfig, getConfigDict, saveConfigDict, ADMIN_TOKEN, BUS_VERSION } from "../../core/config/env.js";
import { getConfig, getConfigDict, saveConfigDict, ADMIN_TOKEN, BUS_VERSION, isNonLocalhostDeployment, isIpAllowed } from "../../core/config/env.js";
import { MemoryStore, memoryStore } from "../../core/services/memoryStore.js";
import { registerStore } from "../../core/services/storeSingleton.js";
import { eventBus } from "../../shared/eventBus.js";
Expand Down Expand Up @@ -90,6 +90,66 @@ export function createHttpServer() {
prefix: "/static/",
});

// ── SEC-05: Security middleware ──────────────────────────────────────────────
// Config is read per-request (not captured at server creation time) to avoid
// test isolation issues and support dynamic reconfiguration.

// Patterns of URL prefixes/exact routes that are agent-auth-gated (exempt from SHOW_AD guard)
const SHOW_AD_AGENT_AUTH_EXEMPT_PREFIXES = [
"/api/agents/register",
"/api/agents/heartbeat",
"/api/agents/resume",
"/api/agents/unregister",
];
const SHOW_AD_AGENT_AUTH_EXEMPT_EXACT: Array<{ method: string; pattern: RegExp }> = [
{ method: "POST", pattern: /^\/api\/threads$/ },
{ method: "POST", pattern: /^\/api\/threads\/[^/]+\/messages$/ },
];

fastify.addHook("onRequest", async (request, reply) => {
// Re-read config on each request for test isolation and dynamic support
const cfg = getConfig();

// Layer 1: IP allowlist — enforced when AGENTCHATBUS_ALLOWED_HOSTS is set.
// Loopback addresses always bypass the allowlist.
if (cfg.allowedHosts.length > 0 && !isLoopbackRequest(request)) {
const ip = request.ip || request.socket.remoteAddress || "";
if (!isIpAllowed(ip, cfg.allowedHosts)) {
reply.code(403);
await reply.send({ detail: "Forbidden: source IP not in AGENTCHATBUS_ALLOWED_HOSTS" });
return;
}
}

// Layer 2: SHOW_AD write guard — when SHOW_AD=true, protect all write endpoints
// that are not already gated by X-Agent-Token.
if (cfg.showAd) {
// Allow all GET requests (read-only)
if (request.method === "GET") return;
// Allow OPTIONS/HEAD
if (request.method === "OPTIONS" || request.method === "HEAD") return;
// Allow loopback — admin can always manage from localhost
if (isLoopbackRequest(request)) return;

const url = request.url.split("?")[0];

// Allow agent-auth-gated endpoints
if (SHOW_AD_AGENT_AUTH_EXEMPT_PREFIXES.some(p => url === p || url.startsWith(p + "/"))) return;
for (const { method, pattern } of SHOW_AD_AGENT_AUTH_EXEMPT_EXACT) {
if (request.method === method && pattern.test(url)) return;
}

// All other write/delete endpoints require X-Admin-Token
const adminToken = request.headers["x-admin-token"] as string | undefined;
if (!cfg.adminToken || adminToken !== cfg.adminToken) {
reply.code(401);
await reply.send({ detail: "Unauthorized: X-Admin-Token required in SHOW_AD mode" });
}
}
});

// ── End SEC-05 ───────────────────────────────────────────────────────────────

fastify.get("/", async (request, reply) => {
return reply.sendFile("index.html");
});
Expand Down Expand Up @@ -666,7 +726,9 @@ export function createHttpServer() {
agent_id: agent.id,
name: agent.name,
display_name: agent.display_name,
token: agent.token,
// SEC-05: Suppress token in public demo mode (SHOW_AD=true) to prevent token leakage.
// Private deployments (localhost or non-SHOW_AD) still receive the token for agent auth.
...(getConfig().showAd ? {} : { token: agent.token }),
capabilities: agent.capabilities,
skills: agent.skills,
emoji: (agent as any).emoji || "🤖"
Expand Down Expand Up @@ -1467,6 +1529,17 @@ async function setThreadStatus(request: FastifyRequest, reply: FastifyReply, sta

export async function startHttpServer() {
const config = getConfig();

// SEC-05: Fail-fast — refuse to start if non-localhost deployment without ADMIN_TOKEN
if (isNonLocalhostDeployment(config) && !config.adminToken) {
console.error(
"[SEC-05] FATAL: Server is configured for non-localhost (HOST != 127.0.0.1 or SHOW_AD=true) " +
"but AGENTCHATBUS_ADMIN_TOKEN is not set. " +
"Set AGENTCHATBUS_ADMIN_TOKEN to a secure value before starting in network mode."
);
process.exit(1);
}

const server = createHttpServer();
await server.listen({ host: config.host, port: config.port });
return server;
Expand Down
Loading
Loading