fix(mcp): supply the verifying key to the MCP server

This commit is contained in:
pedrooot committed 2026-10-07 16:48:09 +02:00
1 parent 9613f7787a
commit ec8d510888
6 files changed
+105 -2

No files matched your search

+4
View File
@@ -194,12 +194,16 @@ services:
dockerfile: Dockerfile
environment:
- PROWLER_MCP_TRANSPORT_MODE=http
# The API generates this key pair on first boot; reading the public half
# here is what lets the MCP server verify token signatures.
- DJANGO_TOKEN_VERIFYING_KEY_FILE=/home/prowler/.config/prowler-api/jwt_public.pem
env_file:
- path: .env
required: false
ports:
- "8000:8000"
volumes:
- ./_data/api:/home/prowler/.config/prowler-api:ro
- ./mcp_server/prowler_mcp_server:/app/prowler_mcp_server
- ./mcp_server/pyproject.toml:/app/pyproject.toml
- ./mcp_server/entrypoint.sh:/app/entrypoint.sh
+5
View File
@@ -175,6 +175,11 @@ services:
restart: unless-stopped
environment:
- PROWLER_MCP_TRANSPORT_MODE=http
# The API generates this key pair on first boot; reading the public half
# here is what lets the MCP server verify token signatures.
- DJANGO_TOKEN_VERIFYING_KEY_FILE=/home/prowler/.config/prowler-api/jwt_public.pem
volumes:
- "./_data/api:/home/prowler/.config/prowler-api:ro"
env_file:
- path: .env
required: false
@@ -222,6 +222,21 @@ Configure the server using environment variables:
| `PROWLER_API_KEY` | Prowler API key | Only for STDIO mode | - |
| `API_BASE_URL` | Custom Prowler API endpoint | No | `https://api.prowler.com/api/v1` |
| `PROWLER_MCP_TRANSPORT_MODE` | Default transport mode (overwritten by `--transport` argument) | No | `stdio` |
| `DJANGO_TOKEN_VERIFYING_KEY` | Prowler API RS256 public key, used in HTTP mode to verify the signature of the bearer token. Escaped newlines (`\n`) are accepted so it fits in an env file | Recommended for HTTP mode | - |
| `DJANGO_TOKEN_VERIFYING_KEY_FILE` | Path to that same public key on disk, read when `DJANGO_TOKEN_VERIFYING_KEY` is empty | No | - |
<Warning>
In HTTP mode, without either of the two variables above the MCP server only
checks that the bearer token is readable and unexpired, not that its signature
is valid. The Prowler API still verifies every forwarded token, so a forged one
is refused one hop later, but the MCP server will have acted on it first. Set
one of them.
Docker Compose wires this up already: the API writes the pair to
`_data/api/jwt_public.pem` on first boot, and the `mcp-server` service mounts
that directory read-only with `DJANGO_TOKEN_VERIFYING_KEY_FILE` pointing at it.
A deployment that does not use Compose has to supply the public key itself.
</Warning>
<CodeGroup>
```bash macOS/Linux
@@ -1 +1 @@
JWT signatures in HTTP transport mode are verified against the Prowler API RS256 public key when `DJANGO_TOKEN_VERIFYING_KEY` is set, refusing forged, `alg: none` and HMAC-keyed tokens
JWT signatures in HTTP transport mode are verified against the Prowler API RS256 public key, supplied through DJANGO_TOKEN_VERIFYING_KEY or a file path, refusing forged, alg none and HMAC-keyed tokens
@@ -1,6 +1,7 @@
import base64
import json
import os
import pathlib
from datetime import datetime
import jwt
@@ -14,6 +15,11 @@ from prowler_mcp_server.lib.logger import logger
# declares `alg: none`, or an HMAC algorithm keyed with the public key, out.
JWT_ALGORITHMS = ["RS256"]
VERIFYING_KEY_FILE_ENV = "DJANGO_TOKEN_VERIFYING_KEY_FILE"
# Tolerated clock drift between the API that issues a token and this server.
JWT_CLOCK_SKEW_SECONDS = 30
class ProwlerAppAuth:
"""Handles authentication for Prowler API using API keys or JWT tokens."""
@@ -35,7 +41,7 @@ class ProwlerAppAuth:
jwt_verifying_key.replace("\\n", "\n").strip() or None
if jwt_verifying_key
else None
)
) or self._read_verifying_key_file()
if mode == "stdio": # STDIO mode
# PROWLER_API_KEY is the current variable; PROWLER_APP_API_KEY is kept
@@ -86,6 +92,20 @@ class ProwlerAppAuth:
logger.warning(f"Failed to parse JWT token: {e}")
return None
@staticmethod
def _read_verifying_key_file() -> str | None:
"""Public key from DJANGO_TOKEN_VERIFYING_KEY_FILE, which compose mounts from the API."""
path = os.getenv(VERIFYING_KEY_FILE_ENV, "").strip()
if not path:
return None
try:
return pathlib.Path(path).read_text().strip() or None
except OSError as error:
logger.warning(
f"Could not read {VERIFYING_KEY_FILE_ENV} at {path}: {error}"
)
return None
def _verify_jwt(self, token: str) -> dict:
"""Verify the signature and standard time claims; raise CredentialError otherwise."""
try:
@@ -96,6 +116,9 @@ class ProwlerAppAuth:
# The API's audience is deployment-specific and unknown here; the
# API checks it on every forwarded request.
options={"require": ["exp"], "verify_aud": False},
# The API and this server may sit on hosts with drifting clocks,
# and iat is only checked once a verifying key is configured.
leeway=JWT_CLOCK_SKEW_SECONDS,
)
except jwt.ExpiredSignatureError:
raise CredentialError("The token has expired")
@@ -8,6 +8,7 @@ on them -- always pass them explicitly, as these tests do.
import base64
import json
import time
import jwt
import pytest
@@ -125,6 +126,61 @@ async def test_http_mode_rejects_a_jwt_with_a_forged_signature(http_request_head
await auth.get_valid_token()
async def test_http_mode_reads_the_verifying_key_from_a_file(
http_request_headers, monkeypatch, tmp_path
):
"""Compose mounts the API's public key; reading it is what enables verification."""
key_file = tmp_path / "jwt_public.pem"
key_file.write_text(JWT_VERIFYING_KEY)
monkeypatch.setenv("DJANGO_TOKEN_VERIFYING_KEY_FILE", str(key_file))
http_request_headers(authorization=f"Bearer {fake_jwt()}")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=None)
assert auth.jwt_verifying_key == JWT_VERIFYING_KEY.strip()
assert await auth.get_valid_token()
async def test_http_mode_rejects_a_forged_jwt_when_the_key_comes_from_a_file(
http_request_headers, monkeypatch, tmp_path
):
key_file = tmp_path / "jwt_public.pem"
key_file.write_text(JWT_VERIFYING_KEY)
monkeypatch.setenv("DJANGO_TOKEN_VERIFYING_KEY_FILE", str(key_file))
http_request_headers(
authorization=f"Bearer {fake_jwt(signing_key=ROGUE_JWT_SIGNING_KEY)}"
)
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=None)
with pytest.raises(CredentialError, match="could not be verified"):
await auth.get_valid_token()
async def test_http_mode_ignores_an_unreadable_verifying_key_file(
http_request_headers, monkeypatch, tmp_path
):
monkeypatch.setenv(
"DJANGO_TOKEN_VERIFYING_KEY_FILE", str(tmp_path / "does-not-exist.pem")
)
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=None)
assert auth.jwt_verifying_key is None
async def test_http_mode_tolerates_a_token_issued_slightly_in_the_future(
http_request_headers,
):
"""Clock drift between the API host and this server must not reject fresh tokens."""
token = fake_jwt(iat=int(time.time()) + 10)
http_request_headers(authorization=f"Bearer {token}")
auth = ProwlerAppAuth(mode="http", jwt_verifying_key=JWT_VERIFYING_KEY)
assert await auth.get_valid_token() == token
async def test_http_mode_rejects_a_jwt_declaring_the_none_algorithm(
http_request_headers,
):