Harden local server boundaries
Unified · Split
| @@ -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 | +}); | |
| @@ -1,4 +1,6 @@ | ||
| 1 | +import { createHash } from "node:crypto"; | |
| 1 | 2 | import { homedir } from "node:os"; |
| 3 | +import { resolve } from "node:path"; | |
| 2 | 4 | import { FileSystem, Path } from "effect"; |
| 3 | 5 | import type { PlatformError } from "effect/PlatformError"; |
| 4 | 6 | import * as Effect from "effect/Effect"; |
| @@ -37,7 +39,7 @@ export interface DaemonStartLock { · 32 unmodified lines | ||
| 37 | 39 | // Host normalization |
| 38 | 40 | // --------------------------------------------------------------------------- |
| 39 | 41 | |
| 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]"]); | |
| 41 | 43 | |
| 42 | 44 | export const canonicalDaemonHost = (hostname: string): string => { |
| 43 | 45 | const normalized = hostname.trim().toLowerCase(); |
| @@ -47,9 +49,9 @@ export const canonicalDaemonHost = (hostname: string): string => { · 3 unmodified lines | ||
| 47 | 49 | export const currentDaemonScopeId = (): string => { |
| 48 | 50 | const explicitScope = process.env.EXECUTOR_SCOPE_DIR?.trim(); |
| 49 | 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 | |
| 62 | 64 | const sanitizeHostForPath = (hostname: string): string => |
| 63 | 65 | 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); | |
| 65 | 68 | |
| 66 | 69 | const daemonRecordPath = (path: Path.Path, input: { hostname: string; port: number }): string => { |
| 67 | 70 | const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname)); |
| @@ -73,7 +76,7 @@ const daemonPointerPath = ( · 5 unmodified lines | ||
| 73 | 76 | input: { hostname: string; scopeId: string }, |
| 74 | 77 | ): string => { |
| 75 | 78 | const host = sanitizeHostForPath(canonicalDaemonHost(input.hostname)); |
| 76 | − const scope = sanitizeScopeForPath(input.scopeId); | |
| 79 | + const scope = scopeKeyForPath(input.scopeId); | |
| 77 | 80 | return path.join(resolveDaemonDataDir(path), `daemon-active-${host}-${scope}.json`); |
| 78 | 81 | }; |
| 79 | 82 | |
| @@ -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 | +}); | |
| @@ -48,7 +48,7 @@ export const parseDaemonBaseUrl = (baseUrl: string, defaultPort: number): Parsed · 47 unmodified lines | ||
| 48 | 48 | // Local-host checks |
| 49 | 49 | // --------------------------------------------------------------------------- |
| 50 | 50 | |
| 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]"]); | |
| 52 | 52 | |
| 53 | 53 | export const canAutoStartLocalDaemonForHost = (hostname: string): boolean => |
| 54 | 54 | LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase()); |
| @@ -97,6 +97,8 @@ import { · 96 unmodified lines | ||
| 97 | 97 | inspectToolPath, |
| 98 | 98 | normalizeCliErrorText, |
| 99 | 99 | parseJsonObjectInput, |
| 100 | + sanitizeCliOutputText, | |
| 101 | + shellQuoteArg, | |
| 100 | 102 | } from "./tooling"; |
| 101 | 103 | |
| 102 | 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 | 461 | console.log(`\nRequested schema:\n${JSON.stringify(requestedSchema, null, 2)}`); |
| 460 | 462 | } |
| 461 | 463 | const template = buildResumeContentTemplate(requestedSchema); |
| 464 | + const contentArg = shellQuoteArg(JSON.stringify(template)); | |
| 462 | 465 | console.log("\nResume commands:"); |
| 463 | − console.log(` ${commandPrefix} --action accept --content '${JSON.stringify(template)}'`); | |
| 466 | + console.log(` ${commandPrefix} --action accept --content ${contentArg}`); | |
| 464 | 467 | console.log(` ${commandPrefix} --action decline`); |
| 465 | 468 | console.log(` ${commandPrefix} --action cancel`); |
| 466 | 469 | } else { |
| @@ -496,6 +499,8 @@ const runForegroundSession = (input: { · 29 unmodified lines | ||
| 496 | 499 | port: number; |
| 497 | 500 | hostname: string; |
| 498 | 501 | allowedHosts: ReadonlyArray<string>; |
| 502 | + authToken: string | undefined; | |
| 503 | + authPassword: string | undefined; | |
| 499 | 504 | }) => |
| 500 | 505 | Effect.gen(function* () { |
| 501 | 506 | const server = yield* Effect.promise(() => |
| @@ -503,6 +508,8 @@ const runForegroundSession = (input: { · 1 unmodified line | ||
| 503 | 508 | port: input.port, |
| 504 | 509 | hostname: input.hostname, |
| 505 | 510 | allowedHosts: input.allowedHosts, |
| 511 | + authToken: input.authToken, | |
| 512 | + authPassword: input.authPassword, | |
| 506 | 513 | embeddedWebUI, |
| 507 | 514 | }), |
| 508 | 515 | ); |
| @@ -521,6 +528,11 @@ const runForegroundSession = (input: { · 12 unmodified lines | ||
| 521 | 528 | if (input.allowedHosts.length > 0) { |
| 522 | 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 | 537 | console.log(`\nPress Ctrl+C to stop.`); |
| 526 | 538 | |
| @@ -532,6 +544,8 @@ const runDaemonSession = (input: { · 5 unmodified lines | ||
| 532 | 544 | port: number; |
| 533 | 545 | hostname: string; |
| 534 | 546 | allowedHosts: ReadonlyArray<string>; |
| 547 | + authToken: string | undefined; | |
| 548 | + authPassword: string | undefined; | |
| 535 | 549 | }) => |
| 536 | 550 | Effect.gen(function* () { |
| 537 | 551 | const daemonHost = canonicalDaemonHost(input.hostname); |
| @@ -559,6 +573,8 @@ const runDaemonSession = (input: { · 21 unmodified lines | ||
| 559 | 573 | port: input.port, |
| 560 | 574 | hostname: input.hostname, |
| 561 | 575 | allowedHosts: input.allowedHosts, |
| 576 | + authToken: input.authToken, | |
| 577 | + authPassword: input.authPassword, | |
| 562 | 578 | embeddedWebUI, |
| 563 | 579 | }), |
| 564 | 580 | ); |
| @@ -582,6 +598,11 @@ const runDaemonSession = (input: { · 17 unmodified lines | ||
| 582 | 598 | }); |
| 583 | 599 | |
| 584 | 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 | 607 | try { |
| 587 | 608 | yield* waitForShutdownSignal(); |
| @@ -871,7 +892,7 @@ const printCallBrowseHelp = (input: { · 283 unmodified lines | ||
| 871 | 892 | if (input.exactTool) { |
| 872 | 893 | console.log(`\nCallable path: ${input.exactTool.id}`); |
| 873 | 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 | 948 | console.log(`Usage:\n ${callPath}\n ${callPath} '{"k":"v"}'`); |
| 928 | 949 | console.log(`\nTool: ${input.tool.id}`); |
| 929 | 950 | if (input.tool.description) { |
| 930 | − console.log(input.tool.description); | |
| 951 | + console.log(sanitizeCliOutputText(input.tool.description)); | |
| 931 | 952 | } |
| 932 | 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 | 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 | 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 | 1305 | scope, |
| 1279 | 1306 | }, |
| 1280 | − ({ port, scope, hostname, allowedHost }) => | |
| 1307 | + ({ port, scope, hostname, allowedHost, authToken, authPassword }) => | |
| 1281 | 1308 | Effect.gen(function* () { |
| 1282 | 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 | 1318 | ).pipe(Command.withDescription("Start a foreground web session")); |
| 1286 | 1319 | |
| @@ -1298,6 +1331,12 @@ const daemonRunCommand = Command.make( · 11 unmodified lines | ||
| 1298 | 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 | 1340 | foreground: Options.boolean("foreground") |
| 1302 | 1341 | .pipe(Options.withDefault(false)) |
| 1303 | 1342 | .pipe( |
| @@ -1307,11 +1346,17 @@ const daemonRunCommand = Command.make( · 3 unmodified lines | ||
| 1307 | 1346 | ), |
| 1308 | 1347 | scope, |
| 1309 | 1348 | }, |
| 1310 | − ({ port, scope, hostname, allowedHost, foreground }) => | |
| 1349 | + ({ port, scope, hostname, allowedHost, authToken, authPassword, foreground }) => | |
| 1311 | 1350 | Effect.gen(function* () { |
| 1312 | 1351 | applyScope(scope); |
| 1313 | 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 | 1360 | } else { |
| 1316 | 1361 | yield* runBackgroundDaemonStart({ port, hostname, allowedHosts: allowedHost }); |
| 1317 | 1362 | } |
| @@ -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 | +}); | |
| @@ -11,6 +11,15 @@ const stripRepeatedErrorPrefix = (input: string): string => { · 10 unmodified lines | ||
| 11 | 11 | return output; |
| 12 | 12 | }; |
| 13 | 13 | |
| 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 | + | |
| 14 | 23 | const TOOL_PATH_TOKEN = /^[A-Za-z0-9._-]+$/; |
| 15 | 24 | |
| 16 | 25 | const toToolPathSegments = (parts: ReadonlyArray<string>): ReadonlyArray<string> => |
| @@ -262,6 +271,11 @@ export const buildResumeContentTemplate = ( · 245 unmodified lines | ||
| 262 | 271 | requestedSchema: Record<string, unknown> | undefined, |
| 263 | 272 | ): Record<string, unknown> => schemaExample(requestedSchema ?? {}) as Record<string, unknown>; |
| 264 | 273 | |
| 274 | +export const shellQuoteArg = (value: string): string => { | |
| 275 | + if (/^[A-Za-z0-9_/:=@%+.,-]+$/.test(value)) return value; | |
| 276 | + return `'${value.replaceAll("'", "'\"'\"'")}'`; | |
| 277 | +}; | |
| 278 | + | |
| 265 | 279 | const tokenizeSegment = (input: string): ReadonlyArray<string> => |
| 266 | 280 | input |
| 267 | 281 | .replace(/([a-z0-9])([A-Z])/g, "$1 $2") |
| @@ -0,0 +1,7 @@ | ||
| 1 | +import { defineConfig } from "vitest/config"; | |
| 2 | + | |
| 3 | +export default defineConfig({ | |
| 4 | + test: { | |
| 5 | + include: ["src/**/*.test.ts"], | |
| 6 | + }, | |
| 7 | +}); | |
| @@ -63,3 +63,54 @@ describe("startServer static/SPA routing", () => { · 62 unmodified lines | ||
| 63 | 63 | expect(await response.text()).toContain("index-shell"); |
| 64 | 64 | }); |
| 65 | 65 | }); |
| 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 | +}); | |
| @@ -7,6 +7,7 @@ · 6 unmodified lines | ||
| 7 | 7 | * Or import: import { startServer } from "@executor-js/local/serve" |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | +import { timingSafeEqual } from "node:crypto"; | |
| 10 | 11 | import { resolve, join } from "node:path"; |
| 11 | 12 | import { readdirSync } from "node:fs"; |
| 12 | 13 | import { getServerHandlers } from "./server/main"; |
| @@ -16,6 +17,21 @@ import { getServerHandlers } from "./server/main"; · 3 unmodified lines | ||
| 16 | 17 | // --------------------------------------------------------------------------- |
| 17 | 18 | |
| 18 | 19 | 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()); | |
| 19 | 35 | |
| 20 | 36 | const makeIsAllowedHost = |
| 21 | 37 | (allowed: ReadonlySet<string>) => |
| @@ -26,6 +42,39 @@ const makeIsAllowedHost = · 4 unmodified lines | ||
| 26 | 42 | return allowed.has(hostname); |
| 27 | 43 | }; |
| 28 | 44 | |
| 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 | + | |
| 29 | 78 | // --------------------------------------------------------------------------- |
| 30 | 79 | // Static files |
| 31 | 80 | // --------------------------------------------------------------------------- |
| @@ -88,6 +137,10 @@ export interface StartServerOptions { · 56 unmodified lines | ||
| 88 | 137 | hostname?: string; |
| 89 | 138 | /** Extra hostnames permitted in the Host header, on top of localhost/127.0.0.1. */ |
| 90 | 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 | 144 | /** Test hook for supplying API/MCP handlers without loading the local server graph. */ |
| 92 | 145 | handlers?: ServerHandlers; |
| 93 | 146 | } |
| @@ -102,6 +155,17 @@ type ServerHandlers = Awaited<ReturnType<typeof getServerHandlers>>; · 8 unmodified lines | ||
| 102 | 155 | export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> { |
| 103 | 156 | const port = opts.port ?? parseInt(process.env.PORT ?? "4788", 10); |
| 104 | 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 | 169 | const allowedHostSet = new Set<string>([...DEFAULT_ALLOWED_HOSTS, ...(opts.allowedHosts ?? [])]); |
| 106 | 170 | const isAllowedHost = makeIsAllowedHost(allowedHostSet); |
| 107 | 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 | 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 | 208 | const url = new URL(req.url); |
| 138 | 209 | |
| 139 | 210 | if (url.pathname.startsWith("/mcp")) { |