From 48a42cc03d3e81f0d638d63c9813f4a69decf9f2 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 16 Jul 2026 12:24:43 -0400 Subject: [PATCH 1/2] fix(spector): prevent unauthenticated remote server stop Bind the mock server to the loopback interface (127.0.0.1) by default and reject /.admin/stop requests from non-loopback sources. Adds a --host option to opt into binding on all interfaces (e.g. 0.0.0.0 inside a container). --- ...spector-stop-loopback-2026-7-16-12-15-0.md | 7 ++++++ packages/spector/docs/using-spector.md | 7 ++++++ packages/spector/src/actions/serve.ts | 3 +++ packages/spector/src/app/app.ts | 2 +- packages/spector/src/app/config.ts | 5 ++++ packages/spector/src/cli/cli.ts | 15 +++++++++++ packages/spector/src/routes/admin.ts | 14 ++++++++++- packages/spector/src/server/server.ts | 16 ++++++++++-- packages/spector/src/utils/index.ts | 1 + .../spector/src/utils/network-utils.test.ts | 25 +++++++++++++++++++ packages/spector/src/utils/network-utils.ts | 23 +++++++++++++++++ 11 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 .chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md create mode 100644 packages/spector/src/utils/network-utils.test.ts create mode 100644 packages/spector/src/utils/network-utils.ts diff --git a/.chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md b/.chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md new file mode 100644 index 00000000000..71be18994c4 --- /dev/null +++ b/.chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/spector" +--- + +Restrict the mock server to the loopback interface (`127.0.0.1`) by default and only accept the unauthenticated `/.admin/stop` signal from the local host. This prevents a network-reachable client from stopping the server. Use `--host 0.0.0.0` to explicitly listen on all interfaces when needed. diff --git a/packages/spector/docs/using-spector.md b/packages/spector/docs/using-spector.md index 96651da9bc6..cb565a09fd3 100644 --- a/packages/spector/docs/using-spector.md +++ b/packages/spector/docs/using-spector.md @@ -34,6 +34,13 @@ tsp-spector serve ./path/to/scenarios --port 1234 tsp-spector serve ./path/to/scenarios --coverageFile ./path/to/spector-coverage.json ``` +> [!NOTE] +> By default the server only binds to the loopback interface (`127.0.0.1`), so it is not reachable from other hosts on the network. If you need to reach the server from another machine or container, opt in explicitly with `--host 0.0.0.0`. Note that the mock server exposes an unauthenticated stop endpoint; even when bound to a public interface, that endpoint only accepts requests from the local host. +> +> ```bash +> tsp-spector serve ./path/to/scenarios --host 0.0.0.0 +> ``` + Alternative to start in background ```bash diff --git a/packages/spector/src/actions/serve.ts b/packages/spector/src/actions/serve.ts index 87a55a0da74..18e22a1b0da 100644 --- a/packages/spector/src/actions/serve.ts +++ b/packages/spector/src/actions/serve.ts @@ -9,6 +9,7 @@ export interface ServeConfig { scenariosPath: string | string[]; coverageFile: string; port: number; + host?: string; } export interface StopConfig { @@ -27,6 +28,7 @@ export async function serve(config: ServeConfig) { const server = new MockApiApp({ port: config.port, + host: config.host, scenarioPath: config.scenariosPath, coverageFile: config.coverageFile, }); @@ -48,6 +50,7 @@ export async function startInBackground(config: ServeConfig) { ...scenariosPath, "--port", config.port.toString(), + ...(config.host ? ["--host", config.host] : []), "--coverageFile", config.coverageFile, ], diff --git a/packages/spector/src/app/app.ts b/packages/spector/src/app/app.ts index a6b5cc2c62a..fb0a2372b8a 100644 --- a/packages/spector/src/app/app.ts +++ b/packages/spector/src/app/app.ts @@ -32,7 +32,7 @@ export class MockApiApp { private resolverConfig!: ResolverConfig; constructor(private config: ApiMockAppConfig) { - this.server = new MockApiServer({ port: config.port }); + this.server = new MockApiServer({ port: config.port, host: config.host }); this.coverageTracker = new CoverageTracker(config.coverageFile); } diff --git a/packages/spector/src/app/config.ts b/packages/spector/src/app/config.ts index 12329741855..856346971d2 100644 --- a/packages/spector/src/app/config.ts +++ b/packages/spector/src/app/config.ts @@ -13,4 +13,9 @@ export interface ApiMockAppConfig { * Coverage file Path. */ coverageFile: string; + + /** + * Host/interface the server should bind to. Defaults to loopback only. + */ + host?: string; } diff --git a/packages/spector/src/cli/cli.ts b/packages/spector/src/cli/cli.ts index f0ecef7be8d..1d7f260619a 100644 --- a/packages/spector/src/cli/cli.ts +++ b/packages/spector/src/cli/cli.ts @@ -10,6 +10,7 @@ import { uploadCoverageReport } from "../actions/upload-coverage-report.js"; import { uploadScenarioManifest } from "../actions/upload-scenario-manifest.js"; import { validateMockApis } from "../actions/validate-mock-apis.js"; import { logger } from "../logger.js"; +import { DEFAULT_HOST } from "../server/server.js"; import { getCommit } from "../utils/misc-utils.js"; export const DEFAULT_PORT = 3000; @@ -108,6 +109,12 @@ async function main() { description: "Port where to host the server", default: DEFAULT_PORT, }) + .option("host", { + type: "string", + description: + "Host/interface to bind to. Defaults to loopback (127.0.0.1) only. Use 0.0.0.0 to listen on all interfaces.", + default: DEFAULT_HOST, + }) .option("coverageFile", { type: "string", description: "Path to the coverage file.", @@ -118,6 +125,7 @@ async function main() { startInBackground({ scenariosPath: args.scenariosPaths, port: args.port, + host: args.host, coverageFile: args.coverageFile, }), ) @@ -152,6 +160,12 @@ async function main() { description: "Port where to host the server", default: DEFAULT_PORT, }) + .option("host", { + type: "string", + description: + "Host/interface to bind to. Defaults to loopback (127.0.0.1) only. Use 0.0.0.0 to listen on all interfaces.", + default: DEFAULT_HOST, + }) .option("coverageFile", { type: "string", description: "Path to the coverage file.", @@ -162,6 +176,7 @@ async function main() { await serve({ scenariosPath: args.scenariosPaths, port: args.port, + host: args.host, coverageFile: args.coverageFile, }); }, diff --git a/packages/spector/src/routes/admin.ts b/packages/spector/src/routes/admin.ts index 09839d8a781..d7d71df8e97 100644 --- a/packages/spector/src/routes/admin.ts +++ b/packages/spector/src/routes/admin.ts @@ -1,10 +1,22 @@ import { Router } from "express"; import { AdminUrls } from "../constants.js"; import { logger } from "../logger.js"; +import { isLoopbackAddress } from "../utils/network-utils.js"; const router = Router(); -router.post(AdminUrls.stop, (_req, res) => { +router.post(AdminUrls.stop, (req, res) => { + // The stop endpoint terminates the server process and is intentionally + // unauthenticated. To avoid an unauthenticated remote shutdown when the server + // is bound to a public interface, only accept the signal from the local host. + if (!isLoopbackAddress(req.socket.remoteAddress)) { + logger.warn( + `Rejected stop request from non-loopback address ${req.socket.remoteAddress ?? "unknown"}.`, + ); + res.status(403).end(); + return; + } + logger.info("Received signal to stop server. Exiting..."); res.status(202).end(); setTimeout(() => { diff --git a/packages/spector/src/server/server.ts b/packages/spector/src/server/server.ts index 37570bd0eb7..8c47d37a1c3 100644 --- a/packages/spector/src/server/server.ts +++ b/packages/spector/src/server/server.ts @@ -8,8 +8,18 @@ import { cleanupBody } from "../utils/index.js"; export interface MockApiServerConfig { port: number; + + /** + * Host/interface the server should bind to. + * Defaults to `127.0.0.1` (loopback only) so the mock server and its + * unauthenticated admin endpoints are not exposed to other hosts on the network. + * Set to `0.0.0.0` to explicitly listen on all interfaces (e.g. inside a container). + */ + host?: string; } +export const DEFAULT_HOST = "127.0.0.1"; + const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => { logger.error("Error", err); @@ -84,15 +94,17 @@ export class MockApiServer { public start(): Promise { this.app.use(errorHandler); + const host = this.config.host ?? DEFAULT_HOST; + return new Promise((resolve, reject) => { - const server = this.app.listen(this.config.port, () => { + const server = this.app.listen(this.config.port, host, () => { const resolvedPort = getPort(server); if (!resolvedPort) { logger.error("Failed to resolve port"); reject(new Error("Failed to resolve port")); return; } - logger.info(`Started server on ${resolvedPort}`); + logger.info(`Started server on ${host}:${resolvedPort}`); resolve(resolvedPort); }); diff --git a/packages/spector/src/utils/index.ts b/packages/spector/src/utils/index.ts index 8b880125ff2..025c8c278a6 100644 --- a/packages/spector/src/utils/index.ts +++ b/packages/spector/src/utils/index.ts @@ -2,5 +2,6 @@ export * from "./body-utils.js"; export * from "./diagnostic-reporter.js"; export * from "./file-utils.js"; export * from "./misc-utils.js"; +export * from "./network-utils.js"; export * from "./request-utils.js"; export * from "./route-utils.js"; diff --git a/packages/spector/src/utils/network-utils.test.ts b/packages/spector/src/utils/network-utils.test.ts new file mode 100644 index 00000000000..a51e8264bd6 --- /dev/null +++ b/packages/spector/src/utils/network-utils.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { isLoopbackAddress } from "./network-utils.js"; + +describe("isLoopbackAddress", () => { + it.each([ + ["127.0.0.1", true], + ["127.1.2.3", true], + ["::1", true], + ["::ffff:127.0.0.1", true], + ])("treats %s as loopback", (address, expected) => { + expect(isLoopbackAddress(address)).toBe(expected); + }); + + it.each([ + ["0.0.0.0", false], + ["10.0.0.5", false], + ["192.168.1.10", false], + ["::ffff:10.0.0.5", false], + ["2001:db8::1", false], + ["", false], + [undefined, false], + ])("treats %s as non-loopback", (address, expected) => { + expect(isLoopbackAddress(address)).toBe(expected); + }); +}); diff --git a/packages/spector/src/utils/network-utils.ts b/packages/spector/src/utils/network-utils.ts new file mode 100644 index 00000000000..4b1085d5c61 --- /dev/null +++ b/packages/spector/src/utils/network-utils.ts @@ -0,0 +1,23 @@ +/** + * Determine whether a remote address refers to the local loopback interface. + * + * Used to restrict administrative endpoints (e.g. the server stop signal) to + * requests originating from the same host, even when the server is bound to a + * broader interface such as `0.0.0.0`. + */ +export function isLoopbackAddress(address: string | undefined): boolean { + if (!address) { + // A missing remote address should be treated as untrusted. + return false; + } + + // Strip an IPv4-mapped IPv6 prefix (e.g. `::ffff:127.0.0.1`). + const normalized = address.startsWith("::ffff:") ? address.slice("::ffff:".length) : address; + + if (normalized === "::1") { + return true; + } + + // Any address in the 127.0.0.0/8 block is loopback. + return /^127\./.test(normalized); +} From 870149dd1080aff94f95b062cfc9ddc4a3269d7b Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 16 Jul 2026 15:25:04 -0400 Subject: [PATCH 2/2] simplify: force loopback bind, drop --host option and stop guard The mock server now always binds to 127.0.0.1, so the unauthenticated /.admin/stop endpoint is unreachable from other hosts. This removes the need for the --host option and the per-request loopback check. --- ...spector-stop-loopback-2026-7-16-12-15-0.md | 2 +- packages/spector/docs/using-spector.md | 6 +---- packages/spector/src/actions/serve.ts | 3 --- packages/spector/src/app/app.ts | 2 +- packages/spector/src/app/config.ts | 5 ---- packages/spector/src/cli/cli.ts | 15 ----------- packages/spector/src/routes/admin.ts | 14 +---------- packages/spector/src/server/server.ts | 21 ++++++---------- packages/spector/src/utils/index.ts | 1 - .../spector/src/utils/network-utils.test.ts | 25 ------------------- packages/spector/src/utils/network-utils.ts | 23 ----------------- 11 files changed, 12 insertions(+), 105 deletions(-) delete mode 100644 packages/spector/src/utils/network-utils.test.ts delete mode 100644 packages/spector/src/utils/network-utils.ts diff --git a/.chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md b/.chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md index 71be18994c4..47f3be07419 100644 --- a/.chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md +++ b/.chronus/changes/fix-spector-stop-loopback-2026-7-16-12-15-0.md @@ -4,4 +4,4 @@ packages: - "@typespec/spector" --- -Restrict the mock server to the loopback interface (`127.0.0.1`) by default and only accept the unauthenticated `/.admin/stop` signal from the local host. This prevents a network-reachable client from stopping the server. Use `--host 0.0.0.0` to explicitly listen on all interfaces when needed. +Bind the mock server to the loopback interface (`127.0.0.1`) so the unauthenticated `/.admin/stop` endpoint can no longer be reached by other hosts on the network. The server is only reachable from the local host. diff --git a/packages/spector/docs/using-spector.md b/packages/spector/docs/using-spector.md index cb565a09fd3..bd62bb6a25f 100644 --- a/packages/spector/docs/using-spector.md +++ b/packages/spector/docs/using-spector.md @@ -35,11 +35,7 @@ tsp-spector serve ./path/to/scenarios --coverageFile ./path/to/spector-coverage. ``` > [!NOTE] -> By default the server only binds to the loopback interface (`127.0.0.1`), so it is not reachable from other hosts on the network. If you need to reach the server from another machine or container, opt in explicitly with `--host 0.0.0.0`. Note that the mock server exposes an unauthenticated stop endpoint; even when bound to a public interface, that endpoint only accepts requests from the local host. -> -> ```bash -> tsp-spector serve ./path/to/scenarios --host 0.0.0.0 -> ``` +> The server always binds to the loopback interface (`127.0.0.1`), so it is only reachable from the local host and is not exposed to other machines on the network. Alternative to start in background diff --git a/packages/spector/src/actions/serve.ts b/packages/spector/src/actions/serve.ts index 18e22a1b0da..87a55a0da74 100644 --- a/packages/spector/src/actions/serve.ts +++ b/packages/spector/src/actions/serve.ts @@ -9,7 +9,6 @@ export interface ServeConfig { scenariosPath: string | string[]; coverageFile: string; port: number; - host?: string; } export interface StopConfig { @@ -28,7 +27,6 @@ export async function serve(config: ServeConfig) { const server = new MockApiApp({ port: config.port, - host: config.host, scenarioPath: config.scenariosPath, coverageFile: config.coverageFile, }); @@ -50,7 +48,6 @@ export async function startInBackground(config: ServeConfig) { ...scenariosPath, "--port", config.port.toString(), - ...(config.host ? ["--host", config.host] : []), "--coverageFile", config.coverageFile, ], diff --git a/packages/spector/src/app/app.ts b/packages/spector/src/app/app.ts index fb0a2372b8a..a6b5cc2c62a 100644 --- a/packages/spector/src/app/app.ts +++ b/packages/spector/src/app/app.ts @@ -32,7 +32,7 @@ export class MockApiApp { private resolverConfig!: ResolverConfig; constructor(private config: ApiMockAppConfig) { - this.server = new MockApiServer({ port: config.port, host: config.host }); + this.server = new MockApiServer({ port: config.port }); this.coverageTracker = new CoverageTracker(config.coverageFile); } diff --git a/packages/spector/src/app/config.ts b/packages/spector/src/app/config.ts index 856346971d2..12329741855 100644 --- a/packages/spector/src/app/config.ts +++ b/packages/spector/src/app/config.ts @@ -13,9 +13,4 @@ export interface ApiMockAppConfig { * Coverage file Path. */ coverageFile: string; - - /** - * Host/interface the server should bind to. Defaults to loopback only. - */ - host?: string; } diff --git a/packages/spector/src/cli/cli.ts b/packages/spector/src/cli/cli.ts index 1d7f260619a..f0ecef7be8d 100644 --- a/packages/spector/src/cli/cli.ts +++ b/packages/spector/src/cli/cli.ts @@ -10,7 +10,6 @@ import { uploadCoverageReport } from "../actions/upload-coverage-report.js"; import { uploadScenarioManifest } from "../actions/upload-scenario-manifest.js"; import { validateMockApis } from "../actions/validate-mock-apis.js"; import { logger } from "../logger.js"; -import { DEFAULT_HOST } from "../server/server.js"; import { getCommit } from "../utils/misc-utils.js"; export const DEFAULT_PORT = 3000; @@ -109,12 +108,6 @@ async function main() { description: "Port where to host the server", default: DEFAULT_PORT, }) - .option("host", { - type: "string", - description: - "Host/interface to bind to. Defaults to loopback (127.0.0.1) only. Use 0.0.0.0 to listen on all interfaces.", - default: DEFAULT_HOST, - }) .option("coverageFile", { type: "string", description: "Path to the coverage file.", @@ -125,7 +118,6 @@ async function main() { startInBackground({ scenariosPath: args.scenariosPaths, port: args.port, - host: args.host, coverageFile: args.coverageFile, }), ) @@ -160,12 +152,6 @@ async function main() { description: "Port where to host the server", default: DEFAULT_PORT, }) - .option("host", { - type: "string", - description: - "Host/interface to bind to. Defaults to loopback (127.0.0.1) only. Use 0.0.0.0 to listen on all interfaces.", - default: DEFAULT_HOST, - }) .option("coverageFile", { type: "string", description: "Path to the coverage file.", @@ -176,7 +162,6 @@ async function main() { await serve({ scenariosPath: args.scenariosPaths, port: args.port, - host: args.host, coverageFile: args.coverageFile, }); }, diff --git a/packages/spector/src/routes/admin.ts b/packages/spector/src/routes/admin.ts index d7d71df8e97..09839d8a781 100644 --- a/packages/spector/src/routes/admin.ts +++ b/packages/spector/src/routes/admin.ts @@ -1,22 +1,10 @@ import { Router } from "express"; import { AdminUrls } from "../constants.js"; import { logger } from "../logger.js"; -import { isLoopbackAddress } from "../utils/network-utils.js"; const router = Router(); -router.post(AdminUrls.stop, (req, res) => { - // The stop endpoint terminates the server process and is intentionally - // unauthenticated. To avoid an unauthenticated remote shutdown when the server - // is bound to a public interface, only accept the signal from the local host. - if (!isLoopbackAddress(req.socket.remoteAddress)) { - logger.warn( - `Rejected stop request from non-loopback address ${req.socket.remoteAddress ?? "unknown"}.`, - ); - res.status(403).end(); - return; - } - +router.post(AdminUrls.stop, (_req, res) => { logger.info("Received signal to stop server. Exiting..."); res.status(202).end(); setTimeout(() => { diff --git a/packages/spector/src/server/server.ts b/packages/spector/src/server/server.ts index 8c47d37a1c3..274e728914b 100644 --- a/packages/spector/src/server/server.ts +++ b/packages/spector/src/server/server.ts @@ -8,17 +8,14 @@ import { cleanupBody } from "../utils/index.js"; export interface MockApiServerConfig { port: number; - - /** - * Host/interface the server should bind to. - * Defaults to `127.0.0.1` (loopback only) so the mock server and its - * unauthenticated admin endpoints are not exposed to other hosts on the network. - * Set to `0.0.0.0` to explicitly listen on all interfaces (e.g. inside a container). - */ - host?: string; } -export const DEFAULT_HOST = "127.0.0.1"; +/** + * The mock server always binds to the loopback interface so it is only reachable + * from the local host. This keeps the unauthenticated admin endpoints (e.g. the + * server stop signal) from being exposed to other hosts on the network. + */ +const LOOPBACK_HOST = "127.0.0.1"; const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => { logger.error("Error", err); @@ -94,17 +91,15 @@ export class MockApiServer { public start(): Promise { this.app.use(errorHandler); - const host = this.config.host ?? DEFAULT_HOST; - return new Promise((resolve, reject) => { - const server = this.app.listen(this.config.port, host, () => { + const server = this.app.listen(this.config.port, LOOPBACK_HOST, () => { const resolvedPort = getPort(server); if (!resolvedPort) { logger.error("Failed to resolve port"); reject(new Error("Failed to resolve port")); return; } - logger.info(`Started server on ${host}:${resolvedPort}`); + logger.info(`Started server on ${LOOPBACK_HOST}:${resolvedPort}`); resolve(resolvedPort); }); diff --git a/packages/spector/src/utils/index.ts b/packages/spector/src/utils/index.ts index 025c8c278a6..8b880125ff2 100644 --- a/packages/spector/src/utils/index.ts +++ b/packages/spector/src/utils/index.ts @@ -2,6 +2,5 @@ export * from "./body-utils.js"; export * from "./diagnostic-reporter.js"; export * from "./file-utils.js"; export * from "./misc-utils.js"; -export * from "./network-utils.js"; export * from "./request-utils.js"; export * from "./route-utils.js"; diff --git a/packages/spector/src/utils/network-utils.test.ts b/packages/spector/src/utils/network-utils.test.ts deleted file mode 100644 index a51e8264bd6..00000000000 --- a/packages/spector/src/utils/network-utils.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isLoopbackAddress } from "./network-utils.js"; - -describe("isLoopbackAddress", () => { - it.each([ - ["127.0.0.1", true], - ["127.1.2.3", true], - ["::1", true], - ["::ffff:127.0.0.1", true], - ])("treats %s as loopback", (address, expected) => { - expect(isLoopbackAddress(address)).toBe(expected); - }); - - it.each([ - ["0.0.0.0", false], - ["10.0.0.5", false], - ["192.168.1.10", false], - ["::ffff:10.0.0.5", false], - ["2001:db8::1", false], - ["", false], - [undefined, false], - ])("treats %s as non-loopback", (address, expected) => { - expect(isLoopbackAddress(address)).toBe(expected); - }); -}); diff --git a/packages/spector/src/utils/network-utils.ts b/packages/spector/src/utils/network-utils.ts deleted file mode 100644 index 4b1085d5c61..00000000000 --- a/packages/spector/src/utils/network-utils.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Determine whether a remote address refers to the local loopback interface. - * - * Used to restrict administrative endpoints (e.g. the server stop signal) to - * requests originating from the same host, even when the server is bound to a - * broader interface such as `0.0.0.0`. - */ -export function isLoopbackAddress(address: string | undefined): boolean { - if (!address) { - // A missing remote address should be treated as untrusted. - return false; - } - - // Strip an IPv4-mapped IPv6 prefix (e.g. `::ffff:127.0.0.1`). - const normalized = address.startsWith("::ffff:") ? address.slice("::ffff:".length) : address; - - if (normalized === "::1") { - return true; - } - - // Any address in the 127.0.0.0/8 block is loopback. - return /^127\./.test(normalized); -}