diff --git a/docs/more/settings.mdx b/docs/more/settings.mdx index 9e90b27de..e6ea0f584 100644 --- a/docs/more/settings.mdx +++ b/docs/more/settings.mdx @@ -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. diff --git a/docs/servers/tasks.mdx b/docs/servers/tasks.mdx index 4ee00ad13..7b374473d 100644 --- a/docs/servers/tasks.mdx +++ b/docs/servers/tasks.mdx @@ -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))") ``` diff --git a/fastmcp_slim/fastmcp/settings.py b/fastmcp_slim/fastmcp/settings.py index c62236e7f..d442d7218 100644 --- a/fastmcp_slim/fastmcp/settings.py +++ b/fastmcp_slim/fastmcp/settings.py @@ -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", diff --git a/fastmcp_slim/pyproject.toml b/fastmcp_slim/pyproject.toml index 1689bfbba..b009efd42 100644 --- a/fastmcp_slim/pyproject.toml +++ b/fastmcp_slim/pyproject.toml @@ -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", diff --git a/fastmcp_tasks/fastmcp_tasks/context.py b/fastmcp_tasks/fastmcp_tasks/context.py index 4149756ec..64d6f474b 100644 --- a/fastmcp_tasks/fastmcp_tasks/context.py +++ b/fastmcp_tasks/fastmcp_tasks/context.py @@ -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 diff --git a/fastmcp_tasks/fastmcp_tasks/encryption.py b/fastmcp_tasks/fastmcp_tasks/encryption.py index 68b81dd5d..34b4fec12 100644 --- a/fastmcp_tasks/fastmcp_tasks/encryption.py +++ b/fastmcp_tasks/fastmcp_tasks/encryption.py @@ -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()) diff --git a/fastmcp_tasks/fastmcp_tasks/settings.py b/fastmcp_tasks/fastmcp_tasks/settings.py index b836b0f67..8dcc96504 100644 --- a/fastmcp_tasks/fastmcp_tasks/settings.py +++ b/fastmcp_tasks/fastmcp_tasks/settings.py @@ -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. diff --git a/fastmcp_tasks/pyproject.toml b/fastmcp_tasks/pyproject.toml index a491aaaa8..6a1de018f 100644 --- a/fastmcp_tasks/pyproject.toml +++ b/fastmcp_tasks/pyproject.toml @@ -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 — diff --git a/tests/tasks/server/test_snapshot_encryption.py b/tests/tasks/server/test_snapshot_encryption.py index 1801d5b47..0c35b6fe4 100644 --- a/tests/tasks/server/test_snapshot_encryption.py +++ b/tests/tasks/server/test_snapshot_encryption.py @@ -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, diff --git a/tests/test_settings.py b/tests/test_settings.py index 1cb5a3d5f..09d014a66 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -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()) diff --git a/uv.lock b/uv.lock index 9d73b321f..0a9cd0bc3 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, ]