Skip to content

Require auth on MCP HTTP server + bind localhost by default (issue #90) - #101

Open
Diogo-Damasceno wants to merge 1 commit into
GH05TCREW:mainfrom
Diogo-Damasceno:fix/mcp-http-auth
Open

Diogo-Damasceno wants to merge 1 commit into
GH05TCREW:mainfrom
Diogo-Damasceno:fix/mcp-http-auth

Conversation

@Diogo-Damasceno

Copy link
Copy Markdown

Summary

Fixes #90. The MCP HTTP server (transport sse) bound to 0.0.0.0:8080 with no authentication, exposing run_task/update_config/get_conversation_history to any network client — which could drive LocalRuntime shell execution on the operator's host.

Changes

  • mcp_transport_streamable_http.py: create_streamable_http_app(router, token=None). When a token is set, all /mcp routes (POST/GET/DELETE) require Authorization: Bearer <token>, returning 401 otherwise.
  • interface/main.py:
    • New --mcp-token flag; threaded into both server start paths.
    • Default --host changed from 0.0.0.0 → 127.0.0.1 (localhost only).
    • Warns when binding to 0.0.0.0 without a token.
  • Legacy behaviour preserved when no token is given, but now defaults to localhost so it isn't accidentally network-exposed.

Verification

  • pytest tests/security/test_mcp_auth.py → 8 passed (auth helper + integration: unauthenticated POST rejected 401, authenticated POST allowed, GET requires auth, no-token stays open).
  • pytest tests/security/ → 145 passed.
  • ruff: clean on all changed files.

Test plan for reviewers

  1. pentestagent mcp_server --type sse → binds 127.0.0.1:8080.
  2. With --mcp-token SECRET: curl -X POST localhost:8080/mcp -d '...' → 401; with -H "Authorization: Bearer SECRET" → proceeds.
  3. --host 0.0.0.0 without --mcp-token → prints a warning.

…05TCREW#90)

The MCP HTTP server (transport 'sse') bound to 0.0.0.0:8080 with no
authentication, exposing run_task/update_config/get_conversation_history to
any network client, which could drive LocalRuntime shell execution on the
host (issue GH05TCREW#90).

- create_streamable_http_app(router, token=None): when a token is provided,
  POST/GET/DELETE /mcp require 'Authorization: Bearer <token>', returning 401
  otherwise.
- interface/main.py: add --mcp-token; default --host changed from 0.0.0.0 to
  127.0.0.1; warn when binding to 0.0.0.0 without a token. Token is threaded
  into both server start paths.
- Without a token the server keeps legacy open behaviour but now defaults to
  localhost, so it is not exposed to the network by accident.

Add tests/security/test_mcp_auth.py (8 tests). Full tests/security suite: 145
passed; ruff clean.
sattyamjjain added a commit to sattyamjjain/agent-airlock that referenced this pull request Sep 15, 2026
…s 34 (#192)

* fix(cves): disposition CVE-2026-90617 at the argument seam

Out of scope. The watcher filed it because the shape classifier read
CWE-77/CWE-78 plus "run_task" as argument-shaped, and that prior was
reasonable: run_task really is a registered MCP tool taking
{task, target, scope}, which is the seam this library sits on.

The argument does not carry the command. "task" is a natural-language
prompt. The shell string is authored downstream by the LLM and executed
by LocalRuntime's asyncio.create_subprocess_shell, which is the product
working as designed for a caller that got in. target and scope become
agent context, not command text.

What the defect actually is: the aiohttp /mcp routes carry no
authentication and bind 0.0.0.0:8080 by default, so any network client
can drive the agent at all. Upstream PR #101 fixes it with
Authorization: Bearer middleware plus a 127.0.0.1 default and changes
no argument or schema. Neither an auth check on someone else's route
nor a bind default is expressible at the tool-call boundary, which is
the rule in docs/cve-triage.md under "The seam".

This is the "both" case that doc describes, resolved without a
second-defence fixture. Unlike CVE-2026-79748, the primitive the
missing check hands over is not a stdio spawn config but a sentence of
English aimed at an LLM designed to run commands. Refusing that at the
argument boundary would mean classifying prose, and on a pentesting
agent the malicious task and the legitimate one are the same string.

No guard, no fixture, no code under src/ changed, so no version bump.
The refusal row and the reasoning land in tests/cves/README.md so the
decision is readable without opening the tracker.

Also de-rots two counts in that section that this edit made wronger:
"all four rows" becomes "every row", and "the two 2026-09 additions"
now names the CVEs it means.

Primary sources:
  https://nvd.nist.gov/vuln/detail/CVE-2026-90617
  GH05TCREW/pentestagent#90
  GH05TCREW/pentestagent#101

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoFgXfaGtzBk56c4Lu2AXh

* feat(cves): clear the triage queue and fix a catalogue that counts 36 and covers 34

Three things, one release.

1. Triage queue

PR #188's disposition of CVE-2026-90617 is carried here unchanged; it could
not land alone because a docs-only change leaves entries under [Unreleased]
on a released version, which check_changelog.py refuses by design. The 0.10.5
bump in this commit is what resolves it.

CVE-2026-90898 (Bifrost, 9.8) and CVE-2026-57124 (PraisonAI, 9.8) are both
in scope, and cut the opposite way from CVE-2026-90617. Each is the split
docs/cve-triage.md calls "both": an unauthenticated HTTP endpoint this
library cannot reach, handing the caller a command plus args bound for a
stdio spawn, which it already refuses. The missing auth is out of scope in
each. The primitive is the CVE-2026-42271 shape, so each gets a
second-defence fixture against the existing McpSubprocessArgInjectionGuard
rather than a new guard, and neither preset claims either CVE.

Filed a day apart, the two records are mirror images of the watcher's own
classifier. Bifrost carries CWE-284 and CWE-306, neither argument-shaped,
and was admitted only by the sink word "stdio". PraisonAI has no sink word
at all and was admitted only by CWE-78. Each signal is load-bearing exactly
once across the pair, which is the argument against reducing it to one.

Both fixtures also pin two integration footguns rather than describing them:
Bifrost nests the spawn fields under stdio_config, so passing the whole
request body finds nothing to inspect and allows it; and its envs is a list
of names, not the env mapping the dangerous-variable check reads.

2. Catalogue

docs/cves/index.md emitted one row per test module, and two CVEs carry two
modules each, so it published 36 rows for 34 CVEs. The anchor is derived
from the CVE id, so each pair emitted the same <a id> twice and both summary
links pointed at it: one link per pair necessarily resolved to the wrong
section, and the HTML was invalid besides.

--check could not see it. It compares generated output to committed output,
and the generator wrote the duplicate into both, so the gate stayed green
while the artefact was wrong. A row is now one CVE listing every module
behind it, and assert_unique_cve_rows() runs in every mode including
--write, so a duplicate cannot be produced in the first place.

Three counts were being conflated and are now separate: 45 regression
modules, 38 CVE-numbered, covering 36 distinct CVEs. README quotes the last
and is asserted against the catalogue's row count; marketplace.json quotes
the first two. The old "36" was a module count that equalled today's CVE
count only because the catalogue double-counted.

3. Distribution checklist

Off DRAFT and dated. All six target lists re-verified live; three no longer
resolve. punkpeye/awesome-mcp-servers now scopes itself to servers you
install and run, and points libraries at its awesome-mcp-devtools sibling,
which replaces it. wong2/awesome-mcp-servers does not accept PRs. And
e2b-dev/awesome-ai-agents has no security or tooling section, which the old
row was conditional on. The two "search for it" placeholders now name real
repositories. Each surviving row carries its exact section heading, what the
list requires, a bullet already in that list's format, and a state column.
The canonical one-liner still said "A type-checker for AI tool calls" while
pyproject, the repo description and the README hero had moved to the
contract-layer framing.

Primary sources:
  https://nvd.nist.gov/vuln/detail/CVE-2026-90898
  maximhq/bifrost#6757
  https://nvd.nist.gov/vuln/detail/CVE-2026-57124
  GHSA-p75f-6fp4-p57w

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HoFgXfaGtzBk56c4Lu2AXh

---------

Co-authored-by: sattyamj-attri <sattyam.jain@attri.ai>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PentestAgent — Unauthenticated MCP HTTP Server Exposes Full Agent Control

1 participant