Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Command sources: `.github/workflows/ci.yml` (the gates), `.github/workflows/rele

```bash
uv sync --locked --extra dev # setup. --locked is required; see Gotchas
uv run pytest # full suite (848 tests, seconds)
uv run pytest # full suite (850 tests, seconds)
uv run pytest tests/test_ssh.py::TestRedactSecrets -v # one class
uv run pytest 'tests/test_ssh.py::TestRedactSecrets::<test_name>' -v # one test
uv run pytest -k "confinement" -v # by keyword
Expand Down Expand Up @@ -79,7 +79,7 @@ Environment variables (names only — `README.md` has the full table): `SSH_MCP_

Four module-level tunables in `ssh.py` are *deliberately* not `Settings` fields — lifted out of inline literals to be greppable and testable without widening the operator-facing config surface. Do not promote them without a reason: `_EVICTION_LOOP_INTERVAL_S` (60s), `_MAX_JUMP_HOST_DEPTH` (5 chained jump hosts), `_STREAM_READ_CHUNK_BYTES`, and `_MAX_SFTP_BYTES` — a **100 MiB hard cap per SFTP transfer**, which `_upload_impl` refuses to exceed while `_download_impl` only warns, the bytes being already on disk by then.

**HTTP transport fails closed by design, and the gates are load-bearing.** Binding to anything other than loopback without `SSH_MCP_HTTP_TOKEN` raises at startup; a token shorter than 16 characters is rejected; DNS-rebinding protection is forced on, and a wildcard `allowed_hosts` entry is refused (the `allowed_hosts` gate in `server.py::_build_transport_security`). Read that refusal set carefully before editing it: the code strips a trailing `:*` and then refuses any remainder that is empty, contains `*`, or is only dots. So `*`, `*:*` and `*.*` are all refused, and the `entry.startswith("*.")`-fires-first bug that used to let `*.*` through is gone — do not reintroduce it. **A leading `*.` suffix wildcard is also refused, as of 0.7.0.** It used to be permitted, and this file used to call it "deliberately permitted"; that was wrong. The MCP SDK never implemented suffix matching — `TransportSecurityMiddleware._validate_host` (`mcp/server/transport_security.py:50-69` in mcp 2.1.1) does an exact-set lookup plus one trailing `:*` pass — so a `*.` entry was compared literally, matched no real `Host` header, and silently produced a 421 for every request with nothing in the log to explain it. Only a trailing `:*` port wildcard is a real feature; list concrete hostnames otherwise. Disabling auth on a non-loopback bind additionally requires `SSH_MCP_HTTP_NETWORK_NO_AUTH=I_ACCEPT_RCE_RISK` — a deliberately verbose opt-in, because the endpoint executes shell commands. Do not add a code path that relaxes any of these, and note the server speaks plain HTTP: TLS is the reverse proxy's job.
**HTTP transport fails closed by design, and the gates are load-bearing.** Binding to anything other than loopback without `SSH_MCP_HTTP_TOKEN` raises at startup; a token shorter than 16 characters is rejected; DNS-rebinding protection is forced on, and a wildcard `allowed_hosts` entry is refused (the `allowed_hosts` gate in `server.py::_build_transport_security`). Read that refusal set carefully before editing it: the code strips a trailing `:*` and then refuses any remainder that is empty, contains `*`, or is only dots. So `*`, `*:*` and `*.*` are all refused, and the `entry.startswith("*.")`-fires-first bug that used to let `*.*` through is gone — do not reintroduce it. **A leading `*.` suffix wildcard is also refused, as of 0.7.0.** It used to be permitted, and this file used to call it "deliberately permitted"; that was wrong. The MCP SDK never implemented suffix matching — `TransportSecurityMiddleware._validate_host` (`mcp/server/transport_security.py:50-70` in mcp 2.2.0) does an exact-set lookup plus one trailing `:*` pass — so a `*.` entry was compared literally, matched no real `Host` header, and silently produced a 421 for every request with nothing in the log to explain it. Only a trailing `:*` port wildcard is a real feature; list concrete hostnames otherwise. Disabling auth on a non-loopback bind additionally requires `SSH_MCP_HTTP_NETWORK_NO_AUTH=I_ACCEPT_RCE_RISK` — a deliberately verbose opt-in, because the endpoint executes shell commands. Do not add a code path that relaxes any of these, and note the server speaks plain HTTP: TLS is the reverse proxy's job.

## Testing

Expand Down Expand Up @@ -167,7 +167,7 @@ Most of these will break a build, a release, or production if ignored. The last

- **`mcp>=2` removed the `mcp.settings` transport fields, and `_build_http_app`'s three keyword arguments have no defaults, deliberately.** v1's `Settings` carried `host`/`port`/`stateless_http`/`transport_security`; v2's does not, so transport config is passed straight to `streamable_http_app()` instead. `_build_transport_security(raw_allowed_hosts, host) -> TransportSecuritySettings` and `_build_http_app(token, *, host, stateless, transport_security)` in `server.py` both require every argument — no `transport_security=None` shortcut. `mcp/server/transport_security.py:48` turns a `None` settings object into `TransportSecuritySettings(enable_dns_rebinding_protection=False)`, so a default would silently fail open on a non-loopback bind. Do not add one to "simplify" a callsite.

- **`mcp>=2` logs a tool-failure message at INFO before converting it to a client-visible error.** `mcp/server/mcpserver/server.py:438` does `logger.info("Tool %r failed: %r", params.name, str(exc))` on the `mcp.server.mcpserver.server` logger before turning a `ToolError` into `CallToolResult(is_error=True)`. `upload_file`/`download_file` are the tools that raise, and their messages carry local and remote paths. `_configure_logging` now raises that logger to WARNING for the same reason it already raises the `asyncssh` loggers above — "Production incident 2026-04-11 (round 2)" was a third-party library logging sensitive text at INFO, and this is the same class of leak on a different logger.
- **`mcp>=2` logs a tool-failure message at INFO before converting it to a client-visible error.** `mcp/server/mcpserver/server.py:444` (mcp 2.2.0) does `logger.info("Tool %r failed: %r", params.name, str(exc))` on the `mcp.server.mcpserver.server` logger before turning a `ToolError` into `CallToolResult(is_error=True)`. `upload_file`/`download_file` are the tools that raise, and their messages carry local and remote paths. `_configure_logging` now raises that logger to WARNING for the same reason it already raises the `asyncssh` loggers above — "Production incident 2026-04-11 (round 2)" was a third-party library logging sensitive text at INFO, and this is the same class of leak on a different logger.

- **The dangerous-command list is a tripwire, not a security boundary.** `README.md` and `SECURITY.md` say so explicitly, and base64 / hex-escape / homoglyph / `$(...)` bypasses are acknowledged. Do not describe it as a security control, and do not widen it into a claim it cannot keep. Two things in it are load-bearing and easy to break by "simplifying". The host-availability entries added in 0.8.1 (`reboot`, `poweroff`, `init 0`, `systemctl poweroff`, `iptables -F`, `nft flush ruleset`, `userdel`, `passwd -l`) are anchored on `_CMD_START` — string start, a shell separator, or `sudo`/`doas` — because a bare `\breboot\b` blocks `last reboot` and `grep reboot /var/log/messages`, and blocking read-only diagnostics is how a tripwire teaches operators to route around it. And `_is_dangerous_command` searches **two renderings** of the command: control characters become a space (so `rm -rf\n/` still matches, needing the whitespace reading) *and* line breaks become `;` (so a second line reads as a command position, not as an argument — with the space rendering alone, `echo hi\nreboot` was measurably not caught). Neither rendering alone is sufficient; a test pins each.

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

**PyJWT is held at 2.14.0 or later, clearing ten advisories that turned the `pip-audit` gate red on `main`.** 2.13.0, locked transitively through `mcp`, carries CVE-2026-101917, CVE-2026-102265 to CVE-2026-102269 and CVE-2026-102271 to CVE-2026-102274: four algorithm-confusion variants, an empty-HMAC-key bypass, lenient Base64URL signature decoding, a followed JWKS redirect, and three denial-of-service paths in JWKS fetching and token/JWKS parsing. None is reachable here: the only `import jwt` in mcp 2.2.0 is `mcp/client/auth/extensions/client_credentials.py`, an OAuth *client* extension ssh-mcp never imports; its HTTP transport authenticates with its own bearer-token middleware.

### Changed

**Stateful HTTP sessions now expire after 30 idle minutes, and at most 10,000 are held at once.** Both limits are mcp 2.2.0 defaults (#64), which the lockfile and container image now ship, and they apply only to stateful sessions on the legacy (2025-11-25 and earlier) protocol. A session with no request in flight and no open GET stream is closed after 1800 s; its next request gets `404` `Session not found`, which a client that does not re-initialize surfaces as an error. An open GET stream prevents expiry but still counts toward the cap, and a session opened beyond the cap gets `503`. Stateless mode (`SSH_MCP_HTTP_STATELESS=true`) avoids both limits. ssh-mcp deliberately exposes no knob for either: both bound server-side memory on an endpoint that executes shell commands. The declared floor stays `mcp>=2.1.1`, so an existing environment that already has 2.1.1 keeps the old unbounded behaviour until it upgrades.

## [0.8.1] - 2026-09-07

> **Why 0.8.1 and not 0.9.0:** nothing here changes a configuration that started successfully on 0.8.0 into one that refuses to start, which is the test 0.8.0 itself recorded for taking the minor. The one behaviour an operator could notice is that four more command shapes are now blocked by the dangerous-command tripwire — and that list has always been documented as advisory and subject to widening, `force=true` still bypasses it, and no *configuration* becomes invalid. Everything else is a hang becoming an error, a log line becoming honest, an image gaining an architecture, and prose corrections.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ Authorization: Bearer <TOKEN>
Host: ssh-mcp.internal
```

For stateful sessions (default), MCPServer maintains per-client context across requests. For stateless deployments behind a load balancer, set `SSH_MCP_HTTP_STATELESS=true` — each request is handled independently with no server-side session.
For stateful sessions (default), MCPServer maintains per-client context across requests. Since mcp 2.2.0, stateful sessions on the legacy (2025-11-25 and earlier) protocol are limited: one with no request in flight and no open GET stream is closed after 30 minutes (its next request gets `404` and must initialize again), and at most 10,000 are held at once (`503` beyond that; an open GET stream still counts). Stateless mode avoids both limits. For stateless deployments behind a load balancer, set `SSH_MCP_HTTP_STATELESS=true` — each request is handled independently with no server-side session.

### Healthcheck

Expand Down
11 changes: 11 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,17 @@ constraint-dependencies = [
# not exploitable here; the floor keeps a future `uv lock` from
# regressing below the fix.
"cryptography>=50.0.1",
# CVE-2026-101917, CVE-2026-102265..102269, CVE-2026-102271..102274 —
# ten PyJWT advisories fixed in 2.14.0: four algorithm-confusion
# variants, an empty-HMAC-key bypass, lenient Base64URL signature
# decoding, a followed JWKS redirect, and three denial-of-service paths
# in JWKS fetching and token/JWKS parsing.
# Transitive: mcp requires pyjwt[crypto]. The only importer in mcp 2.2.0
# is mcp/client/auth/extensions/client_credentials.py, an OAuth client
# extension ssh-mcp never imports, so none is reachable here; the floor
# keeps the lockfile/container image clear and a future `uv lock` from
# regressing.
"pyjwt>=2.14.0",
]

[tool.ruff.lint]
Expand Down
10 changes: 5 additions & 5 deletions src/ssh_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,7 @@ def _build_http_app(
so a default here would make that fail-open path reachable by
omission. Note there is deliberately no ``host`` parameter: the SDK
consults ``host`` only to auto-enable loopback protection when
``transport_security is None`` (mcp/server/lowlevel/server.py:735),
``transport_security is None`` (mcp/server/lowlevel/server.py:742 in mcp 2.2.0),
which this signature forbids, so forwarding it had no effect.

Returns a ``Starlette`` instance ready to hand to ``uvicorn.run``.
Expand All @@ -731,8 +731,8 @@ def _build_http_app(
# (Starlette itself went 0.52.1 -> 1.3.1 during this release cycle,
# unconstrained by this project). `streamable_http_app()` above wires
# its own inner Starlette app with `lifespan=lambda app:
# self.session_manager.run()` (mcp/server/lowlevel/server.py:828) — calling
# `lifespan_ctx(inner_app)` therefore did nothing but reach `.run()`
# self.session_manager.run()` (mcp/server/lowlevel/server.py:840 in mcp 2.2.0) —
# calling `lifespan_ctx(inner_app)` therefore did nothing but reach `.run()`
# through a private indirection. `MCPServer.session_manager` is the
# SDK-documented public accessor for the exact same object, and
# `.run()` takes no arguments, so we call it directly from OUR outer
Expand Down Expand Up @@ -987,7 +987,7 @@ def _build_transport_security(
# and three docs sites advertised it, but the SDK never
# implemented suffix matching: mcp 2.1.1's
# TransportSecurityMiddleware._validate_host
# (mcp/server/transport_security.py:50-69) does an exact-set
# (mcp/server/transport_security.py:50-70) does an exact-set
# lookup and then ONE trailing ":*" port-wildcard pass, and there
# is no "*." handling anywhere in the SDK. A "*." entry was
# therefore matched LITERALLY, so a real Host header such as
Expand Down Expand Up @@ -1030,7 +1030,7 @@ def _build_transport_security(
# but we always pass this explicitly (D7) rather than relying on
# either default: passing any transport_security object at all
# suppresses the SDK's loopback-only auto-enable
# (mcp/server/lowlevel/server.py:735), so the value has to be stated
# (mcp/server/lowlevel/server.py:742 in mcp 2.2.0), so the value has to be stated
# here.
return TransportSecuritySettings(
enable_dns_rebinding_protection=True,
Expand Down
11 changes: 11 additions & 0 deletions tests/test_dependency_floors.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,17 @@
"(deprecated websocket_server). None reachable from ssh-mcp's SDK "
"usage; 1.28.1 is the max first-patched version across the three.",
),
"pyjwt": (
"2.14.0",
"CVE-2026-101917, CVE-2026-102265 to CVE-2026-102269 and "
"CVE-2026-102271 to CVE-2026-102274 — four algorithm-confusion variants, "
"an empty-HMAC-key bypass, lenient Base64URL signature decoding, "
"a followed JWKS redirect, and three denial-of-service paths in JWKS "
"fetching and token/JWKS parsing; all fixed in 2.14.0. Transitive via mcp; "
"ssh-mcp never imports "
"mcp.client.auth.extensions.client_credentials, the only importer, "
"so none is reachable here.",
),
}

# Packages that ssh-mcp imports directly and must therefore constrain in the
Expand Down
7 changes: 4 additions & 3 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading