Aller au contenu

TLS devant la passerelle

Le transport Streamable HTTP parle du HTTP nu, délibérément : la liste des dépendances de la passerelle fait partie du produit, et une pile TLS gonflerait l’arbre qu’un auditeur doit lire, pour un travail qu’un reverse proxy fait déjà bien. Mais l’identité voyage dans l’en-tête Authorization: Bearer de chaque requête : les jetons SSO ne doivent jamais traverser un réseau en clair.

Le motif : la passerelle écoute là où TLS se termine. Même hôte que le terminateur TLS, lié à la boucle locale, ou le même réseau de conteneurs privé, sans port publié. Les configurations nginx (1.27) et Caddy (2.11) ci-dessous ont été exécutées face à la passerelle et ont fait passer le flux MCP complet.

  1. N’exposez jamais le port en clair. --http 127.0.0.1:8080 sur un hôte mutualisé ; en compose, aucune entrée ports: sur la passerelle.
  2. Transmettez Authorization et Mcp-Session-Id intacts. nginx et Caddy le font tous deux par défaut.
  3. Les requêtes amont portent Content-Length. L’analyseur de la passerelle est strict : l’encodage de transfert chunked est refusé (411). Gardez le proxy_request_buffering de nginx à sa valeur par défaut (on).
  4. Ne mettez pas les réponses en tampon. Le flux GET est en server-sent events ; proxy_buffering off (nginx), flush_interval -1 (Caddy).
  5. Délai de lecture plus long que l’outil le plus lent. Un tools/call posté ne produit aucun octet tant que l’outil n’a pas répondu. Le flux SSE est sûr à tout réglage au-dessus de 15 s (intervalle de keep-alive) ; dimensionnez le délai pour les outils.
  6. La liste d’Origin autorisées nomme l’origine publique. Les clients navigateur ont besoin de --allowed-origin https://mcp.example.com, c’est-à-dire l’origine TLS, pas celle de la boucle locale. Les clients MCP hors navigateur n’envoient pas d’Origin et passent.

La passerelle plafonne les corps de requête à 4 Mio ; alignez le proxy dessus (client_max_body_size 4m).

server {
listen 443 ssl;
server_name mcp.example.com;
ssl_certificate /etc/nginx/certs/mcp.example.com.pem;
ssl_certificate_key /etc/nginx/certs/mcp.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
location = /mcp {
proxy_pass http://127.0.0.1:8080;
# The gateway speaks HTTP/1.1 with Content-Length bodies only.
proxy_http_version 1.1;
proxy_set_header Connection "";
# SSE: deliver events as they are written, do not spool.
proxy_buffering off;
# Must outlast the slowest tool call (contract point 5).
proxy_read_timeout 300s;
# The gateway caps request bodies at 4 MiB; match it.
client_max_body_size 4m;
}
location / { return 404; }
}

location = /mcp est en correspondance exacte à dessein : la passerelle sert un seul point d’entrée, et tout le reste n’a rien à faire à l’atteindre.

mcp.example.com {
reverse_proxy 127.0.0.1:8080 {
# SSE: flush each event immediately.
flush_interval -1
}
}

Caddy obtient et renouvelle le certificat lui-même (ACME) ; ses valeurs par défaut diffusent déjà le SSE en flux et ne fixent aucun délai de lecture. Pour un laboratoire sans nom public, tls internal émet un certificat depuis l’autorité locale de Caddy.

La passerelle perd entièrement son entrée ports:. Le proxy est le seul service qui écoute sur l’hôte :

services:
gateway:
image: my-gateway # extends ghcr.io/obsign/obsign-proxy
command: ["obsign-proxy", "...", "--http", "0.0.0.0:8080",
"--allowed-origin", "https://mcp.example.com", "--", "..."]
# no ports: — reachable only on the compose network
tls:
image: nginx:1.27-alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
ports:
- "443:443"

avec proxy_pass http://gateway:8080; dans la configuration nginx.

Là où la population de clients est faite de machines plutôt que de navigateurs, le proxy peut aussi exiger un certificat client. Cela authentifie le canal ; le jeton bearer reste l’identité à laquelle le journal attribue les actes. Le mTLS restreint qui peut présenter un jeton ; il n’en remplace pas un.

TLS protège le jeton et le trafic en transit. Il ne fait pas partie de l’histoire de la preuve : ce qui rend le journal démontrable, c’est la chaîne de signatures dans le WAL et le scellé du ledger, dont aucun n’implique le transport.

Quatre sondes, chacune arrimée à un point du contrat :

Fenêtre de terminal
BASE=https://mcp.example.com/mcp
TOKEN=... # a valid SSO token
SID=$(curl -si "$BASE" -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
| tr -d '\r' | awk 'tolower($1)=="mcp-session-id:" {print $2}')
# Token passes through, SSE unbuffered: a ": keep-alive" comment must
# appear within ~15 s. Silence means the proxy is spooling the stream.
curl -N --max-time 20 "$BASE" -H "Authorization: Bearer $TOKEN" \
-H "Mcp-Session-Id: $SID" -H 'Accept: text/event-stream'
# No token → the *gateway's* 401 (WWW-Authenticate: Bearer), proving the
# proxy did not answer in its place.
curl -si "$BASE" -H 'Content-Type: application/json' -d '{}'
# A hostile Origin → 403 from the gateway, through the proxy.
curl -s -o /dev/null -w '%{http_code}\n' "$BASE" \
-H "Authorization: Bearer $TOKEN" -H 'Origin: https://evil.example' \
-H 'Content-Type: application/json' -d '{}'
# A chunked request → 200: the proxy buffered it into Content-Length.
# A 411 means requests are being relayed chunked; restore request buffering.
curl -s -o /dev/null -w '%{http_code}\n' "$BASE" \
-H "Authorization: Bearer $TOKEN" -H 'Transfer-Encoding: chunked' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'

Et le port en clair ne doit pas répondre depuis l’extérieur de l’hôte : que curl http://mcp.example.com:8080/mcp expire est le résultat correct.