mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-15 15:50:35 +00:00
* fix(gateway): match conversation reference URLs on the addressed host A conversation URL reference was accepted only when its (scheme, netloc) equalled Gateway's request.url. Behind TLS that URL is often http: Docker Compose and Helm run uvicorn without FORWARDED_ALLOW_IPS, so nginx's X-Forwarded-Proto from another container is ignored, and nginx.local.conf resends its own $scheme. The browser's https chat link then got a 422, and so did an explicit :443 or a differently cased host. Resolve the run request's origin with _request_origin(), as the CSRF check does (Forwarded / X-Forwarded-Host / Host), normalize the reference's origin the same way, and compare host and port only. The scheme cannot tell two deployments on the same host apart, and URLs remain owner-bound local selectors that are never fetched. * test(gateway): cover forwarded port, IPv6 and fragment for conversation URLs Name X-Forwarded-Port in the API.md reference contract: it is appended when the forwarded host has no port, so a stray value causes a 422. * test(runtime): synchronize PostgreSQL lease expiration with row lock --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
103 lines
5.4 KiB
Python
103 lines
5.4 KiB
Python
"""A conversation URL is matched against the host the client addressed.
|
|
|
|
Gateway runs behind nginx and often behind a TLS proxy too. uvicorn only
|
|
applies ``X-Forwarded-Proto`` from ``FORWARDED_ALLOW_IPS`` (``127.0.0.1`` by
|
|
default), so with nginx in another container or pod the request URL stays
|
|
``http`` while the browser's link is ``https``. A local nginx resends its own
|
|
``$scheme``. These cases send the request through uvicorn's real
|
|
``ProxyHeadersMiddleware`` and the real ``prepare_conversation_reader``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import FastAPI, HTTPException, Request
|
|
from langgraph.store.memory import InMemoryStore
|
|
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
|
|
|
from app.gateway.authz import AuthContext
|
|
from app.gateway.conversation_access import prepare_conversation_reader
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
|
|
from deerflow.runtime.events.store.memory import MemoryRunEventStore
|
|
from deerflow.runtime.runs.manager import EditReplayVisibility
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
_NGINX_CONTAINER = "172.18.0.5"
|
|
_CHAT = "/workspace/chats/source"
|
|
|
|
|
|
def _app() -> FastAPI:
|
|
config = AppConfig.model_validate(
|
|
{
|
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
|
"tools": [{"name": "read_conversation", "group": "conversation", "use": "deerflow.tools.conversation:read_conversation"}],
|
|
}
|
|
)
|
|
manager = AsyncMock()
|
|
manager.list_edit_replay_visibility.return_value = EditReplayVisibility()
|
|
run_context = SimpleNamespace(event_store=MemoryRunEventStore(), thread_store=MemoryThreadMetaStore(InMemoryStore()))
|
|
app = FastAPI()
|
|
|
|
@app.post("/api/threads/current/runs")
|
|
async def create_run(request: Request, body: dict) -> dict:
|
|
request.state.auth = AuthContext(SimpleNamespace(id="alice", system_role="user"), ["runs:read"])
|
|
try:
|
|
_, ids = prepare_conversation_reader(body["references"], request=request, user_id="alice", run_context=run_context, run_manager=manager, app_config=config)
|
|
except HTTPException as exc:
|
|
return {"status": exc.status_code}
|
|
return {"status": 200, "ids": list(ids)}
|
|
|
|
return app
|
|
|
|
|
|
async def _submit(reference: str, headers: dict[str, str], *, peer: str = _NGINX_CONTAINER, trusted: str = "127.0.0.1") -> dict:
|
|
# ``trusted`` is uvicorn's FORWARDED_ALLOW_IPS; ``peer`` is the address nginx connects from.
|
|
transport = httpx.ASGITransport(app=ProxyHeadersMiddleware(_app(), trusted_hosts=trusted), client=(peer, 50000))
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://gateway:8001") as client:
|
|
response = await client.post("/api/threads/current/runs", json={"references": [reference]}, headers=headers)
|
|
return response.json()
|
|
|
|
|
|
_TLS_EDGE = {"host": "deerflow.example", "x-forwarded-proto": "https", "x-forwarded-for": "203.0.113.9"}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("reference", "headers", "peer", "trusted"),
|
|
[
|
|
pytest.param(f"https://deerflow.example{_CHAT}", _TLS_EDGE, _NGINX_CONTAINER, "127.0.0.1", id="docker-nginx-untrusted-peer"),
|
|
pytest.param(f"https://deerflow.example{_CHAT}", {**_TLS_EDGE, "x-forwarded-proto": "http"}, "127.0.0.1", "127.0.0.1", id="local-nginx-resends-http"),
|
|
pytest.param(f"https://deerflow.example{_CHAT}", _TLS_EDGE, _NGINX_CONTAINER, _NGINX_CONTAINER, id="trusted-proxy"),
|
|
pytest.param(f"https://deerflow.example{_CHAT}", {**_TLS_EDGE, "host": "gateway:8001", "x-forwarded-host": "deerflow.example"}, _NGINX_CONTAINER, "127.0.0.1", id="forwarded-host"),
|
|
pytest.param(f"https://deerflow.example:443{_CHAT}", _TLS_EDGE, _NGINX_CONTAINER, "127.0.0.1", id="explicit-default-port"),
|
|
pytest.param(f"https://DeerFlow.Example{_CHAT}", _TLS_EDGE, _NGINX_CONTAINER, "127.0.0.1", id="host-case"),
|
|
pytest.param(f"http://localhost:2026{_CHAT}", {"host": "localhost:2026", "x-forwarded-proto": "http"}, "127.0.0.1", "127.0.0.1", id="plain-http-dev"),
|
|
pytest.param(f"https://deerflow.example:8443{_CHAT}", {**_TLS_EDGE, "x-forwarded-port": "8443"}, _NGINX_CONTAINER, "127.0.0.1", id="forwarded-port"),
|
|
pytest.param(f"http://[::1]:2026{_CHAT}", {"host": "[::1]:2026", "x-forwarded-proto": "http"}, "127.0.0.1", "127.0.0.1", id="ipv6-host"),
|
|
],
|
|
)
|
|
async def test_a_link_from_the_addressed_host_is_accepted(reference, headers, peer, trusted) -> None:
|
|
assert await _submit(reference, headers, peer=peer, trusted=trusted) == {"status": 200, "ids": ["source"]}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"reference",
|
|
[
|
|
pytest.param(f"https://other.example{_CHAT}", id="other-host"),
|
|
pytest.param(f"https://deerflow.example:8443{_CHAT}", id="other-port"),
|
|
pytest.param(f"https://alice@deerflow.example{_CHAT}", id="credentials"),
|
|
pytest.param(f"ftp://deerflow.example{_CHAT}", id="other-scheme"),
|
|
pytest.param(f"https://deerflow.example:notaport{_CHAT}", id="bad-port"),
|
|
pytest.param(f"https://{_CHAT}", id="no-host"),
|
|
pytest.param(f"https://deerflow.example{_CHAT}?tab=1", id="query"),
|
|
pytest.param(f"https://deerflow.example{_CHAT}#tab", id="fragment"),
|
|
],
|
|
)
|
|
async def test_a_link_from_anywhere_else_is_still_rejected(reference) -> None:
|
|
assert await _submit(reference, _TLS_EDGE) == {"status": 422}
|