mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 20:44:17 +02:00
Scope the encryption key and its dependency to fastmcp-tasks
The setting becomes FASTMCP_TASKS_ENCRYPTION_KEY on a new TasksSettings class, because this key protects task context snapshots, not all FastMCP data at rest. fastmcp-tasks now declares cryptography itself instead of pushing it onto fastmcp-slim[server]. Addresses review feedback on #4772. 🤖 Generated with Claude Code Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4411cfe3ca
commit
62b76959e4
11 changed files with 92 additions and 79 deletions
|
|
@ -81,15 +81,14 @@ These control how the server listens when running with an HTTP transport.
|
|||
|
||||
## Tasks (Docket)
|
||||
|
||||
Task settings (the `FASTMCP_DOCKET_` variables) moved to the optional `fastmcp-tasks` package. See [server tasks](/servers/tasks) for configuration.
|
||||
Task settings (the `FASTMCP_DOCKET_` and `FASTMCP_TASKS_` variables) live in the optional `fastmcp-tasks` package. See [server tasks](/servers/tasks) for configuration, including `FASTMCP_TASKS_ENCRYPTION_KEY` for [encrypting task snapshots at rest](/servers/tasks#credentials-at-rest).
|
||||
|
||||
## Security
|
||||
|
||||
These control encryption of FastMCP's data at rest, and its SSRF protection for the outbound fetches it makes during authentication (OAuth client metadata and JWKS).
|
||||
These control FastMCP's SSRF protection for the outbound fetches it makes during authentication (OAuth client metadata and JWKS).
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `FASTMCP_ENCRYPTION_KEY` | `str` | None | Key used to encrypt sensitive FastMCP data at rest. Today this protects the [task context snapshot](/servers/tasks#credentials-at-rest), which carries the submitting caller's access token and HTTP headers and lives in the Docket backend for the task's TTL. Every server and worker sharing a task queue must set the same key. When unset, the snapshot is stored as plaintext JSON. |
|
||||
| `FASTMCP_SSRF_TRUST_PROXY` | `bool` | `false` | Trust an outbound HTTP proxy for SSRF-protected fetches. When `false`, FastMCP resolves the target hostname itself and refuses to connect if it maps to a private, loopback, link-local, or reserved IP. When `true`, FastMCP routes auth metadata and JWKS fetches through the configured `HTTPS_PROXY`/`ALL_PROXY` and does not honor `NO_PROXY`; if no proxy is configured the fetch is refused. |
|
||||
|
||||
By default, FastMCP protects its OAuth and JWKS fetches against [SSRF](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery) by resolving the target hostname, rejecting any address that maps to a private, loopback, link-local, or reserved IP, and then pinning the connection to that validated IP.
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ mcp.add_extension(TasksExtension(url="redis://localhost:6379/0", concurrency=20)
|
|||
| `FASTMCP_DOCKET_URL` | `memory://` | Backend URL (`memory://` or `redis://host:port/db`) |
|
||||
| `FASTMCP_DOCKET_NAME` | `fastmcp` | Queue name. Servers and workers sharing a name and URL share a queue. |
|
||||
| `FASTMCP_DOCKET_CONCURRENCY` | `10` | Maximum concurrent tasks per worker. |
|
||||
| `FASTMCP_TASKS_ENCRYPTION_KEY` | (unset) | Encrypts [task context snapshots at rest](#credentials-at-rest). Every server and worker sharing a queue must set the same key. |
|
||||
|
||||
## Backends
|
||||
|
||||
|
|
@ -199,10 +200,10 @@ A background task runs long after the request that submitted it has ended, but i
|
|||
|
||||
That snapshot lives in the backend for the task's TTL. With `memory://` it never leaves the process. With Redis or Valkey it is a stored value, and by default it is stored as plaintext JSON. A `rediss://` URL encrypts the connection, not the data the backend holds. Anyone who can read the backend can read the tokens.
|
||||
|
||||
Set `FASTMCP_ENCRYPTION_KEY` to encrypt the snapshot before it is written:
|
||||
Set `FASTMCP_TASKS_ENCRYPTION_KEY` to encrypt the snapshot before it is written:
|
||||
|
||||
```bash
|
||||
export FASTMCP_ENCRYPTION_KEY=$(python -c "import secrets; print(secrets.token_urlsafe(32))")
|
||||
export FASTMCP_TASKS_ENCRYPTION_KEY=$(python -c "import secrets; print(secrets.token_urlsafe(32))")
|
||||
```
|
||||
|
||||
<Warning>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from pathlib import Path
|
|||
from typing import Annotated, Any, Literal
|
||||
|
||||
from platformdirs import user_data_dir
|
||||
from pydantic import Field, SecretStr, field_validator
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import (
|
||||
BaseSettings,
|
||||
SettingsConfigDict,
|
||||
|
|
@ -267,26 +267,6 @@ class Settings(BaseSettings):
|
|||
),
|
||||
] = False
|
||||
|
||||
encryption_key: Annotated[
|
||||
SecretStr | None,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
Key used to encrypt sensitive FastMCP data at rest. Today this
|
||||
protects the task-context snapshot, which carries the submitting
|
||||
caller's access token and HTTP headers and is written to the
|
||||
Docket backend for the task's TTL. Every server and worker
|
||||
sharing a task queue must set the same key; a worker that cannot
|
||||
decrypt a snapshot fails the task rather than running it as an
|
||||
anonymous caller. When unset, the snapshot is stored as
|
||||
plaintext JSON. The Fernet key is derived from this value with
|
||||
PBKDF2, so any non-empty string works, but use at least 32
|
||||
random characters.
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = None
|
||||
|
||||
server_dependencies: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of dependencies to install in the server environment",
|
||||
|
|
|
|||
|
|
@ -92,10 +92,6 @@ openai = ["openai>=1.102.0"]
|
|||
server = [
|
||||
"fastmcp-slim[mcp]=={{ version }}",
|
||||
"authlib>=1.6.11",
|
||||
# Fernet and the HKDF/PBKDF2 key derivation behind FASTMCP_ENCRYPTION_KEY and
|
||||
# the OAuth proxy's storage encryption. Previously reached only through
|
||||
# authlib.
|
||||
"cryptography>=43.0.0",
|
||||
"cyclopts>=4.0.0",
|
||||
"griffelib>=2.0.0",
|
||||
"jsonref>=1.1.0",
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ class TaskContextSnapshot:
|
|||
) -> None:
|
||||
"""Store this snapshot as a single Redis key.
|
||||
|
||||
The stored value is encrypted when a ``FASTMCP_ENCRYPTION_KEY`` is
|
||||
The stored value is encrypted when a ``FASTMCP_TASKS_ENCRYPTION_KEY`` is
|
||||
configured: this payload carries the caller's bearer token and headers,
|
||||
and a distributed backend keeps it where the backend's operators can
|
||||
read it (#4747).
|
||||
|
|
@ -374,8 +374,9 @@ async def restore_task_snapshot(key: str = TaskKey()) -> None:
|
|||
# workers is the likely reason and is not guessable from the wire error.
|
||||
_logger.error(
|
||||
"Failed to decrypt the task snapshot for %s. Every server and worker "
|
||||
"on this queue must share the same FASTMCP_ENCRYPTION_KEY. The task "
|
||||
"will fail rather than run without the submitting caller's identity.",
|
||||
"on this queue must share the same FASTMCP_TASKS_ENCRYPTION_KEY. The "
|
||||
"task will fail rather than run without the submitting caller's "
|
||||
"identity.",
|
||||
key,
|
||||
)
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ inbound HTTP header, and it lives in the Docket backend for the task's TTL. A
|
|||
distributed backend therefore keeps bearer credentials in Redis, where a
|
||||
``rediss://`` URL protects the wire but not the stored value.
|
||||
|
||||
Setting ``FASTMCP_ENCRYPTION_KEY`` turns the stored snapshot into a Fernet token.
|
||||
The same key must reach every server and worker on the queue, because the process
|
||||
that restores a snapshot is rarely the one that captured it.
|
||||
Setting ``FASTMCP_TASKS_ENCRYPTION_KEY`` turns the stored snapshot into a
|
||||
Fernet token. The same key must reach every server and worker on the queue,
|
||||
because the process that restores a snapshot is rarely the one that captured it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -16,13 +16,13 @@ from abc import ABC, abstractmethod
|
|||
from functools import lru_cache
|
||||
from typing import ClassVar
|
||||
|
||||
import fastmcp
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
from fastmcp_tasks.settings import tasks_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Domain separation: FASTMCP_ENCRYPTION_KEY is meant to serve other at-rest uses
|
||||
# over time, and each derives its own Fernet key from this shared material.
|
||||
# Domain separation: FASTMCP_TASKS_ENCRYPTION_KEY may protect other task-owned
|
||||
# state over time, and each use derives its own Fernet key from this material.
|
||||
_SNAPSHOT_KEY_SALT = "fastmcp-task-snapshot-key"
|
||||
|
||||
# Below this, warn: the keyspace is small enough that the offline attacker this
|
||||
|
|
@ -86,7 +86,7 @@ class PlaintextCodec(SnapshotCodec):
|
|||
if text.startswith(_FERNET_PREFIX):
|
||||
raise SnapshotDecryptionError(
|
||||
"The stored task snapshot is encrypted, but this process has "
|
||||
"no FASTMCP_ENCRYPTION_KEY configured."
|
||||
"no FASTMCP_TASKS_ENCRYPTION_KEY configured."
|
||||
)
|
||||
return text
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ class EncryptedCodec(SnapshotCodec):
|
|||
|
||||
if not material:
|
||||
raise ValueError(
|
||||
"FASTMCP_ENCRYPTION_KEY must not be empty. Unset it to store "
|
||||
"FASTMCP_TASKS_ENCRYPTION_KEY must not be empty. Unset it to store "
|
||||
"task snapshots as plaintext, or set at least 32 random "
|
||||
"characters."
|
||||
)
|
||||
|
|
@ -141,7 +141,7 @@ class EncryptedCodec(SnapshotCodec):
|
|||
except InvalidToken as e:
|
||||
raise SnapshotDecryptionError(
|
||||
"The stored task snapshot could not be decrypted with the "
|
||||
"configured FASTMCP_ENCRYPTION_KEY."
|
||||
"configured FASTMCP_TASKS_ENCRYPTION_KEY."
|
||||
) from e
|
||||
|
||||
|
||||
|
|
@ -160,7 +160,7 @@ def _codec_for(material: str) -> EncryptedCodec:
|
|||
|
||||
def snapshot_codec() -> SnapshotCodec:
|
||||
"""The codec for the configured key; the plaintext codec when none is set."""
|
||||
key = fastmcp.settings.encryption_key
|
||||
key = tasks_settings.encryption_key
|
||||
if key is None:
|
||||
return _PLAINTEXT_CODEC
|
||||
return _codec_for(key.get_secret_value())
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import os
|
|||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import Field, SecretStr
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Load the same dotenv source as core FastMCP settings, so a deployment that
|
||||
|
|
@ -129,6 +129,38 @@ class DocketSettings(BaseSettings):
|
|||
docket_settings = DocketSettings()
|
||||
|
||||
|
||||
class TasksSettings(BaseSettings):
|
||||
"""Settings for the task engine itself, as opposed to its Docket backend."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="FASTMCP_TASKS_",
|
||||
env_file=_ENV_FILE,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
encryption_key: Annotated[
|
||||
SecretStr | None,
|
||||
Field(
|
||||
description=inspect.cleandoc(
|
||||
"""
|
||||
Key used to encrypt task context snapshots at rest. The snapshot
|
||||
carries the submitting caller's access token and HTTP headers,
|
||||
and it is written to the Docket backend for the task's TTL.
|
||||
Every server and worker sharing a task queue must set the same
|
||||
key; a worker that cannot decrypt a snapshot fails the task
|
||||
rather than running it as an anonymous caller. When unset, the
|
||||
snapshot is stored as plaintext JSON. The Fernet key is derived
|
||||
from this value with PBKDF2, so any non-empty string works, but
|
||||
use at least 32 random characters.
|
||||
"""
|
||||
),
|
||||
),
|
||||
] = None
|
||||
|
||||
|
||||
tasks_settings = TasksSettings()
|
||||
|
||||
|
||||
class TasksClientSettings(BaseSettings):
|
||||
"""Client-side settings for driving background tasks.
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ fallback-version = "0.0.0"
|
|||
[tool.hatch.metadata.hooks.uv-dynamic-versioning]
|
||||
dependencies = [
|
||||
"fastmcp-slim[server]=={{ version }}",
|
||||
# Fernet and the PBKDF2 key derivation behind FASTMCP_TASKS_ENCRYPTION_KEY,
|
||||
# which encrypts task context snapshots at rest.
|
||||
"cryptography>=43.0.0",
|
||||
"pydocket>=0.20.0",
|
||||
# burner-redis 0.1.7's Windows build crashes the interpreter (native fault,
|
||||
# no Python traceback) running the memory:// backend under pytest-xdist —
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
The snapshot carries the submitting caller's access token and every inbound HTTP
|
||||
header, and it is written to the Docket backend for the task's TTL. With a
|
||||
distributed backend those credentials sit in Redis where the backend's operators
|
||||
can read them. Setting ``FASTMCP_ENCRYPTION_KEY`` makes the snapshot a Fernet
|
||||
can read them. Setting ``FASTMCP_TASKS_ENCRYPTION_KEY`` makes the snapshot a Fernet
|
||||
token instead, and makes a worker that cannot decrypt one fail the task rather
|
||||
than run it as an anonymous caller.
|
||||
"""
|
||||
|
|
@ -25,9 +25,9 @@ from fastmcp_tasks.encryption import (
|
|||
snapshot_codec,
|
||||
)
|
||||
from fastmcp_tasks.keys import task_redis_prefix
|
||||
from fastmcp_tasks.settings import TasksSettings, tasks_settings
|
||||
from pydantic import SecretStr
|
||||
|
||||
import fastmcp
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
from fastmcp_tasks import TasksExtension
|
||||
|
|
@ -45,14 +45,14 @@ OTHER_KEY = "a-different-test-encryption-key-entirely"
|
|||
|
||||
@pytest.fixture
|
||||
def encryption_key() -> Iterator[str]:
|
||||
"""Configure the global encryption key for the duration of a test."""
|
||||
"""Configure the tasks encryption key for the duration of a test."""
|
||||
clear_codec_cache()
|
||||
previous = fastmcp.settings.encryption_key
|
||||
fastmcp.settings.encryption_key = SecretStr(KEY)
|
||||
previous = tasks_settings.encryption_key
|
||||
tasks_settings.encryption_key = SecretStr(KEY)
|
||||
try:
|
||||
yield KEY
|
||||
finally:
|
||||
fastmcp.settings.encryption_key = previous
|
||||
tasks_settings.encryption_key = previous
|
||||
clear_codec_cache()
|
||||
|
||||
|
||||
|
|
@ -60,12 +60,12 @@ def encryption_key() -> Iterator[str]:
|
|||
def no_encryption_key() -> Iterator[None]:
|
||||
"""Guarantee no key is configured, whatever the ambient environment holds."""
|
||||
clear_codec_cache()
|
||||
previous = fastmcp.settings.encryption_key
|
||||
fastmcp.settings.encryption_key = None
|
||||
previous = tasks_settings.encryption_key
|
||||
tasks_settings.encryption_key = None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fastmcp.settings.encryption_key = previous
|
||||
tasks_settings.encryption_key = previous
|
||||
clear_codec_cache()
|
||||
|
||||
|
||||
|
|
@ -136,10 +136,32 @@ class TestSnapshotCodec:
|
|||
anonymous run, defeating the submitter's fail-closed configuration.
|
||||
"""
|
||||
encrypted = EncryptedCodec(KEY).encode('{"a": 1}')
|
||||
with pytest.raises(SnapshotDecryptionError, match="no FASTMCP_ENCRYPTION_KEY"):
|
||||
with pytest.raises(
|
||||
SnapshotDecryptionError, match="no FASTMCP_TASKS_ENCRYPTION_KEY"
|
||||
):
|
||||
PlaintextCodec().decode(encrypted)
|
||||
|
||||
|
||||
class TestTasksSettings:
|
||||
def test_encryption_key_defaults_to_none(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.delenv("FASTMCP_TASKS_ENCRYPTION_KEY", raising=False)
|
||||
|
||||
assert TasksSettings().encryption_key is None
|
||||
|
||||
def test_encryption_key_env_var(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("FASTMCP_TASKS_ENCRYPTION_KEY", "s3kr1t-material")
|
||||
|
||||
key = TasksSettings().encryption_key
|
||||
assert key is not None
|
||||
assert key.get_secret_value() == "s3kr1t-material"
|
||||
|
||||
def test_encryption_key_is_not_printable(self, monkeypatch: pytest.MonkeyPatch):
|
||||
"""A settings dump must never carry the key into a log."""
|
||||
monkeypatch.setenv("FASTMCP_TASKS_ENCRYPTION_KEY", "s3kr1t-material")
|
||||
|
||||
assert "s3kr1t-material" not in repr(TasksSettings())
|
||||
|
||||
|
||||
class TestSnapshotSerialization:
|
||||
def test_json_round_trip_preserves_every_field(
|
||||
self, sensitive_snapshot: TaskContextSnapshot
|
||||
|
|
@ -265,7 +287,7 @@ class TestEncryptedSnapshotRoundTrip:
|
|||
|
||||
assert final.status == "failed"
|
||||
assert final.error is not None
|
||||
assert "FASTMCP_ENCRYPTION_KEY" in caplog.text
|
||||
assert "FASTMCP_TASKS_ENCRYPTION_KEY" in caplog.text
|
||||
|
||||
async def test_missing_snapshot_fails_the_task(
|
||||
self, echo_token_server: FastMCP, encryption_key: str
|
||||
|
|
@ -332,7 +354,7 @@ class TestEncryptedSnapshotRoundTrip:
|
|||
created = await submit_task(
|
||||
echo_token_server, "whoami", {}, access_token=token
|
||||
)
|
||||
fastmcp.settings.encryption_key = None
|
||||
tasks_settings.encryption_key = None
|
||||
clear_codec_cache()
|
||||
final = await wait_for_task(
|
||||
echo_token_server,
|
||||
|
|
|
|||
|
|
@ -19,24 +19,3 @@ def test_http_host_origin_protection_env_var(value, expected, monkeypatch):
|
|||
monkeypatch.setenv("FASTMCP_HTTP_HOST_ORIGIN_PROTECTION", value)
|
||||
|
||||
assert Settings().http_host_origin_protection == expected
|
||||
|
||||
|
||||
def test_encryption_key_defaults_to_none(monkeypatch):
|
||||
monkeypatch.delenv("FASTMCP_ENCRYPTION_KEY", raising=False)
|
||||
|
||||
assert Settings().encryption_key is None
|
||||
|
||||
|
||||
def test_encryption_key_env_var(monkeypatch):
|
||||
monkeypatch.setenv("FASTMCP_ENCRYPTION_KEY", "s3kr1t-material")
|
||||
|
||||
key = Settings().encryption_key
|
||||
assert key is not None
|
||||
assert key.get_secret_value() == "s3kr1t-material"
|
||||
|
||||
|
||||
def test_encryption_key_is_not_printable(monkeypatch):
|
||||
"""A settings dump must never carry the key into a log."""
|
||||
monkeypatch.setenv("FASTMCP_ENCRYPTION_KEY", "s3kr1t-material")
|
||||
|
||||
assert "s3kr1t-material" not in repr(Settings())
|
||||
|
|
|
|||
4
uv.lock
generated
4
uv.lock
generated
|
|
@ -1009,7 +1009,6 @@ openai = [
|
|||
]
|
||||
server = [
|
||||
{ name = "authlib" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "cyclopts" },
|
||||
{ name = "exceptiongroup" },
|
||||
{ name = "griffelib" },
|
||||
|
|
@ -1038,7 +1037,6 @@ requires-dist = [
|
|||
{ name = "authlib", marker = "extra == 'client'", specifier = ">=1.6.11" },
|
||||
{ name = "authlib", marker = "extra == 'server'", specifier = ">=1.6.11" },
|
||||
{ name = "azure-identity", marker = "extra == 'azure'", specifier = ">=1.16.0" },
|
||||
{ name = "cryptography", marker = "extra == 'server'", specifier = ">=43.0.0" },
|
||||
{ name = "cyclopts", marker = "extra == 'server'", specifier = ">=4.0.0" },
|
||||
{ name = "exceptiongroup", marker = "extra == 'client'", specifier = ">=1.2.2" },
|
||||
{ name = "exceptiongroup", marker = "extra == 'mcp'", specifier = ">=1.2.2" },
|
||||
|
|
@ -1091,6 +1089,7 @@ name = "fastmcp-tasks"
|
|||
source = { editable = "fastmcp_tasks" }
|
||||
dependencies = [
|
||||
{ name = "burner-redis", marker = "sys_platform == 'win32'" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastmcp-slim", extra = ["server"] },
|
||||
{ name = "pydocket" },
|
||||
]
|
||||
|
|
@ -1098,6 +1097,7 @@ dependencies = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "burner-redis", marker = "sys_platform == 'win32'", specifier = "<0.1.7" },
|
||||
{ name = "cryptography", specifier = ">=43.0.0" },
|
||||
{ name = "fastmcp-slim", extras = ["server"], editable = "fastmcp_slim" },
|
||||
{ name = "pydocket", specifier = ">=0.20.0" },
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue