mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 05:30:39 +00:00
* fix(gateway): reject non-ASCII tokens instead of raising in constant-time compares
hmac.compare_digest raises TypeError when a str operand holds non-ASCII
characters. Starlette decodes header bytes as latin-1 and query values as
UTF-8, so a single 0xE9 byte in a CSRF token, GitHub webhook signature,
internal auth token, or OIDC state turned the expected 403/401 into a 500.
Route the five client-facing comparisons through a new
app.gateway.utils.constant_time_equals helper that compares the UTF-8
encodings ("surrogatepass" so even a lone surrogate cannot raise). ASCII
results are unchanged, and no bypass was possible before the fix.
* fix(provisioner): reject non-ASCII API keys instead of raising; drive OIDC test over HTTP
The standalone provisioner compared X-API-Key with secrets.compare_digest on
str operands, so a non-ASCII header raised TypeError (500) the same way the
Gateway sites did. It cannot import app.gateway, so encode inline.
The OIDC state regression test now mounts the auth router and sends the
percent-encoded state through a real signed state cookie instead of stubbing
get_state_cookie and calling the handler directly.
* docs(changelog): reference #6076
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
87 lines
3.4 KiB
Python
87 lines
3.4 KiB
Python
"""Authentication for trusted Gateway internal callers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import secrets
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
from app.gateway.utils import constant_time_equals
|
|
from deerflow.config.paths import make_safe_user_id
|
|
from deerflow.runtime.user_context import DEFAULT_USER_ID
|
|
|
|
INTERNAL_AUTH_HEADER_NAME = "X-DeerFlow-Internal-Token"
|
|
INTERNAL_OWNER_USER_ID_HEADER_NAME = "X-DeerFlow-Owner-User-Id"
|
|
INTERNAL_AUTH_ENV_VAR = "DEER_FLOW_INTERNAL_AUTH_TOKEN"
|
|
INTERNAL_SYSTEM_ROLE = "internal"
|
|
|
|
|
|
def _load_internal_auth_token() -> str:
|
|
token = os.environ.get(INTERNAL_AUTH_ENV_VAR)
|
|
if token:
|
|
return token
|
|
return secrets.token_urlsafe(32)
|
|
|
|
|
|
_INTERNAL_AUTH_TOKEN = _load_internal_auth_token()
|
|
|
|
|
|
def create_internal_auth_headers(*, owner_user_id: str | None = None) -> dict[str, str]:
|
|
"""Return headers that authenticate trusted Gateway internal calls."""
|
|
headers = {INTERNAL_AUTH_HEADER_NAME: _INTERNAL_AUTH_TOKEN}
|
|
if owner_user_id:
|
|
headers[INTERNAL_OWNER_USER_ID_HEADER_NAME] = owner_user_id
|
|
return headers
|
|
|
|
|
|
def is_valid_internal_auth_token(token: str | None) -> bool:
|
|
"""Return True when *token* matches this Gateway worker's internal token."""
|
|
return bool(token) and constant_time_equals(token, _INTERNAL_AUTH_TOKEN)
|
|
|
|
|
|
def get_internal_user(owner_user_id: str | None = None):
|
|
"""Return the synthetic user used for trusted internal channel calls.
|
|
|
|
When *owner_user_id* is provided (extracted from the
|
|
``X-DeerFlow-Owner-User-Id`` header), the synthetic user's ``.id``
|
|
carries the actual channel owner instead of ``DEFAULT_USER_ID``.
|
|
This ensures that ``get_effective_user_id()`` and downstream
|
|
filesystem-path resolution (per-user custom skills, memory, thread
|
|
data) use the correct identity for IM channel messages instead of
|
|
falling back to ``"default"``.
|
|
|
|
The owner id is normalized through :func:`make_safe_user_id` so that
|
|
IM channel ids containing characters outside ``[A-Za-z0-9_-]`` (e.g.
|
|
Feishu ``open_id`` prefixed with ``ou_`` and containing underscores
|
|
that the rest of the system may treat as path separators, or
|
|
Telegram chat ids like ``-1001234567890``) cannot be used to escape
|
|
the per-user storage bucket or impersonate a different user via
|
|
header value tricks (e.g. trailing slashes, ``..`` segments). The
|
|
normalization is lossy but deterministic: two distinct raw inputs
|
|
never share a safe id, so cross-user bleed is impossible.
|
|
"""
|
|
if owner_user_id:
|
|
effective_id = make_safe_user_id(owner_user_id)
|
|
else:
|
|
effective_id = DEFAULT_USER_ID
|
|
return SimpleNamespace(id=effective_id, system_role=INTERNAL_SYSTEM_ROLE)
|
|
|
|
|
|
def get_trusted_internal_owner_user_id(request: Any) -> str | None:
|
|
"""Return the owner override for a trusted internal request, if present.
|
|
|
|
The header is ignored for normal browser/API callers. It is only honored
|
|
after ``AuthMiddleware`` has validated the internal auth token and stamped
|
|
the synthetic internal user onto ``request.state.user``.
|
|
"""
|
|
user = getattr(getattr(request, "state", None), "user", None)
|
|
if getattr(user, "system_role", None) != INTERNAL_SYSTEM_ROLE:
|
|
return None
|
|
|
|
owner_user_id = request.headers.get(INTERNAL_OWNER_USER_ID_HEADER_NAME)
|
|
if not owner_user_id:
|
|
return None
|
|
owner_user_id = owner_user_id.strip()
|
|
return owner_user_id or None
|