mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-09 21:14:22 +00:00
fix(mcp): supply the verifying key to the MCP server
This commit is contained in:
6 files changed
+105
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
Reference in new issue
Block a user