mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* fix(nginx): canonicalize the loopback origin onto localhost localhost and 127.0.0.1 (or [::1]) are the same loopback listener but separate cookie jars to a browser, so an admin who sets up on one spelling lands on /login when they later open the other, with no hint why (#6156). Cookies cannot bridge the gap: no Domain attribute spans both hosts. All three nginx configs now map the numeric spellings to "localhost", the name every banner and doc already prints, and the main server answers a 301 to the same URL on the canonical host. The published port survives because the map keys on $http_host and reattaches the captured port; Docker's arbitrary host-port mapping is unaffected. Safe methods only: GET/HEAD redirect, while API POSTs pass through untouched, since a redirected POST strands clients that do not re-send bodies. Non-loopback Hosts (LAN names, ingress domains) never match, and the canonical target itself is not a match, so there is no redirect loop. The new suite parses both maps out of each config and evaluates the full method x host matrix against nginx semantics, including the suffix-spoof and redirect-loop cases, and pins the maps identical across the Docker, local-dev, and Helm configs so they cannot drift. Fixes #6156 Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com> * docs: keep the loopback note in the nginx configs The root AGENTS.md paragraph pushed the middlewares and sandbox instruction chains over the 96 KiB hard limit. The nginx configs already carry the full explanation next to the maps, so drop the duplicated paragraph instead of shrinking the budgets. * fix(nginx): let WebSocket upgrades bypass the loopback canonical redirect willem-bd caught this running the stack locally: the canonicalization map keyed on method:host alone, so a GET with an Upgrade header got the same 301 as a plain GET. WebSocket clients do not follow redirects, so every WS handshake against a 127.0.0.1/[::1] spelling died at the redirect. Key the origin map on method:upgrade:host instead. Handshakes carry a non-empty Upgrade value, fall off the GET::/HEAD:: patterns, and proxy through untouched. Plain GET/HEAD behavior is unchanged. --------- Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
144 lines
6.0 KiB
Python
144 lines
6.0 KiB
Python
"""Loopback host canonicalization shared by the three nginx configs (#6156).
|
|
|
|
Browsers scope cookies per host, so serving both ``localhost:2026`` and
|
|
``127.0.0.1:2026`` (or ``[::1]:2026``) splits the login session into separate
|
|
jars. The proxy canonicalizes the numeric spellings onto ``localhost`` with a
|
|
301, safe methods only: a redirected API POST would strand clients that do not
|
|
re-send bodies, and non-loopback Hosts (LAN names, ingress domains) must never
|
|
match. The canonical target itself must not match either, or the redirect
|
|
would loop.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
import pytest
|
|
from support.nginx_conf import NGINX_CONFIGS, read_config
|
|
|
|
|
|
def _extract_block(content: str, marker: re.Pattern[str]) -> str:
|
|
"""Return the brace-balanced block whose header matches ``marker``."""
|
|
match = marker.search(content)
|
|
assert match, f"could not find block matching {marker.pattern!r}"
|
|
start = match.end() - 1 # index of the opening brace
|
|
depth = 0
|
|
for i, ch in enumerate(content[start:], start=start):
|
|
if ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return content[start : i + 1]
|
|
raise AssertionError(f"unbalanced braces after {marker.pattern!r}")
|
|
|
|
|
|
def _extract_map(content: str, variable: str) -> list[tuple[str, str]]:
|
|
"""Entries of ``map ... $<variable> { ... }`` as ``(selector, value)`` pairs."""
|
|
body = _extract_block(content, re.compile(r"map\s+\S+\s+\$" + re.escape(variable) + r"\s*\{"))
|
|
entries = []
|
|
for line in body[1:-1].splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
selector, value = line.rstrip(";").split(None, 1)
|
|
entries.append((selector.strip('"'), value.strip().strip('"')))
|
|
return entries
|
|
|
|
|
|
def _nginx_regex(selector: str) -> re.Pattern[str] | None:
|
|
"""Translate an nginx map regex selector (``~``/``~*``) into a Python pattern."""
|
|
if selector.startswith("~*"):
|
|
return re.compile(selector[2:].replace("(?<", "(?P<"), re.IGNORECASE)
|
|
if selector.startswith("~"):
|
|
return re.compile(selector[1:].replace("(?<", "(?P<"))
|
|
return None
|
|
|
|
|
|
def _evaluate_map(entries: list[tuple[str, str]], key: str) -> str:
|
|
"""nginx map semantics: exact strings win first, then regexes in file order."""
|
|
default = ""
|
|
regexes: list[tuple[re.Pattern[str], str]] = []
|
|
for selector, value in entries:
|
|
if selector == "default":
|
|
default = value
|
|
continue
|
|
pattern = _nginx_regex(selector)
|
|
if pattern is None:
|
|
if selector == key:
|
|
return value
|
|
else:
|
|
regexes.append((pattern, value))
|
|
for pattern, value in regexes:
|
|
match = pattern.search(key)
|
|
if match:
|
|
for name, group in match.groupdict().items():
|
|
if group is not None:
|
|
value = value.replace(f"${name}", group)
|
|
return value
|
|
return default
|
|
|
|
|
|
def _canonical_origin(content: str, method: str, host: str, upgrade: str = "") -> str:
|
|
"""The redirect target the two-map pipeline produces, or "" for no redirect."""
|
|
canonical_host = _evaluate_map(_extract_map(content, "loopback_canonical_host"), host)
|
|
origin = _evaluate_map(_extract_map(content, "loopback_origin"), f"{method}:{upgrade}:{canonical_host}")
|
|
return origin.replace("$loopback_canonical_host", canonical_host)
|
|
|
|
|
|
@pytest.mark.parametrize("config_path", NGINX_CONFIGS)
|
|
@pytest.mark.parametrize(
|
|
("method", "host", "expected"),
|
|
[
|
|
# The reported split: both spellings serve :2026, cookies do not follow.
|
|
("GET", "127.0.0.1:2026", "localhost:2026"),
|
|
("HEAD", "127.0.0.1:2026", "localhost:2026"),
|
|
("GET", "127.0.0.1", "localhost"),
|
|
("GET", "[::1]:2026", "localhost:2026"),
|
|
("GET", "[::1]", "localhost"),
|
|
# Docker publishes an arbitrary host port; it must survive the redirect.
|
|
("GET", "127.0.0.1:18080", "localhost:18080"),
|
|
# API clients re-sending no bodies must never be redirected mid-POST.
|
|
("POST", "127.0.0.1:2026", ""),
|
|
("PUT", "[::1]:2026", ""),
|
|
# The canonical target must not match: that would be a redirect loop.
|
|
("GET", "localhost:2026", ""),
|
|
("GET", "localhost", ""),
|
|
# LAN names and ingress domains stay untouched.
|
|
("GET", "deerflow.lan:2026", ""),
|
|
("GET", "deerflow.example.com", ""),
|
|
# Suffix spoofs of the numeric spelling must not match.
|
|
("GET", "127.0.0.1.evil.example", ""),
|
|
("GET", "evil-127.0.0.1.example:2026", ""),
|
|
],
|
|
)
|
|
def test_loopback_canonicalization(config_path: str, method: str, host: str, expected: str) -> None:
|
|
assert _canonical_origin(read_config(config_path), method, host) == expected
|
|
|
|
|
|
@pytest.mark.parametrize("config_path", NGINX_CONFIGS)
|
|
@pytest.mark.parametrize("host", ["127.0.0.1:2026", "[::1]:2026", "127.0.0.1:18080"])
|
|
def test_websocket_handshake_is_not_redirected(config_path: str, host: str) -> None:
|
|
# WS clients do not follow 301s; the upgrade request must reach the proxy as-is.
|
|
assert _canonical_origin(read_config(config_path), "GET", host, upgrade="websocket") == ""
|
|
|
|
|
|
@pytest.mark.parametrize("config_path", NGINX_CONFIGS)
|
|
def test_main_server_redirects_to_the_canonical_origin(config_path: str) -> None:
|
|
content = read_config(config_path)
|
|
server = _extract_block(content, re.compile(r"server\s*\{(?=[^}]*?listen\s+(?:\[::\]:)?2026\b)"))
|
|
assert 'if ($loopback_origin != "")' in server
|
|
assert "return 301 $scheme://$loopback_origin$request_uri;" in server
|
|
|
|
|
|
def test_canonicalization_maps_match_across_configs() -> None:
|
|
"""The three maintained configs carry the same routing and must not drift."""
|
|
per_config = [
|
|
(
|
|
_extract_map(read_config(path), "loopback_canonical_host"),
|
|
_extract_map(read_config(path), "loopback_origin"),
|
|
)
|
|
for path in NGINX_CONFIGS
|
|
]
|
|
assert all(entries == per_config[0] for entries in per_config[1:])
|