History

Merge pull request #718 from RhysSullivan/codex/local-server-boundaries

Harden local server boundaries

Rhys Sullivan committed 6 months ago

Commit c6ef26c2 parents 7bd646c c0dbc5810 files · +338 −15Browse files

Unified · Split

10 files
  1. apps/cli/src/daemon-state.test.ts +91 −0
  2. apps/cli/src/daemon-state.ts +8 −5
  3. apps/cli/src/daemon.test.ts +16 −0
  4. apps/cli/src/daemon.ts +1 −1
  5. apps/cli/src/main.ts +54 −9
  6. apps/cli/src/tooling.test.ts +25 −0
  7. apps/cli/src/tooling.ts +14 −0
  8. apps/cli/vitest.config.ts +7 −0
  9. apps/local/src/serve.test.ts +51 −0
  10. apps/local/src/serve.ts +71 −0
apps/cli/src/daemon-state.test.ts+91−0
@@ -0,0 +1,91 @@
1+import { afterEach, describe, expect, it } from "@effect/vitest";
2+import { BunServices } from "@effect/platform-bun";
3+import { mkdtempSync, rmSync } from "node:fs";
4+import { tmpdir } from "node:os";
5+import { join } from "node:path";
6+import * as Effect from "effect/Effect";
7+
8+import {
9+ canonicalDaemonHost,
10+ currentDaemonScopeId,
11+ readDaemonPointer,
12+ writeDaemonPointer,
13+} from "./daemon-state";
14+
15+const previousDataDir = process.env.EXECUTOR_DATA_DIR;
16+const previousScopeDir = process.env.EXECUTOR_SCOPE_DIR;
17+const originalCwd = process.cwd();
18+
19+afterEach(() => {
20+ if (previousDataDir === undefined) {
21+ delete process.env.EXECUTOR_DATA_DIR;
22+ } else {
23+ process.env.EXECUTOR_DATA_DIR = previousDataDir;
24+ }
25+
26+ if (previousScopeDir === undefined) {
27+ delete process.env.EXECUTOR_SCOPE_DIR;
28+ } else {
29+ process.env.EXECUTOR_SCOPE_DIR = previousScopeDir;
30+ }
31+
32+ process.chdir(originalCwd);
33+});
34+
35+describe("daemon host and scope identity", () => {
36+ it("does not collapse wildcard binds into loopback pointers", () => {
37+ expect(canonicalDaemonHost("127.0.0.1")).toBe("localhost");
38+ expect(canonicalDaemonHost("0.0.0.0")).toBe("0.0.0.0");
39+ });
40+
41+ it("resolves relative scope directories against the current workspace", () => {
42+ const workspace = mkdtempSync(join(tmpdir(), "executor-daemon-scope-"));
43+ try {
44+ process.chdir(workspace);
45+ process.env.EXECUTOR_SCOPE_DIR = "executor.jsonc";
46+
47+ expect(currentDaemonScopeId()).toBe(`scope:${join(workspace, "executor.jsonc")}`);
48+ } finally {
49+ rmSync(workspace, { recursive: true, force: true });
50+ }
51+ });
52+
53+ it.effect("keeps daemon pointers distinct for scope ids with the same sanitized form", () =>
54+ Effect.gen(function* () {
55+ const dataDir = mkdtempSync(join(tmpdir(), "executor-daemon-state-"));
56+ process.env.EXECUTOR_DATA_DIR = dataDir;
57+
58+ try {
59+ const firstScope = "scope:/workspace/app";
60+ const secondScope = "scope:_workspace_app";
61+
62+ yield* writeDaemonPointer({
63+ hostname: "localhost",
64+ port: 4788,
65+ pid: process.pid,
66+ scopeId: firstScope,
67+ scopeDir: "/workspace/app",
68+ token: "first-token",
69+ });
70+ yield* writeDaemonPointer({
71+ hostname: "localhost",
72+ port: 4789,
73+ pid: process.pid,
74+ scopeId: secondScope,
75+ scopeDir: "_workspace_app",
76+ token: "second-token",
77+ });
78+
79+ const first = yield* readDaemonPointer({ hostname: "localhost", scopeId: firstScope });
80+ const second = yield* readDaemonPointer({ hostname: "localhost", scopeId: secondScope });
81+
82+ expect(first?.port).toBe(4788);
83+ expect(first?.token).toBe("first-token");
84+ expect(second?.port).toBe(4789);
85+ expect(second?.token).toBe("second-token");
86+ } finally {
87+ rmSync(dataDir, { recursive: true, force: true });
88+ }
89+ }).pipe(Effect.provide(BunServices.layer)),
90+ );
91+});
apps/cli/src/daemon-state.ts+8−5
@@ -1,4 +1,6 @@
1+import { createHash } from "node:crypto";
12 import { homedir } from "node:os";
3+import { resolve } from "node:path";
24 import { FileSystem, Path } from "effect";
35 import type { PlatformError } from "effect/PlatformError";
46 import * as Effect from "effect/Effect";
@@ -37,7 +39,7 @@ export interface DaemonStartLock { · 32 unmodified lines
3739 // Host normalization
3840 // ---------------------------------------------------------------------------
3941
40−const LOCAL_HOST_ALIASES = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
42+const LOCAL_HOST_ALIASES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
4143
4244 export const canonicalDaemonHost = (hostname: string): string => {
4345 const normalized = hostname.trim().toLowerCase();
@@ -47,9 +49,9 @@ export const canonicalDaemonHost = (hostname: string): string => { · 3 unmodified lines
4749 export const currentDaemonScopeId = (): string => {
4850 const explicitScope = process.env.EXECUTOR_SCOPE_DIR?.trim();
4951 if (explicitScope && explicitScope.length > 0) {
50− return `scope:${explicitScope}`;
52+ return `scope:${resolve(explicitScope)}`;
5153 }
52− return `cwd:${process.cwd()}`;
54+ return `cwd:${resolve(process.cwd())}`;
5355 };
5456
5557 // ---------------------------------------------------------------------------
@@ -61,7 +63,8 @@ const resolveDaemonDataDir = (path: Path.Path): string => · 5 unmodified lines
6163
6264 const sanitizeHostForPath = (hostname: string): string =>
6365 hostname.replaceAll(/[^a-z0-9.-]+/gi, "_");
64−const sanitizeScopeForPath = (scopeId: string): string => scopeId.replaceAll(/[^a-z0-9.-]+/gi, "_");
66+const scopeKeyForPath = (scopeId: string): string =>
67+ createHash("sha256").update(scopeId).digest("hex").slice(0, 24);
6568
6669 const daemonRecordPath = (path: Path.Path, input: { hostname: string; port: number }): string => {
6770 const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname));
@@ -73,7 +76,7 @@ const daemonPointerPath = ( · 5 unmodified lines
7376 input: { hostname: string; scopeId: string },
7477 ): string => {
7578 const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname));
76− const scope = sanitizeScopeForPath(input.scopeId);
79+ const scope = scopeKeyForPath(input.scopeId);
7780 return path.join(resolveDaemonDataDir(path), `daemon-active-${host}-${scope}.json`);
7881 };
7982
apps/cli/src/daemon.test.ts+16−0
@@ -0,0 +1,16 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { canAutoStartLocalDaemonForHost } from "./daemon";
4+
5+describe("canAutoStartLocalDaemonForHost", () => {
6+ it("allows loopback hosts", () => {
7+ expect(canAutoStartLocalDaemonForHost("localhost")).toBe(true);
8+ expect(canAutoStartLocalDaemonForHost("127.0.0.1")).toBe(true);
9+ expect(canAutoStartLocalDaemonForHost("[::1]")).toBe(true);
10+ });
11+
12+ it("does not treat wildcard binds as loopback", () => {
13+ expect(canAutoStartLocalDaemonForHost("0.0.0.0")).toBe(false);
14+ expect(canAutoStartLocalDaemonForHost("::")).toBe(false);
15+ });
16+});
apps/cli/src/daemon.ts+1−1
@@ -48,7 +48,7 @@ export const parseDaemonBaseUrl = (baseUrl: string, defaultPort: number): Parsed · 47 unmodified lines
4848 // Local-host checks
4949 // ---------------------------------------------------------------------------
5050
51−const LOCAL_DAEMON_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
51+const LOCAL_DAEMON_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
5252
5353 export const canAutoStartLocalDaemonForHost = (hostname: string): boolean =>
5454 LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase());
apps/cli/src/main.ts+54−9
@@ -97,6 +97,8 @@ import { · 96 unmodified lines
9797 inspectToolPath,
9898 normalizeCliErrorText,
9999 parseJsonObjectInput,
100+ sanitizeCliOutputText,
101+ shellQuoteArg,
100102 } from "./tooling";
101103
102104 // Embedded web UI — baked into compiled binaries via `with { type: "file" }`
@@ -459,8 +461,9 @@ const printExecutionOutcome = (input: { baseUrl: string; outcome: ExecuteCodeOut · 356 unmodified lines
459461 console.log(`\nRequested schema:\n${JSON.stringify(requestedSchema, null, 2)}`);
460462 }
461463 const template = buildResumeContentTemplate(requestedSchema);
464+ const contentArg = shellQuoteArg(JSON.stringify(template));
462465 console.log("\nResume commands:");
463− console.log(` ${commandPrefix} --action accept --content '${JSON.stringify(template)}'`);
466+ console.log(` ${commandPrefix} --action accept --content ${contentArg}`);
464467 console.log(` ${commandPrefix} --action decline`);
465468 console.log(` ${commandPrefix} --action cancel`);
466469 } else {
@@ -496,6 +499,8 @@ const runForegroundSession = (input: { · 29 unmodified lines
496499 port: number;
497500 hostname: string;
498501 allowedHosts: ReadonlyArray<string>;
502+ authToken: string | undefined;
503+ authPassword: string | undefined;
499504 }) =>
500505 Effect.gen(function* () {
501506 const server = yield* Effect.promise(() =>
@@ -503,6 +508,8 @@ const runForegroundSession = (input: { · 1 unmodified line
503508 port: input.port,
504509 hostname: input.hostname,
505510 allowedHosts: input.allowedHosts,
511+ authToken: input.authToken,
512+ authPassword: input.authPassword,
506513 embeddedWebUI,
507514 }),
508515 );
@@ -521,6 +528,11 @@ const runForegroundSession = (input: { · 12 unmodified lines
521528 if (input.allowedHosts.length > 0) {
522529 console.log(` Extra allowed Host headers: ${input.allowedHosts.join(", ")}`);
523530 }
531+ if (input.authPassword) {
532+ console.log(" Basic authentication is enabled.");
533+ } else if (input.authToken) {
534+ console.log(" Token authentication is enabled.");
535+ }
524536 }
525537 console.log(`\nPress Ctrl+C to stop.`);
526538
@@ -532,6 +544,8 @@ const runDaemonSession = (input: { · 5 unmodified lines
532544 port: number;
533545 hostname: string;
534546 allowedHosts: ReadonlyArray<string>;
547+ authToken: string | undefined;
548+ authPassword: string | undefined;
535549 }) =>
536550 Effect.gen(function* () {
537551 const daemonHost = canonicalDaemonHost(input.hostname);
@@ -559,6 +573,8 @@ const runDaemonSession = (input: { · 21 unmodified lines
559573 port: input.port,
560574 hostname: input.hostname,
561575 allowedHosts: input.allowedHosts,
576+ authToken: input.authToken,
577+ authPassword: input.authPassword,
562578 embeddedWebUI,
563579 }),
564580 );
@@ -582,6 +598,11 @@ const runDaemonSession = (input: { · 17 unmodified lines
582598 });
583599
584600 console.log(`Daemon ready on http://${daemonHost}:${daemonPort}`);
601+ if (input.authPassword) {
602+ console.log("Basic authentication is enabled.");
603+ } else if (input.authToken) {
604+ console.log("Token authentication is enabled.");
605+ }
585606
586607 try {
587608 yield* waitForShutdownSignal();
@@ -871,7 +892,7 @@ const printCallBrowseHelp = (input: { · 283 unmodified lines
871892 if (input.exactTool) {
872893 console.log(`\nCallable path: ${input.exactTool.id}`);
873894 if (input.exactTool.description) {
874− console.log(input.exactTool.description);
895+ console.log(sanitizeCliOutputText(input.exactTool.description));
875896 }
876897 }
877898
@@ -927,13 +948,13 @@ const printCallLeafHelp = (input: { · 49 unmodified lines
927948 console.log(`Usage:\n ${callPath}\n ${callPath} '{"k":"v"}'`);
928949 console.log(`\nTool: ${input.tool.id}`);
929950 if (input.tool.description) {
930− console.log(input.tool.description);
951+ console.log(sanitizeCliOutputText(input.tool.description));
931952 }
932953 if (input.schema?.inputTypeScript) {
933− console.log(`\nInput:\n${input.schema.inputTypeScript}`);
954+ console.log(`\nInput:\n${sanitizeCliOutputText(input.schema.inputTypeScript)}`);
934955 }
935956 if (input.schema?.outputTypeScript) {
936− console.log(`\nOutput:\n${input.schema.outputTypeScript}`);
957+ console.log(`\nOutput:\n${sanitizeCliOutputText(input.schema.outputTypeScript)}`);
937958 }
938959 });
939960
@@ -1275,12 +1296,24 @@ const webCommand = Command.make( · 335 unmodified lines
12751296 "Additional hostname permitted in the Host header (repeatable). localhost/127.0.0.1 are always allowed.",
12761297 ),
12771298 ),
1299+ authToken: Options.string("auth-token")
1300+ .pipe(Options.optional)
1301+ .pipe(Options.withDescription("Bearer token required for requests.")),
1302+ authPassword: Options.string("auth-password")
1303+ .pipe(Options.optional)
1304+ .pipe(Options.withDescription("Basic auth password required for requests.")),
12781305 scope,
12791306 },
1280− ({ port, scope, hostname, allowedHost }) =>
1307+ ({ port, scope, hostname, allowedHost, authToken, authPassword }) =>
12811308 Effect.gen(function* () {
12821309 applyScope(scope);
1283− yield* runForegroundSession({ port, hostname, allowedHosts: allowedHost });
1310+ yield* runForegroundSession({
1311+ port,
1312+ hostname,
1313+ allowedHosts: allowedHost,
1314+ authToken: Option.getOrUndefined(authToken),
1315+ authPassword: Option.getOrUndefined(authPassword),
1316+ });
12841317 }),
12851318 ).pipe(Command.withDescription("Start a foreground web session"));
12861319
@@ -1298,6 +1331,12 @@ const daemonRunCommand = Command.make( · 11 unmodified lines
12981331 "Additional hostname permitted in the Host header (repeatable). localhost/127.0.0.1 are always allowed.",
12991332 ),
13001333 ),
1334+ authToken: Options.string("auth-token")
1335+ .pipe(Options.optional)
1336+ .pipe(Options.withDescription("Bearer token required for requests.")),
1337+ authPassword: Options.string("auth-password")
1338+ .pipe(Options.optional)
1339+ .pipe(Options.withDescription("Basic auth password required for requests.")),
13011340 foreground: Options.boolean("foreground")
13021341 .pipe(Options.withDefault(false))
13031342 .pipe(
@@ -1307,11 +1346,17 @@ const daemonRunCommand = Command.make( · 3 unmodified lines
13071346 ),
13081347 scope,
13091348 },
1310− ({ port, scope, hostname, allowedHost, foreground }) =>
1349+ ({ port, scope, hostname, allowedHost, authToken, authPassword, foreground }) =>
13111350 Effect.gen(function* () {
13121351 applyScope(scope);
13131352 if (foreground) {
1314− yield* runDaemonSession({ port, hostname, allowedHosts: allowedHost });
1353+ yield* runDaemonSession({
1354+ port,
1355+ hostname,
1356+ allowedHosts: allowedHost,
1357+ authToken: Option.getOrUndefined(authToken),
1358+ authPassword: Option.getOrUndefined(authPassword),
1359+ });
13151360 } else {
13161361 yield* runBackgroundDaemonStart({ port, hostname, allowedHosts: allowedHost });
13171362 }
apps/cli/src/tooling.test.ts+25−0
@@ -0,0 +1,25 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { sanitizeCliOutputText, shellQuoteArg } from "./tooling";
4+
5+describe("shellQuoteArg", () => {
6+ it("quotes single quotes without breaking the shell argument", () => {
7+ expect(shellQuoteArg(`{"name":"owner's repo"}`)).toBe(`'{"name":"owner'"'"'s repo"}'`);
8+ });
9+
10+ it("leaves simple values readable", () => {
11+ expect(shellQuoteArg("exec_123")).toBe("exec_123");
12+ });
13+});
14+
15+describe("sanitizeCliOutputText", () => {
16+ it("removes terminal control sequences from tool metadata", () => {
17+ expect(sanitizeCliOutputText("safe\u001b[2J\u001b]0;title\u0007 text\u0000")).toBe("safe text");
18+ });
19+
20+ it("preserves readable multiline content", () => {
21+ expect(sanitizeCliOutputText("type Input = {\n\tname: string\n}")).toBe(
22+ "type Input = {\n\tname: string\n}",
23+ );
24+ });
25+});
apps/cli/src/tooling.ts+14−0
@@ -11,6 +11,15 @@ const stripRepeatedErrorPrefix = (input: string): string => { · 10 unmodified lines
1111 return output;
1212 };
1313
14+export const sanitizeCliOutputText = (input: string): string =>
15+ input
16+ // oxlint-disable-next-line eslint/no-control-regex -- boundary: CLI output sanitizer intentionally strips OSC control sequences
17+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/gu, "")
18+ // oxlint-disable-next-line eslint/no-control-regex -- boundary: CLI output sanitizer intentionally strips ANSI control sequences
19+ .replace(/\u001b[@-_][0-?]*[ -/]*[@-~]/gu, "")
20+ // oxlint-disable-next-line eslint/no-control-regex -- boundary: CLI output sanitizer intentionally strips remaining control characters
21+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "");
22+
1423 const TOOL_PATH_TOKEN = /^[A-Za-z0-9._-]+$/;
1524
1625 const toToolPathSegments = (parts: ReadonlyArray<string>): ReadonlyArray<string> =>
@@ -262,6 +271,11 @@ export const buildResumeContentTemplate = ( · 245 unmodified lines
262271 requestedSchema: Record<string, unknown> | undefined,
263272 ): Record<string, unknown> => schemaExample(requestedSchema ?? {}) as Record<string, unknown>;
264273
274+export const shellQuoteArg = (value: string): string => {
275+ if (/^[A-Za-z0-9_/:=@%+.,-]+$/.test(value)) return value;
276+ return `'${value.replaceAll("'", "'\"'\"'")}'`;
277+};
278+
265279 const tokenizeSegment = (input: string): ReadonlyArray<string> =>
266280 input
267281 .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
apps/cli/vitest.config.ts+7−0
@@ -0,0 +1,7 @@
1+import { defineConfig } from "vitest/config";
2+
3+export default defineConfig({
4+ test: {
5+ include: ["src/**/*.test.ts"],
6+ },
7+});
apps/local/src/serve.test.ts+51−0
@@ -63,3 +63,54 @@ describe("startServer static/SPA routing", () => { · 62 unmodified lines
6363 expect(await response.text()).toContain("index-shell");
6464 });
6565 });
66+
67+describe("startServer network bind auth", () => {
68+ it("refuses non-loopback binds without a token or password", async () => {
69+ await expect(
70+ startServer({
71+ port: 0,
72+ hostname: "0.0.0.0",
73+ clientDir,
74+ handlers: {
75+ api: {
76+ handler: async () => new Response("ok"),
77+ dispose: async () => {},
78+ },
79+ mcp: {
80+ handleRequest: async () => new Response("ok"),
81+ close: async () => {},
82+ },
83+ },
84+ }),
85+ ).rejects.toThrow("non-loopback host without an auth token or password");
86+ });
87+
88+ it("requires the configured token when auth is enabled", async () => {
89+ server = await startServer({
90+ port: 0,
91+ hostname: "127.0.0.1",
92+ clientDir,
93+ authToken: "test-token",
94+ handlers: {
95+ api: {
96+ handler: async () => new Response("ok"),
97+ dispose: async () => {},
98+ },
99+ mcp: {
100+ handleRequest: async () => new Response("ok"),
101+ close: async () => {},
102+ },
103+ },
104+ });
105+
106+ const baseUrl = `http://127.0.0.1:${server.port}`;
107+ const unauthorized = await fetch(`${baseUrl}/api/health`);
108+ expect(unauthorized.status).toBe(401);
109+
110+ const authorized = await fetch(`${baseUrl}/api/health`, {
111+ headers: { authorization: "Bearer test-token" },
112+ });
113+ expect(authorized.status).toBe(200);
114+ expect(await authorized.text()).toBe("ok");
115+ });
116+});
apps/local/src/serve.ts+71−0
@@ -7,6 +7,7 @@ · 6 unmodified lines
77 * Or import: import { startServer } from "@executor-js/local/serve"
88 */
99
10+import { timingSafeEqual } from "node:crypto";
1011 import { resolve, join } from "node:path";
1112 import { readdirSync } from "node:fs";
1213 import { getServerHandlers } from "./server/main";
@@ -16,6 +17,21 @@ import { getServerHandlers } from "./server/main"; · 3 unmodified lines
1617 // ---------------------------------------------------------------------------
1718
1819 const DEFAULT_ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1"];
20+const LOOPBACK_BIND_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
21+
22+const normalizeCredential = (value: string | undefined): string | null => {
23+ const normalized = value?.trim();
24+ return normalized && normalized.length > 0 ? normalized : null;
25+};
26+
27+const safeEqual = (actual: string, expected: string): boolean => {
28+ const actualBytes = Buffer.from(actual);
29+ const expectedBytes = Buffer.from(expected);
30+ return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
31+};
32+
33+const isLoopbackBindHost = (hostname: string): boolean =>
34+ LOOPBACK_BIND_HOSTS.has(hostname.trim().toLowerCase());
1935
2036 const makeIsAllowedHost =
2137 (allowed: ReadonlySet<string>) =>
@@ -26,6 +42,39 @@ const makeIsAllowedHost = · 4 unmodified lines
2642 return allowed.has(hostname);
2743 };
2844
45+const hasBearerToken = (request: Request, token: string): boolean => {
46+ const authorization = request.headers.get("authorization");
47+ const bearer = authorization?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();
48+ return (
49+ (bearer !== undefined && safeEqual(bearer, token)) ||
50+ safeEqual(request.headers.get("x-executor-token") ?? "", token)
51+ );
52+};
53+
54+const hasBasicPassword = (request: Request, password: string): boolean => {
55+ const authorization = request.headers.get("authorization");
56+ const encoded = authorization?.match(/^Basic\s+(.+)$/i)?.[1]?.trim();
57+ if (!encoded) return false;
58+
59+ let decoded: string;
60+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Basic auth decoding accepts untrusted header bytes
61+ try {
62+ decoded = Buffer.from(encoded, "base64").toString("utf8");
63+ } catch {
64+ return false;
65+ }
66+
67+ const separator = decoded.indexOf(":");
68+ const actualPassword = separator >= 0 ? decoded.slice(separator + 1) : decoded;
69+ return safeEqual(actualPassword, password);
70+};
71+
72+const makeIsAuthorized =
73+ (auth: { token: string | null; password: string | null }) =>
74+ (request: Request): boolean =>
75+ (auth.token !== null && hasBearerToken(request, auth.token)) ||
76+ (auth.password !== null && hasBasicPassword(request, auth.password));
77+
2978 // ---------------------------------------------------------------------------
3079 // Static files
3180 // ---------------------------------------------------------------------------
@@ -88,6 +137,10 @@ export interface StartServerOptions { · 56 unmodified lines
88137 hostname?: string;
89138 /** Extra hostnames permitted in the Host header, on top of localhost/127.0.0.1. */
90139 allowedHosts?: ReadonlyArray<string>;
140+ /** Bearer token required for requests. Required for non-loopback bind addresses. */
141+ authToken?: string;
142+ /** Basic auth password required for requests. Required for non-loopback bind addresses. */
143+ authPassword?: string;
91144 /** Test hook for supplying API/MCP handlers without loading the local server graph. */
92145 handlers?: ServerHandlers;
93146 }
@@ -102,6 +155,17 @@ type ServerHandlers = Awaited<ReturnType<typeof getServerHandlers>>; · 8 unmodified lines
102155 export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> {
103156 const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);
104157 const hostname = opts.hostname ?? "127.0.0.1";
158+ const auth = {
159+ token: normalizeCredential(opts.authToken),
160+ password: normalizeCredential(opts.authPassword),
161+ };
162+ const isNetworkBind = !isLoopbackBindHost(hostname);
163+ const requiresAuth = auth.token !== null || auth.password !== null;
164+ if (isNetworkBind && !requiresAuth) {
165+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: startServer is a Promise API and rejects invalid bind options
166+ throw new Error("Refusing to listen on a non-loopback host without an auth token or password.");
167+ }
168+ const isAuthorized = makeIsAuthorized(auth);
105169 const allowedHostSet = new Set<string>([...DEFAULT_ALLOWED_HOSTS, ...(opts.allowedHosts ?? [])]);
106170 const isAllowedHost = makeIsAllowedHost(allowedHostSet);
107171 const clientDir = opts.clientDir ?? resolve(import.meta.dirname, "../dist");
@@ -134,6 +198,13 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 26 unmodified lines
134198 return new Response("Forbidden", { status: 403 });
135199 }
136200
201+ if (requiresAuth && !isAuthorized(req)) {
202+ return new Response("Unauthorized", {
203+ status: 401,
204+ headers: { "www-authenticate": 'Bearer realm="executor", Basic realm="executor"' },
205+ });
206+ }
207+
137208 const url = new URL(req.url);
138209
139210 if (url.pathname.startsWith("/mcp")) {

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t