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 | import { homedir } from "node:os"; | 2 | import { homedir } from "node:os"; |
| 3 | import { resolve } from "node:path"; | ||
| 2 | import { FileSystem, Path } from "effect"; | 4 | import { FileSystem, Path } from "effect"; |
| 3 | import type { PlatformError } from "effect/PlatformError"; | 5 | import type { PlatformError } from "effect/PlatformError"; |
| 4 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| @@ -37,7 +39,7 @@ export interface DaemonStartLock { · 32 unmodified lines | |||
| 37 | // Host normalization | 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 | export const canonicalDaemonHost = (hostname: string): string => { | 44 | export 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 | |||
| 47 | export const currentDaemonScopeId = (): string => { | 49 | export 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 | ||
| 62 | const sanitizeHostForPath = (hostname: string): string => | 64 | const sanitizeHostForPath = (hostname: string): string => |
| 63 | hostname.replaceAll(/[^a-z0-9.-]+/gi, "_"); | 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 | const daemonRecordPath = (path: Path.Path, input: { hostname: string; port: number }): string => { | 69 | const 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 | ||
| @@ -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 | // Local-host checks | 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 | export const canAutoStartLocalDaemonForHost = (hostname: string): boolean => | 53 | export const canAutoStartLocalDaemonForHost = (hostname: string): boolean => |
| 54 | LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase()); | 54 | LOCAL_DAEMON_HOSTNAMES.has(hostname.toLowerCase()); |
| @@ -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 | } |
| @@ -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 | return output; | 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 | const TOOL_PATH_TOKEN = /^[A-Za-z0-9._-]+$/; | 23 | const TOOL_PATH_TOKEN = /^[A-Za-z0-9._-]+$/; |
| 15 | 24 | ||
| 16 | const toToolPathSegments = (parts: ReadonlyArray<string>): ReadonlyArray<string> => | 25 | const 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 | ||
| 274 | export const shellQuoteArg = (value: string): string => { | ||
| 275 | if (/^[A-Za-z0-9_/:=@%+.,-]+$/.test(value)) return value; | ||
| 276 | return `'${value.replaceAll("'", "'\"'\"'")}'`; | ||
| 277 | }; | ||
| 278 | |||
| 265 | const tokenizeSegment = (input: string): ReadonlyArray<string> => | 279 | const tokenizeSegment = (input: string): ReadonlyArray<string> => |
| 266 | input | 280 | input |
| 267 | .replace(/([a-z0-9])([A-Z])/g, "$1 $2") | 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 | expect(await response.text()).toContain("index-shell"); | 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 | * Or import: import { startServer } from "@executor-js/local/serve" | 7 | * Or import: import { startServer } from "@executor-js/local/serve" |
| 8 | */ | 8 | */ |
| 9 | 9 | ||
| 10 | import { timingSafeEqual } from "node:crypto"; | ||
| 10 | import { resolve, join } from "node:path"; | 11 | import { resolve, join } from "node:path"; |
| 11 | import { readdirSync } from "node:fs"; | 12 | import { readdirSync } from "node:fs"; |
| 12 | import { getServerHandlers } from "./server/main"; | 13 | import { getServerHandlers } from "./server/main"; |
| @@ -16,6 +17,21 @@ import { getServerHandlers } from "./server/main"; · 3 unmodified lines | |||
| 16 | // --------------------------------------------------------------------------- | 17 | // --------------------------------------------------------------------------- |
| 17 | 18 | ||
| 18 | const DEFAULT_ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1"]; | 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 | const makeIsAllowedHost = | 36 | const 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 | ||
| 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 | // Static files | 79 | // 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 | |||
| 102 | export async function startServer(opts: StartServerOptions = {}): Promise<ServerInstance> { | 155 | export 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")) { |