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 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { BunServices } from "@effect/platform-bun";
3import { mkdtempSync, rmSync } from "node:fs";
4import { tmpdir } from "node:os";
5import { join } from "node:path";
6import * as Effect from "effect/Effect";
7
8import {
9 canonicalDaemonHost,
10 currentDaemonScopeId,
11 readDaemonPointer,
12 writeDaemonPointer,
13} from "./daemon-state";
14
15const previousDataDir = process.env.EXECUTOR_DATA_DIR;
16const previousScopeDir = process.env.EXECUTOR_SCOPE_DIR;
17const originalCwd = process.cwd();
18
19afterEach(() => {
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
35describe("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 @@
1import { createHash } from "node:crypto";
1import { homedir } from "node:os";2import { homedir } from "node:os";
3import { resolve } from "node:path";
2import { FileSystem, Path } from "effect";4import { FileSystem, Path } from "effect";
3import type { PlatformError } from "effect/PlatformError";5import type { PlatformError } from "effect/PlatformError";
4import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
@@ -37,7 +39,7 @@ export interface DaemonStartLock { · 32 unmodified lines
37// Host normalization39// Host normalization
38// ---------------------------------------------------------------------------40// ---------------------------------------------------------------------------
39 41
40const LOCAL_HOST_ALIASES = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);42const LOCAL_HOST_ALIASES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
41 43
42export const canonicalDaemonHost = (hostname: string): string => {44export const canonicalDaemonHost = (hostname: string): string => {
43 const normalized = hostname.trim().toLowerCase();45 const normalized = hostname.trim().toLowerCase();
@@ -47,9 +49,9 @@ export const canonicalDaemonHost = (hostname: string): string => { · 3 unmodified lines
47export const currentDaemonScopeId = (): string => {49export const currentDaemonScopeId = (): string => {
48 const explicitScope = process.env.EXECUTOR_SCOPE_DIR?.trim();50 const explicitScope = process.env.EXECUTOR_SCOPE_DIR?.trim();
49 if (explicitScope && explicitScope.length > 0) {51 if (explicitScope && explicitScope.length > 0) {
50 return `scope:${explicitScope}`;52 return `scope:${resolve(explicitScope)}`;
51 }53 }
52 return `cwd:${process.cwd()}`;54 return `cwd:${resolve(process.cwd())}`;
53};55};
54 56
55// ---------------------------------------------------------------------------57// ---------------------------------------------------------------------------
@@ -61,7 +63,8 @@ const resolveDaemonDataDir = (path: Path.Path): string => · 5 unmodified lines
61 63
62const sanitizeHostForPath = (hostname: string): string =>64const sanitizeHostForPath = (hostname: string): string =>
63 hostname.replaceAll(/[^a-z0-9.-]+/gi, "_");65 hostname.replaceAll(/[^a-z0-9.-]+/gi, "_");
64const sanitizeScopeForPath = (scopeId: string): string => scopeId.replaceAll(/[^a-z0-9.-]+/gi, "_");66const scopeKeyForPath = (scopeId: string): string =>
67 createHash("sha256").update(scopeId).digest("hex").slice(0, 24);
65 68
66const daemonRecordPath = (path: Path.Path, input: { hostname: string; port: number }): string => {69const daemonRecordPath = (path: Path.Path, input: { hostname: string; port: number }): string => {
67 const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname));70 const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname));
@@ -73,7 +76,7 @@ const daemonPointerPath = ( · 5 unmodified lines
73 input: { hostname: string; scopeId: string },76 input: { hostname: string; scopeId: string },
74): string => {77): string => {
75 const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname));78 const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname));
76 const scope = sanitizeScopeForPath(input.scopeId);79 const scope = scopeKeyForPath(input.scopeId);
77 return path.join(resolveDaemonDataDir(path), `daemon-active-${host}-${scope}.json`);80 return path.join(resolveDaemonDataDir(path), `daemon-active-${host}-${scope}.json`);
78};81};
79 82
apps/cli/src/daemon.test.ts+16−0
@@ -0,0 +1,16 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { canAutoStartLocalDaemonForHost } from "./daemon";
4
5describe("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
48// Local-host checks48// Local-host checks
49// ---------------------------------------------------------------------------49// ---------------------------------------------------------------------------
50 50
51const LOCAL_DAEMON_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);51const LOCAL_DAEMON_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
52 52
53export const canAutoStartLocalDaemonForHost = (hostname: string): boolean =>53export const canAutoStartLocalDaemonForHost = (hostname: string): boolean =>
54 LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase());54 LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase());
apps/cli/src/main.ts+54−9
@@ -97,6 +97,8 @@ import { · 96 unmodified lines
97 inspectToolPath,97 inspectToolPath,
98 normalizeCliErrorText,98 normalizeCliErrorText,
99 parseJsonObjectInput,99 parseJsonObjectInput,
100 sanitizeCliOutputText,
101 shellQuoteArg,
100} from "./tooling";102} from "./tooling";
101 103
102// Embedded web UI — baked into compiled binaries via `with { type: "file" }`104// 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
459 console.log(`\nRequested schema:\n${JSON.stringify(requestedSchema, null, 2)}`);461 console.log(`\nRequested schema:\n${JSON.stringify(requestedSchema, null, 2)}`);
460 }462 }
461 const template = buildResumeContentTemplate(requestedSchema);463 const template = buildResumeContentTemplate(requestedSchema);
464 const contentArg = shellQuoteArg(JSON.stringify(template));
462 console.log("\nResume commands:");465 console.log("\nResume commands:");
463 console.log(` ${commandPrefix} --action accept --content '${JSON.stringify(template)}'`);466 console.log(` ${commandPrefix} --action accept --content ${contentArg}`);
464 console.log(` ${commandPrefix} --action decline`);467 console.log(` ${commandPrefix} --action decline`);
465 console.log(` ${commandPrefix} --action cancel`);468 console.log(` ${commandPrefix} --action cancel`);
466 } else {469 } else {
@@ -496,6 +499,8 @@ const runForegroundSession = (input: { · 29 unmodified lines
496 port: number;499 port: number;
497 hostname: string;500 hostname: string;
498 allowedHosts: ReadonlyArray<string>;501 allowedHosts: ReadonlyArray<string>;
502 authToken: string | undefined;
503 authPassword: string | undefined;
499}) =>504}) =>
500 Effect.gen(function* () {505 Effect.gen(function* () {
501 const server = yield* Effect.promise(() =>506 const server = yield* Effect.promise(() =>
@@ -503,6 +508,8 @@ const runForegroundSession = (input: { · 1 unmodified line
503 port: input.port,508 port: input.port,
504 hostname: input.hostname,509 hostname: input.hostname,
505 allowedHosts: input.allowedHosts,510 allowedHosts: input.allowedHosts,
511 authToken: input.authToken,
512 authPassword: input.authPassword,
506 embeddedWebUI,513 embeddedWebUI,
507 }),514 }),
508 );515 );
@@ -521,6 +528,11 @@ const runForegroundSession = (input: { · 12 unmodified lines
521 if (input.allowedHosts.length > 0) {528 if (input.allowedHosts.length > 0) {
522 console.log(` Extra allowed Host headers: ${input.allowedHosts.join(", ")}`);529 console.log(` Extra allowed Host headers: ${input.allowedHosts.join(", ")}`);
523 }530 }
531 if (input.authPassword) {
532 console.log(" Basic authentication is enabled.");
533 } else if (input.authToken) {
534 console.log(" Token authentication is enabled.");
535 }
524 }536 }
525 console.log(`\nPress Ctrl+C to stop.`);537 console.log(`\nPress Ctrl+C to stop.`);
526 538
@@ -532,6 +544,8 @@ const runDaemonSession = (input: { · 5 unmodified lines
532 port: number;544 port: number;
533 hostname: string;545 hostname: string;
534 allowedHosts: ReadonlyArray<string>;546 allowedHosts: ReadonlyArray<string>;
547 authToken: string | undefined;
548 authPassword: string | undefined;
535}) =>549}) =>
536 Effect.gen(function* () {550 Effect.gen(function* () {
537 const daemonHost = canonicalDaemonHost(input.hostname);551 const daemonHost = canonicalDaemonHost(input.hostname);
@@ -559,6 +573,8 @@ const runDaemonSession = (input: { · 21 unmodified lines
559 port: input.port,573 port: input.port,
560 hostname: input.hostname,574 hostname: input.hostname,
561 allowedHosts: input.allowedHosts,575 allowedHosts: input.allowedHosts,
576 authToken: input.authToken,
577 authPassword: input.authPassword,
562 embeddedWebUI,578 embeddedWebUI,
563 }),579 }),
564 );580 );
@@ -582,6 +598,11 @@ const runDaemonSession = (input: { · 17 unmodified lines
582 });598 });
583 599
584 console.log(`Daemon ready on http://${daemonHost}:${daemonPort}`);600 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 }
585 606
586 try {607 try {
587 yield* waitForShutdownSignal();608 yield* waitForShutdownSignal();
@@ -871,7 +892,7 @@ const printCallBrowseHelp = (input: { · 283 unmodified lines
871 if (input.exactTool) {892 if (input.exactTool) {
872 console.log(`\nCallable path: ${input.exactTool.id}`);893 console.log(`\nCallable path: ${input.exactTool.id}`);
873 if (input.exactTool.description) {894 if (input.exactTool.description) {
874 console.log(input.exactTool.description);895 console.log(sanitizeCliOutputText(input.exactTool.description));
875 }896 }
876 }897 }
877 898
@@ -927,13 +948,13 @@ const printCallLeafHelp = (input: { · 49 unmodified lines
927 console.log(`Usage:\n ${callPath}\n ${callPath} '{"k":"v"}'`);948 console.log(`Usage:\n ${callPath}\n ${callPath} '{"k":"v"}'`);
928 console.log(`\nTool: ${input.tool.id}`);949 console.log(`\nTool: ${input.tool.id}`);
929 if (input.tool.description) {950 if (input.tool.description) {
930 console.log(input.tool.description);951 console.log(sanitizeCliOutputText(input.tool.description));
931 }952 }
932 if (input.schema?.inputTypeScript) {953 if (input.schema?.inputTypeScript) {
933 console.log(`\nInput:\n${input.schema.inputTypeScript}`);954 console.log(`\nInput:\n${sanitizeCliOutputText(input.schema.inputTypeScript)}`);
934 }955 }
935 if (input.schema?.outputTypeScript) {956 if (input.schema?.outputTypeScript) {
936 console.log(`\nOutput:\n${input.schema.outputTypeScript}`);957 console.log(`\nOutput:\n${sanitizeCliOutputText(input.schema.outputTypeScript)}`);
937 }958 }
938 });959 });
939 960
@@ -1275,12 +1296,24 @@ const webCommand = Command.make( · 335 unmodified lines
1275 "Additional hostname permitted in the Host header (repeatable). localhost/127.0.0.1 are always allowed.",1296 "Additional hostname permitted in the Host header (repeatable). localhost/127.0.0.1 are always allowed.",
1276 ),1297 ),
1277 ),1298 ),
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.")),
1278 scope,1305 scope,
1279 },1306 },
1280 ({ port, scope, hostname, allowedHost }) =>1307 ({ port, scope, hostname, allowedHost, authToken, authPassword }) =>
1281 Effect.gen(function* () {1308 Effect.gen(function* () {
1282 applyScope(scope);1309 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 });
1284 }),1317 }),
1285).pipe(Command.withDescription("Start a foreground web session"));1318).pipe(Command.withDescription("Start a foreground web session"));
1286 1319
@@ -1298,6 +1331,12 @@ const daemonRunCommand = Command.make( · 11 unmodified lines
1298 "Additional hostname permitted in the Host header (repeatable). localhost/127.0.0.1 are always allowed.",1331 "Additional hostname permitted in the Host header (repeatable). localhost/127.0.0.1 are always allowed.",
1299 ),1332 ),
1300 ),1333 ),
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.")),
1301 foreground: Options.boolean("foreground")1340 foreground: Options.boolean("foreground")
1302 .pipe(Options.withDefault(false))1341 .pipe(Options.withDefault(false))
1303 .pipe(1342 .pipe(
@@ -1307,11 +1346,17 @@ const daemonRunCommand = Command.make( · 3 unmodified lines
1307 ),1346 ),
1308 scope,1347 scope,
1309 },1348 },
1310 ({ port, scope, hostname, allowedHost, foreground }) =>1349 ({ port, scope, hostname, allowedHost, authToken, authPassword, foreground }) =>
1311 Effect.gen(function* () {1350 Effect.gen(function* () {
1312 applyScope(scope);1351 applyScope(scope);
1313 if (foreground) {1352 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 });
1315 } else {1360 } else {
1316 yield* runBackgroundDaemonStart({ port, hostname, allowedHosts: allowedHost });1361 yield* runBackgroundDaemonStart({ port, hostname, allowedHosts: allowedHost });
1317 }1362 }
apps/cli/src/tooling.test.ts+25−0
@@ -0,0 +1,25 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { sanitizeCliOutputText, shellQuoteArg } from "./tooling";
4
5describe("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
15describe("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
11 return output;11 return output;
12};12};
13 13
14export 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
14const TOOL_PATH_TOKEN = /^[A-Za-z0-9._-]+$/;23const TOOL_PATH_TOKEN = /^[A-Za-z0-9._-]+$/;
15 24
16const toToolPathSegments = (parts: ReadonlyArray<string>): ReadonlyArray<string> =>25const toToolPathSegments = (parts: ReadonlyArray<string>): ReadonlyArray<string> =>
@@ -262,6 +271,11 @@ export const buildResumeContentTemplate = ( · 245 unmodified lines
262 requestedSchema: Record<string, unknown> | undefined,271 requestedSchema: Record<string, unknown> | undefined,
263): Record<string, unknown> => schemaExample(requestedSchema ?? {}) as Record<string, unknown>;272): Record<string, unknown> => schemaExample(requestedSchema ?? {}) as Record<string, unknown>;
264 273
274export const shellQuoteArg = (value: string): string => {
275 if (/^[A-Za-z0-9_/:=@%+.,-]+$/.test(value)) return value;
276 return `'${value.replaceAll("'", "'\"'\"'")}'`;
277};
278
265const tokenizeSegment = (input: string): ReadonlyArray<string> =>279const tokenizeSegment = (input: string): ReadonlyArray<string> =>
266 input280 input
267 .replace(/([a-z0-9])([A-Z])/g, "$1 $2")281 .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
apps/cli/vitest.config.ts+7−0
@@ -0,0 +1,7 @@
1import { defineConfig } from "vitest/config";
2
3export 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
63 expect(await response.text()).toContain("index-shell");63 expect(await response.text()).toContain("index-shell");
64 });64 });
65});65});
66
67describe("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
7 * Or import: import { startServer } from "@executor-js/local/serve"7 * Or import: import { startServer } from "@executor-js/local/serve"
8 */8 */
9 9
10import { timingSafeEqual } from "node:crypto";
10import { resolve, join } from "node:path";11import { resolve, join } from "node:path";
11import { readdirSync } from "node:fs";12import { readdirSync } from "node:fs";
12import { getServerHandlers } from "./server/main";13import { getServerHandlers } from "./server/main";
@@ -16,6 +17,21 @@ import { getServerHandlers } from "./server/main"; · 3 unmodified lines
16// ---------------------------------------------------------------------------17// ---------------------------------------------------------------------------
17 18
18const DEFAULT_ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1"];19const DEFAULT_ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1"];
20const LOOPBACK_BIND_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
21
22const normalizeCredential = (value: string | undefined): string | null => {
23 const normalized = value?.trim();
24 return normalized && normalized.length > 0 ? normalized : null;
25};
26
27const 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
33const isLoopbackBindHost = (hostname: string): boolean =>
34 LOOPBACK_BIND_HOSTS.has(hostname.trim().toLowerCase());
19 35
20const makeIsAllowedHost =36const makeIsAllowedHost =
21 (allowed: ReadonlySet<string>) =>37 (allowed: ReadonlySet<string>) =>
@@ -26,6 +42,39 @@ const makeIsAllowedHost = · 4 unmodified lines
26 return allowed.has(hostname);42 return allowed.has(hostname);
27 };43 };
28 44
45const 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
54const 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
72const 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
29// ---------------------------------------------------------------------------78// ---------------------------------------------------------------------------
30// Static files79// Static files
31// ---------------------------------------------------------------------------80// ---------------------------------------------------------------------------
@@ -88,6 +137,10 @@ export interface StartServerOptions { · 56 unmodified lines
88 hostname?: string;137 hostname?: string;
89 /** Extra hostnames permitted in the Host header, on top of localhost/127.0.0.1. */138 /** Extra hostnames permitted in the Host header, on top of localhost/127.0.0.1. */
90 allowedHosts?: ReadonlyArray<string>;139 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;
91 /** Test hook for supplying API/MCP handlers without loading the local server graph. */144 /** Test hook for supplying API/MCP handlers without loading the local server graph. */
92 handlers?: ServerHandlers;145 handlers?: ServerHandlers;
93}146}
@@ -102,6 +155,17 @@ type ServerHandlers = Awaited<ReturnType<typeof getServerHandlers>>; · 8 unmodified lines
102export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> {155export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> {
103 const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);156 const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10);
104 const hostname = opts.hostname ?? "127.0.0.1";157 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);
105 const allowedHostSet = new Set<string>([...DEFAULT_ALLOWED_HOSTS, ...(opts.allowedHosts ?? [])]);169 const allowedHostSet = new Set<string>([...DEFAULT_ALLOWED_HOSTS, ...(opts.allowedHosts ?? [])]);
106 const isAllowedHost = makeIsAllowedHost(allowedHostSet);170 const isAllowedHost = makeIsAllowedHost(allowedHostSet);
107 const clientDir = opts.clientDir ?? resolve(import.meta.dirname, "../dist");171 const clientDir = opts.clientDir ?? resolve(import.meta.dirname, "../dist");
@@ -134,6 +198,13 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 26 unmodified lines
134 return new Response("Forbidden", { status: 403 });198 return new Response("Forbidden", { status: 403 });
135 }199 }
136 200
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
137 const url = new URL(req.url);208 const url = new URL(req.url);
138 209
139 if (url.pathname.startsWith("/mcp")) {210 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