deer-flow/backend/app/gateway/internal_auth.py
Hyeonsang Cho 14cd840bbf
fix(gateway): reject non-ASCII tokens instead of raising in constant-time compares (#6076)
* 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>
2026-09-30 17:41:11 +08:00

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