deer-flow/backend/tests/test_deploy_home_writability.py
Nan Gao db7340d2fa
fix(security): add DEER_FLOW_CREDENTIALS_KEY and wire the channel credentials cipher (#6611)
* fix(security): add DEER_FLOW_CREDENTIALS_KEY for at-rest credential encryption

There was no at-rest encryption key. ChannelCredentialCipher had no
production caller: the channel service, the scheduled-task notification
outbox and the channel-connections router all built
ChannelConnectionRepository without a cipher, so store_credentials refused
(RuntimeError) and get_credentials always returned None.

- New harness module deerflow.config.credentials_key: env-only
  DEER_FLOW_CREDENTIALS_KEY (comma-separated Fernet keys; first encrypts,
  all decrypt via MultiFernet), CredentialsCipher with encrypt_text /
  decrypt_text / rotate_text, a fernet:v2: prefix with legacy fernet:v1:
  values still readable, and get_credentials_cipher() as the process-wide
  entry point. Malformed keys are refused without echoing key material.
  Without the variable a single instance generates
  {base_dir}/.credentials_key. Never derived from AUTH_JWT_SECRET.
- New deerflow.config.secret_file.read_or_create_secret_file: O_EXCL
  create (0600) plus read-back with a bounded settle window, shared by
  .credentials_key, .jwt_secret (fixes two replicas cold-starting on a
  shared volume keeping different JWT secrets) and the managed-model key.
- ChannelCredentialCipher now builds on CredentialsCipher (same
  constructors); the Gateway loads the cipher off the event loop at
  startup when channel_connections is enabled and passes it to every
  ChannelConnectionRepository. Undecryptable values stay "missing + warn".
- Startup gate: a malformed key stops startup in any topology; a declared
  multi-instance deployment with channel_connections enabled and no key
  is refused. Plain multi-worker shares the generated file and is allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(deploy): generate and inject DEER_FLOW_CREDENTIALS_KEY in Helm, compose and deploy.sh

- Helm: the app Secret generates a Fernet key (randBytes 32 translated to
  the urlsafe alphabet) and preserves it across upgrades via lookup like
  AUTH_JWT_SECRET; the gateway reads it through an optional secretKeyRef
  so a user-managed existingAppSecret without it still boots (the Gateway
  gate refuses only multi-instance deployments that store credentials).
  README, NOTES and values document backup, rotation and existingAppSecret.
- scripts/deploy.sh resolves the key shell -> .env -> persisted file ->
  generated, persisting it as $DEER_FLOW_HOME/.credentials_key (0600,
  noclobber/O_EXCL with read-back): the same file the Gateway generates in
  its runtime home, so either side's key is the one both keep.
- Both compose files pass DEER_FLOW_CREDENTIALS_KEY=${...:-} to the gateway.
- .env.example entry with a generation hint; README and CHANGELOG notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(security): make abandoned secret-file replacement single-winner

A rename is last-writer-wins, so replicas that restart together and all
find the same abandoned empty secret file could each replace it and keep
a different value. The process that exclusively creates
<name>.replacing now owns the replacement, re-checks the file is still
empty, and publishes by renaming the claim over it; peers wait for that
value, bounded so a crashed replacer cannot block startup forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): link DEER_FLOW_CREDENTIALS_KEY entries to #6611

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(security): name the setting that turns off a credentials-key consumer

The multi-instance refusal ended with "or disable
channel_connections.enabled=true"; it now says "or set
channel_connections.enabled=false".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(security): never expose a secret file before its value is written

O_EXCL published the name before its content, so a creator that stalled
mid-write left an empty file a peer replaced after the settle window; the
resumed creator then returned a value nobody persisted, or deleted the
peer's valid file when its own write failed. Creators now write and sync
a private temporary file and hard-link it to the name, which fails if
the name exists; a failed creator only removes its own temporary file.
Filesystems without hard links publish through the same single-winner
<name>.replacing claim that replaces abandoned empty files, so creation
and replacement are serialized there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(models): recover an empty managed-model key when no catalog exists

An interrupted first save could leave an empty key file; every later save
then passed empty bytes to Fernet and the endpoint returned 503 until the
file was removed by hand. An empty key with no catalog encrypted nothing,
so it now goes through the secret-file recovery path; next to an existing
catalog it is still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(docker): pass the dotenv DEER_FLOW_CREDENTIALS_KEY through make docker-start

scripts/docker.sh runs Compose from docker/ without --env-file, so the dev
gateway's DEER_FLOW_CREDENTIALS_KEY=${DEER_FLOW_CREDENTIALS_KEY:-} entry
interpolated to empty and overrode the key the checkout .env supplied via
env_file. Export it from the dotenv like AUTH_TRUSTED_PROXIES.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:51:09 +08:00

245 lines
9.3 KiB
Python

"""Regression coverage for deploy.sh's DEER_FLOW_HOME permission preflight."""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from support.shell import find_script_bash
REPO_ROOT = Path(__file__).resolve().parents[2]
BASH = find_script_bash()
pytestmark = pytest.mark.skipif(BASH is None, reason="repo shell-script tests need Git Bash on Windows")
@pytest.fixture
def deploy_fixture(tmp_path: Path):
"""Run the real deploy script with isolated state and a recording Docker stub."""
worktree = tmp_path / "repo"
shutil.copytree(REPO_ROOT / "scripts", worktree / "scripts")
shutil.copytree(REPO_ROOT / "docker", worktree / "docker")
(worktree / "backend").mkdir()
config = worktree / "config.yaml"
config.write_text("sandbox:\n use: deerflow.sandbox:LocalSandboxProvider\n", encoding="utf-8")
extensions = worktree / "extensions_config.json"
extensions.write_text('{"mcpServers":{},"skills":{}}\n', encoding="utf-8")
# A path containing spaces also checks quoting in the guard and recovery hint.
home = tmp_path / "runtime home"
home.mkdir()
capture = tmp_path / "docker-args.txt"
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
docker = bin_dir / "docker"
docker.write_text(
'#!/usr/bin/env sh\nprintf "%s\\n" "$@" >> "$CAPTURE_DOCKER_ARGS"\ncase " $* " in\n *" config "*) printf " DEER_FLOW_PROBE_VALUE: %s\\n" "${PERMISSION_TEST_COMPOSE_SECRET:-\\"\\"}" ;;\nesac\n',
encoding="utf-8",
)
docker.chmod(0o755)
env = os.environ.copy()
env.pop("PERMISSION_TEST_COMPOSE_SECRET", None)
env.update(
PATH=f"{bin_dir}{os.pathsep}{env['PATH']}",
DEER_FLOW_HOME=str(home),
DEER_FLOW_CONFIG_PATH=str(config),
DEER_FLOW_EXTENSIONS_CONFIG_PATH=str(extensions),
CAPTURE_DOCKER_ARGS=str(capture),
# Keep the preflight independent of secret generation and optional extras.
BETTER_AUTH_SECRET="test-better-auth-secret",
DEER_FLOW_INTERNAL_AUTH_TOKEN="test-internal-auth-token",
DEER_FLOW_CREDENTIALS_KEY="test-credentials-key",
UV_EXTRAS="redis",
)
return worktree, env, home, capture
@pytest.fixture
def unwritable_home(deploy_fixture):
"""Reproduce unwritability without chown, restoring permissions on every exit."""
if os.name != "posix":
pytest.skip("directory write permissions require a POSIX filesystem")
if os.geteuid() == 0:
pytest.skip("root can write directories despite removed write permissions")
_, _, home, _ = deploy_fixture
original_mode = home.stat().st_mode
home.chmod(0o555)
try:
if os.access(home, os.W_OK):
pytest.skip("filesystem does not enforce directory write permissions")
yield home
finally:
home.chmod(original_mode)
@pytest.fixture(
params=[
("BETTER_AUTH_SECRET", ".better-auth-secret"),
("DEER_FLOW_INTERNAL_AUTH_TOKEN", ".internal-auth-token"),
("DEER_FLOW_CREDENTIALS_KEY", ".credentials_key"),
],
ids=["better-auth", "internal-auth", "credentials-key"],
)
def persisted_secret(request, deploy_fixture):
_, _, home, _ = deploy_fixture
key, filename = request.param
secret_file = home / filename
secret_file.write_text("persisted-test-secret\n", encoding="utf-8")
return key, secret_file
@pytest.fixture
def unreadable_secret(persisted_secret):
if os.name != "posix":
pytest.skip("file read permissions require a POSIX filesystem")
if os.geteuid() == 0:
pytest.skip("root can read files despite removed read permissions")
_, secret_file = persisted_secret
original_mode = secret_file.stat().st_mode
secret_file.chmod(0o000)
try:
if os.access(secret_file, os.R_OK):
pytest.skip("filesystem does not enforce file read permissions")
yield secret_file
finally:
secret_file.chmod(original_mode)
def _run_deploy(deploy_fixture, command: str):
worktree, env, _, _ = deploy_fixture
return subprocess.run(
[BASH, str(worktree / "scripts" / "deploy.sh"), *([command] if command else [])],
cwd=worktree,
env=env,
capture_output=True,
text=True,
check=False,
timeout=30,
)
@pytest.mark.parametrize("command", ["", "build", "start"], ids=["up", "build", "start"])
def test_deploy_rejects_unwritable_home_before_invoking_docker(deploy_fixture, unwritable_home, command):
_, _, _, capture = deploy_fixture
result = _run_deploy(deploy_fixture, command)
assert result.returncode == 1
assert f"{unwritable_home} is not writable by '" in result.stderr
assert "make docker-start" in result.stderr
assert f"sudo chown -R {os.geteuid()}:{os.getegid()} '{unwritable_home}'" in result.stderr
assert not capture.exists(), "Docker must not run when the home preflight fails"
@pytest.mark.parametrize(
("command", "compose_command"),
[("", "up"), ("build", "build"), ("start", "up"), ("down", "down")],
ids=["up", "build", "start", "down"],
)
def test_deploy_allows_writable_home(deploy_fixture, command, compose_command):
_, _, _, capture = deploy_fixture
result = _run_deploy(deploy_fixture, command)
assert result.returncode == 0, result.stderr
assert "is not writable" not in result.stderr
assert compose_command in capture.read_text(encoding="utf-8").splitlines()
def test_deploy_down_allows_unwritable_home(deploy_fixture, unwritable_home):
_, env, _, capture = deploy_fixture
env.pop("BETTER_AUTH_SECRET")
env.pop("DEER_FLOW_INTERNAL_AUTH_TOKEN")
env.pop("DEER_FLOW_CREDENTIALS_KEY")
result = _run_deploy(deploy_fixture, "down")
assert result.returncode == 0, result.stderr
assert "is not writable" not in result.stderr
assert capture.read_text(encoding="utf-8").splitlines()[-1] == "down"
assert not list(unwritable_home.iterdir()), "Stopping must not generate secrets"
@pytest.mark.parametrize("command", ["", "build", "start"], ids=["up", "build", "start"])
def test_deploy_rejects_unreadable_persisted_secret_before_starting_compose(deploy_fixture, persisted_secret, unreadable_secret, command):
_, env, home, capture = deploy_fixture
key, _ = persisted_secret
env.pop(key)
result = _run_deploy(deploy_fixture, command)
assert result.returncode == 1
assert f"{unreadable_secret} is not readable by '" in result.stderr
assert "make docker-start" in result.stderr
assert f"sudo chown -R {os.geteuid()}:{os.getegid()} '{home}'" in result.stderr
assert "persisted-test-secret" not in result.stdout + result.stderr
args = capture.read_text(encoding="utf-8").splitlines()
assert "config" in args
assert not {"up", "build"}.intersection(args), "Only the dotenv probe may run before a secret preflight fails"
@pytest.mark.parametrize("source", ["shell", "dotenv"])
def test_deploy_allows_override_of_unreadable_persisted_secret(deploy_fixture, persisted_secret, unreadable_secret, source):
worktree, env, _, capture = deploy_fixture
key, _ = persisted_secret
if source == "dotenv":
env.pop(key)
(worktree / ".env").write_text(f"{key}=override-from-dotenv\n", encoding="utf-8")
env["PERMISSION_TEST_COMPOSE_SECRET"] = "override-from-dotenv"
result = _run_deploy(deploy_fixture, "build")
assert result.returncode == 0, result.stderr
assert "is not readable" not in result.stderr
assert unreadable_secret.stat().st_mode & 0o777 == 0
assert "build" in capture.read_text(encoding="utf-8").splitlines()
if source == "dotenv":
assert f"{key} loaded from {worktree / '.env'}" in result.stdout
def test_deploy_allows_readonly_persisted_secret(deploy_fixture, persisted_secret):
if os.name != "posix":
pytest.skip("file write permissions require a POSIX filesystem")
if os.geteuid() == 0:
pytest.skip("root can write files despite removed write permissions")
_, env, _, capture = deploy_fixture
key, secret_file = persisted_secret
env.pop(key)
original_mode = secret_file.stat().st_mode
secret_file.chmod(0o400)
try:
if os.access(secret_file, os.W_OK):
pytest.skip("filesystem does not enforce file write permissions")
result = _run_deploy(deploy_fixture, "build")
assert result.returncode == 0, result.stderr
assert f"{key} loaded from {secret_file}" in result.stdout
assert secret_file.read_text(encoding="utf-8") == "persisted-test-secret\n"
assert secret_file.stat().st_mode & 0o777 == 0o400
assert "build" in capture.read_text(encoding="utf-8").splitlines()
finally:
secret_file.chmod(original_mode)
def test_deploy_down_allows_unreadable_persisted_secret(deploy_fixture, unreadable_secret):
_, env, home, capture = deploy_fixture
env.pop("BETTER_AUTH_SECRET")
env.pop("DEER_FLOW_INTERNAL_AUTH_TOKEN")
env.pop("DEER_FLOW_CREDENTIALS_KEY")
result = _run_deploy(deploy_fixture, "down")
assert result.returncode == 0, result.stderr
assert "is not readable" not in result.stderr
args = capture.read_text(encoding="utf-8").splitlines()
assert args[-1] == "down"
assert "config" not in args, "Stopping must not resolve secrets"
assert list(home.iterdir()) == [unreadable_secret], "Stopping must not generate secrets"