From ec8d5108883fa4674f3f923d2570dbb03f792357 Mon Sep 17 00:00:00 2001 From: pedrooot Date: Wed, 7 Oct 2026 16:48:09 +0200 Subject: [PATCH] fix(mcp): supply the verifying key to the MCP server --- docker-compose-dev.yml | 4 ++ docker-compose.yml | 5 ++ .../installation/prowler-mcp.mdx | 15 +++++ ...mcp-jwt-signature-verification.security.md | 2 +- .../prowler_app/utils/auth.py | 25 ++++++++- .../tests/prowler_app/utils/test_auth.py | 56 +++++++++++++++++++ 6 files changed, 105 insertions(+), 2 deletions(-) diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 6f7c5a3ff4..66cafa0afc 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 4b836aba6c..252bd7fec7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/getting-started/installation/prowler-mcp.mdx b/docs/getting-started/installation/prowler-mcp.mdx index 5be469b5b2..e42de79404 100644 --- a/docs/getting-started/installation/prowler-mcp.mdx +++ b/docs/getting-started/installation/prowler-mcp.mdx @@ -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 | - | + + +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. + ```bash macOS/Linux diff --git a/mcp_server/changelog.d/mcp-jwt-signature-verification.security.md b/mcp_server/changelog.d/mcp-jwt-signature-verification.security.md index 21cb5f9ac3..2df011cacc 100644 --- a/mcp_server/changelog.d/mcp-jwt-signature-verification.security.md +++ b/mcp_server/changelog.d/mcp-jwt-signature-verification.security.md @@ -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 diff --git a/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py b/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py index e72a933af1..fb01970de8 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py +++ b/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py @@ -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") diff --git a/mcp_server/tests/prowler_app/utils/test_auth.py b/mcp_server/tests/prowler_app/utils/test_auth.py index 01a107a135..65e185543e 100644 --- a/mcp_server/tests/prowler_app/utils/test_auth.py +++ b/mcp_server/tests/prowler_app/utils/test_auth.py @@ -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, ):