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:
Chris Guidry 2026-08-06 12:06:08 -04:00
commit 62b76959e4
11 changed files with 92 additions and 79 deletions

View file

@ -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.

View file

@ -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>

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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())

View file

@ -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.

View file

@ -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 —

View file

@ -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,

View file

@ -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
View file

@ -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" },
]