Skip to content

Enforce a TLS posture on non-loopback HTTP binds#67

Open
deviantony wants to merge 1 commit into
http-api-passthroughfrom
tls-posture
Open

Enforce a TLS posture on non-loopback HTTP binds#67
deviantony wants to merge 1 commit into
http-api-passthroughfrom
tls-posture

Conversation

@deviantony
Copy link
Copy Markdown
Member

@deviantony deviantony commented Jun 4, 2026

Over HTTP the server carries two secrets on the wire (the shared gate token and each caller's permanent Portainer API key), so it now refuses to boot on a non-loopback bind unless the operator declares one of three transport postures:

  • server-terminated TLS: PORTAINER_MCP_TLS_CERT/_TLS_KEY -> uvicorn ssl_certfile/ssl_keyfile. Self-signed leaf certs WARN (never block).
  • TLS-terminating proxy: PORTAINER_MCP_TRUST_PROXY_TLS=1 + PORTAINER_MCP_FORWARDED_ALLOW_IPS -> trusted X-Forwarded-Proto.
  • plaintext opt-out: PORTAINER_MCP_DANGEROUSLY_ALLOW_PLAINTEXT_HTTP=1, the one loud escape hatch (WARNs every start, marks the audit log insecure_transport: true).

Both encrypted shapes converge on scheme == "https", enforced by TLSRequiredMiddleware as a backstop. It is installed via the verifier's get_middleware() so it runs before the bearer-auth backend -- a plaintext request is rejected before the per-user key is validated and forwarded upstream. Loopback binds stay exempt for dev. A broken declaration hard-fails at startup, never silently downgrades.

Adds tls.py, the auth.py wiring (pre-auth middleware hook + insecure_transport audit flag), main() resolution, tests, and docs. cryptography promoted to a direct dependency (self-signed detection).

Related to #65 and #66

Over HTTP the server carries two secrets on the wire (the shared gate
token and each caller's permanent Portainer API key), so it now refuses
to boot on a non-loopback bind unless the operator declares one of three
transport postures:

  - server-terminated TLS: PORTAINER_MCP_TLS_CERT/_TLS_KEY -> uvicorn
    ssl_certfile/ssl_keyfile. Self-signed leaf certs WARN (never block).
  - TLS-terminating proxy: PORTAINER_MCP_TRUST_PROXY_TLS=1 +
    PORTAINER_MCP_FORWARDED_ALLOW_IPS -> trusted X-Forwarded-Proto.
  - plaintext opt-out: PORTAINER_MCP_DANGEROUSLY_ALLOW_PLAINTEXT_HTTP=1,
    the one loud escape hatch (WARNs every start, marks the audit log
    insecure_transport: true).

Both encrypted shapes converge on scheme == "https", enforced by
TLSRequiredMiddleware as a backstop. It is installed via the verifier's
get_middleware() so it runs before the bearer-auth backend -- a plaintext
request is rejected before the per-user key is validated and forwarded
upstream. Loopback binds stay exempt for dev. A broken declaration
hard-fails at startup, never silently downgrades.

Adds tls.py, the auth.py wiring (pre-auth middleware hook +
insecure_transport audit flag), main() resolution, tests, and docs.
cryptography promoted to a direct dependency (self-signed detection).

Co-Authored-By: Claude Opus 4.8 (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.

1 participant