deer-flow/backend/tests/test_compose_auth_trusted_proxies.py
Hyeonsang Cho 9c159ef880
fix(auth): key the compose login lockout by client, not by nginx (#6519)
* fix(auth): key the compose login lockout by client, not by nginx

Failed logins are counted per client IP, and the Gateway honors X-Real-IP
only from a peer listed in AUTH_TRUSTED_PROXIES. The compose files never
set it, and every browser request reaches the Gateway from the nginx
container, so all logins shared nginx's address: five wrong passwords
from anyone locked every user out for five minutes.

AUTH_TRUSTED_PROXIES now also accepts hostnames, resolved off the event
loop and cached for 10 seconds (failed lookups included, and only when
X-Real-IP is present), and both compose files default it to the bundled
nginx service, whose address Docker assigns. nginx overwrites X-Real-IP
with $remote_addr on every Gateway route; a test pins that for the three
maintained configs.

* docs(changelog): reference #6519

* fix(auth): share one trusted-proxy lookup across concurrent cache misses

A burst of setup-status / login requests on an expired hostname entry
started one getaddrinfo per request while DNS was slow, occupying the
default executor that password verification also uses. The cache check
and an in-flight task registration now happen without a yield between
them; later callers join that task through asyncio.shield, so a cancelled
request cannot cancel the lookup the others joined, and the task caches
its own result.

The compose default keeps `:-` on purpose: every browser request arrives
through nginx, so an empty AUTH_TRUSTED_PROXIES would only bring back the
shared lockout. The compose comment and .env.example say so, and the
rendered-compose test now covers the explicitly empty value.

* fix(docker): keep a .env AUTH_TRUSTED_PROXIES under make docker-start

docker.sh start runs Compose from docker/ without --env-file, so the dev
compose file's ${AUTH_TRUSTED_PROXIES:-nginx} interpolated from the shell
only, and the resolved environment entry outranked the operator's value
loaded through env_file. Export it from .env alongside the proxy variables
(a shell export still wins), and strip a CRLF before the quotes.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-09 17:22:45 +08:00

170 lines
7.9 KiB
Python

"""The compose Gateway must key the login lockout by client, not by nginx.
``auth.py`` counts failed logins per client IP and honors ``X-Real-IP`` only
from a peer listed in ``AUTH_TRUSTED_PROXIES``. In the compose stack every
browser request reaches the Gateway from the ``nginx`` container, whose address
Docker assigns, so with the variable unset all logins shared nginx's address
and five wrong passwords from anyone locked out every user. Both compose files
name the bundled proxy by its service name; that trust is safe only while
nginx overwrites ``X-Real-IP`` on every route to the Gateway, pinned below for
the three maintained nginx configs.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
from pathlib import Path
import pytest
import yaml
from support.compose import DOCKER, requires_docker_compose
from support.nginx_conf import NGINX_CONFIGS, read_config
from support.shell import find_script_bash
REPO_ROOT = Path(__file__).resolve().parents[2]
COMPOSE_PATHS = {
"prod": REPO_ROOT / "docker" / "docker-compose.yaml",
"dev": REPO_ROOT / "docker" / "docker-compose-dev.yaml",
}
EXPECTED_ENTRY = "AUTH_TRUSTED_PROXIES=${AUTH_TRUSTED_PROXIES:-nginx}"
BASH_EXECUTABLE = find_script_bash()
@pytest.mark.parametrize("variant", sorted(COMPOSE_PATHS))
def test_gateway_trusts_the_bundled_nginx_service_by_default(variant: str):
compose = yaml.safe_load(COMPOSE_PATHS[variant].read_text(encoding="utf-8"))
services = compose["services"]
assert EXPECTED_ENTRY in services["gateway"]["environment"]
# The default resolves only while the proxy is the service named ``nginx``
# on a network the Gateway shares.
assert set(services["nginx"]["networks"]) & set(services["gateway"]["networks"])
def _render(tmp_path: Path, variant: str, env_file: str | None) -> dict:
"""Render the compose file the way deploy.sh calls it (``--env-file ../.env``)."""
docker_dir = tmp_path / "docker"
shutil.copytree(REPO_ROOT / "docker", docker_dir)
(tmp_path / "frontend").mkdir(exist_ok=True)
env = {key: value for key, value in os.environ.items() if not key.startswith(("DEER_FLOW_", "COMPOSE_", "AUTH_"))}
env.update(
DEER_FLOW_ROOT=str(tmp_path),
DEER_FLOW_HOME=str(tmp_path / "home"),
DEER_FLOW_REPO_ROOT=str(tmp_path),
DEER_FLOW_CONFIG_PATH=str(tmp_path / "config.yaml"),
DEER_FLOW_EXTENSIONS_CONFIG_PATH=str(tmp_path / "extensions_config.json"),
BETTER_AUTH_SECRET="test-secret",
DEER_FLOW_INTERNAL_AUTH_TOKEN="test-token",
)
command = [DOCKER, "compose"]
if env_file is not None:
(tmp_path / ".env").write_text(env_file, encoding="utf-8")
command += ["--env-file", str(tmp_path / ".env")]
command += ["-p", "deer-flow-trusted-proxies-test", "-f", str(docker_dir / COMPOSE_PATHS[variant].name), "config", "--format", "json"]
result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=120, check=False)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)["services"]["gateway"]["environment"]
@requires_docker_compose
@pytest.mark.parametrize("variant", sorted(COMPOSE_PATHS))
@pytest.mark.parametrize(
("env_file", "expected"),
[(None, "nginx"), ("AUTH_TRUSTED_PROXIES=\n", "nginx"), ("AUTH_TRUSTED_PROXIES=10.0.0.0/8,edge-proxy\n", "10.0.0.0/8,edge-proxy")],
# An explicitly empty value is not direct mode here: every browser request
# comes through nginx, so it would only bring back the shared lockout.
ids=["default", "explicitly-empty", "operator-override"],
)
def test_real_compose_renders_the_default_and_keeps_an_operator_override(tmp_path, variant: str, env_file: str | None, expected: str):
assert _render(tmp_path, variant, env_file)["AUTH_TRUSTED_PROXIES"] == expected
def _render_through_docker_start(tmp_path: Path, env_file: str, shell_value: str | None = None) -> dict:
"""Render the dev compose file with the exact command, cwd and environment ``make docker-start`` uses.
``scripts/docker.sh start`` runs Compose from ``docker/`` without
``--env-file``, so interpolation never reads the checkout ``.env`` by itself.
A ``docker`` shell function stands in for the CLI and swaps ``up ...`` for
``config`` while keeping every argument before it.
"""
docker_dir = tmp_path / "docker"
shutil.copytree(REPO_ROOT / "docker", docker_dir)
(tmp_path / "frontend").mkdir()
(tmp_path / "frontend" / ".env").write_text("", encoding="utf-8")
(tmp_path / ".env").write_text(env_file, encoding="utf-8")
(tmp_path / "config.yaml").write_text("sandbox:\n use: deerflow.sandbox.local:LocalSandboxProvider\n", encoding="utf-8")
(tmp_path / "extensions_config.json").write_text("{}", encoding="utf-8")
rendered = tmp_path / "rendered.json"
env = {key: value for key, value in os.environ.items() if not key.startswith(("DEER_FLOW_", "COMPOSE_", "AUTH_"))}
if shell_value is not None:
env["AUTH_TRUSTED_PROXIES"] = shell_value
script = f"""
source '{REPO_ROOT / "scripts" / "docker.sh"}'
PROJECT_ROOT='{tmp_path}'
DOCKER_DIR='{docker_dir}'
require_compose_version() {{ :; }}
docker() {{
local args=()
for arg in "$@"; do
[ "$arg" = up ] && break
args+=("$arg")
done
command '{DOCKER}' "${{args[@]}}" config --format json > '{rendered}'
}}
start
"""
subprocess.run([BASH_EXECUTABLE, "-c", script], env=env, capture_output=True, text=True, timeout=120, check=True)
return json.loads(rendered.read_text(encoding="utf-8"))["services"]["gateway"]["environment"]
@requires_docker_compose
@pytest.mark.skipif(BASH_EXECUTABLE is None, reason="bash is required to run scripts/docker.sh")
@pytest.mark.parametrize(
("env_file", "shell_value", "expected"),
[
("", None, "nginx"),
("AUTH_TRUSTED_PROXIES=\n", None, "nginx"),
("AUTH_TRUSTED_PROXIES=10.0.0.0/8,edge-proxy\n", None, "10.0.0.0/8,edge-proxy"),
("AUTH_TRUSTED_PROXIES='10.0.0.0/8'\r\n", None, "10.0.0.0/8"),
("AUTH_TRUSTED_PROXIES=10.0.0.0/8\n", "192.0.2.1", "192.0.2.1"),
],
ids=["default", "explicitly-empty", "dotenv-override", "quoted-crlf", "shell-export-wins"],
)
def test_make_docker_start_keeps_a_dotenv_override(tmp_path, env_file: str, shell_value: str | None, expected: str):
"""The dev launcher must hand the checkout .env value to interpolation, or the default replaces it."""
assert _render_through_docker_start(tmp_path, env_file, shell_value)["AUTH_TRUSTED_PROXIES"] == expected
def _gateway_locations(content: str) -> list[tuple[str, str]]:
"""``(selector, block)`` for every location whose ``proxy_pass`` targets the Gateway."""
locations = []
for match in re.finditer(r"^\s*location\s+([^{#\n]+?)\s*\{", content, re.MULTILINE):
start = match.end() - 1
depth = 0
for index in range(start, len(content)):
if content[index] == "{":
depth += 1
elif content[index] == "}":
depth -= 1
if depth == 0:
block = content[start : index + 1]
break
proxy_pass = re.search(r"proxy_pass\s+([^;]+);", block)
if proxy_pass and "gateway" in proxy_pass.group(1):
locations.append((match.group(1), block))
return locations
@pytest.mark.parametrize("path", NGINX_CONFIGS)
def test_every_gateway_location_overwrites_x_real_ip(path: str):
"""A location that forwarded the client's own X-Real-IP would let anyone pick their lockout bucket."""
locations = _gateway_locations(read_config(path))
assert len(locations) >= 10, f"{path}: found only {len(locations)} gateway locations; did the parser break?"
missing = [selector for selector, block in locations if not re.search(r"proxy_set_header\s+X-Real-IP\s+\$remote_addr\s*;", block)]
assert missing == [], f"{path}: gateway locations that pass the client's X-Real-IP through: {missing}"