From 0ecb81d219e1ca15f19f1995ff5c26d1f3c97bfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E8=8F=8A?= <277378677+maiphucgiang@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:30:52 +0800 Subject: [PATCH] Add trusted management origin allowlist for reverse-proxy logins --- .env.example | 4 +++ app/admin_api.py | 2 +- app/admin_auth.py | 33 +++++++++++++---- app/settings.py | 46 ++++++++++++++++++++++-- converter.py | 15 +++++++- docker-compose.yml | 1 + docs/advanced.md | 4 +-- docs/advanced.zh-CN.md | 4 +-- tests/test_admin_api.py | 62 +++++++++++++++++++++++++++++++- tests/test_deployment.py | 5 ++- tests/test_environment_config.py | 23 +++++++++++- tests/test_runtime_endpoints.py | 25 ++++++++++--- 12 files changed, 203 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index 4c8db64..1d84684 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,10 @@ CODEBUDDY2API_KEY= # Disabling CSRF checks does not disable authentication; use only on trusted local networks. CODEBUDDY2API_ADMIN_CSRF=true +# Extra trusted management origins behind reverse proxies (comma separated; bare domains use HTTPS). +# Unset leaves the list configurable in the WebUI; an explicit value locks it. +# CODEBUDDY2API_ADMIN_ORIGINS=https://chat.example.com + # Unset leaves tool metadata configurable in the WebUI; an explicit Boolean locks it. # CODEBUDDY2API_KEEP_TOOL_METADATA=true diff --git a/app/admin_api.py b/app/admin_api.py index e81d92f..65984a9 100644 --- a/app/admin_api.py +++ b/app/admin_api.py @@ -201,7 +201,7 @@ async def guarded(request: Request): @route("POST", "/admin/session") async def session_login(request): - if auth.csrf_enabled() and not same_origin(request): + if auth.csrf_enabled() and not same_origin(request, auth.allowed_origins()): return error_response(403, "登录请求 Origin 校验失败") data = await _body(request, 8192) result, status = auth.login(request, data.get("api_key")) diff --git a/app/admin_auth.py b/app/admin_auth.py index a05a487..701df8c 100644 --- a/app/admin_auth.py +++ b/app/admin_auth.py @@ -20,7 +20,21 @@ def error_response(status, message): headers={"Cache-Control": "no-store", "Pragma": "no-cache"}) -def same_origin(request): +def origin_allowlist(value): + """Parse a normalized origin list into comparable (scheme, host, port) triples.""" + triples = set() + for entry in str(value or "").split(","): + try: + parts = urlsplit(entry.strip()) + port = parts.port + except ValueError: + continue + if parts.scheme in ("http", "https") and parts.hostname: + triples.add((parts.scheme, parts.hostname, port if port is not None else (443 if parts.scheme == "https" else 80))) + return frozenset(triples) + + +def same_origin(request, allowed=()): origin = request.headers.get("origin") reference = origin if origin is None: @@ -38,10 +52,13 @@ def same_origin(request): target = urlsplit(str(request.url)) supplied_port = supplied.port if supplied.port is not None else (443 if supplied.scheme == "https" else 80) target_port = target.port if target.port is not None else (443 if target.scheme == "https" else 80) - return (supplied.scheme in ("http", "https") and supplied.username is None and supplied.password is None - and not supplied.fragment - and (origin is None or (not supplied.path and not supplied.query)) - and (supplied.scheme, supplied.hostname, supplied_port) == (target.scheme, target.hostname, target_port)) + clean = (supplied.scheme in ("http", "https") and supplied.username is None and supplied.password is None + and not supplied.fragment and (origin is None or (not supplied.path and not supplied.query))) + if clean and (supplied.scheme, supplied.hostname, supplied_port) == (target.scheme, target.hostname, target_port): + return True + # Explicitly trusted origins survive proxies that rewrite the forwarded Host/scheme. + return (origin is not None and clean and not supplied.path and not supplied.query + and (supplied.scheme, supplied.hostname, supplied_port) in allowed) except ValueError: return False @@ -71,6 +88,10 @@ def csrf_enabled(self): """Only an explicitly disabled startup option skips browser-origin protection.""" return self.config.get("admin_csrf", True) is not False + def allowed_origins(self): + """Extra trusted browser origins from hot configuration.""" + return origin_allowlist(self.config.get("admin_allowed_origins")) + def enabled(self): with self.lock: return bool(self._key()) @@ -157,7 +178,7 @@ async def no_cache(message): if (self.auth.csrf_enabled() and cookie and not public_session and (method not in ("GET", "HEAD", "OPTIONS") or path == "/admin/oauth/poll")): supplied = request.headers.get("x-csrf-token", "") - if not same_origin(request) or not hmac.compare_digest(supplied.encode(), session["csrf_token"].encode()): + if not same_origin(request, self.auth.allowed_origins()) or not hmac.compare_digest(supplied.encode(), session["csrf_token"].encode()): return await error_response(403, "Origin 或 CSRF 校验失败")(scope, receive, no_cache) scope.setdefault("state", {}).update(admin_identity=identity or sid, admin_cookie=cookie, admin_session=session) diff --git a/app/settings.py b/app/settings.py index 91466c9..2336fbc 100644 --- a/app/settings.py +++ b/app/settings.py @@ -3,10 +3,39 @@ import math import os +import re +from urllib.parse import urlsplit + + +def normalize_allowed_origins(value): + """Normalize a comma/space separated origin list; bare hosts default to HTTPS.""" + entries = [entry for entry in re.split(r"[\s,]+", value.strip()) if entry] + if len(entries) > 32: + raise ValueError("admin_allowed_origins: 来源数量超出上限") + normalized = [] + for entry in entries: + candidate = entry if "://" in entry else f"https://{entry}" + try: + parts = urlsplit(candidate) + port = parts.port + except ValueError: + raise ValueError(f"admin_allowed_origins: 来源无效 {entry!r}") from None + if (parts.scheme not in ("http", "https") or not parts.hostname + or parts.username is not None or parts.password is not None + or parts.path not in ("", "/") or parts.query or parts.fragment + or (port is not None and not 1 <= port <= 65535)): + raise ValueError(f"admin_allowed_origins: 来源无效 {entry!r}") + host = f"[{parts.hostname}]" if ":" in parts.hostname else parts.hostname + default_port = 443 if parts.scheme == "https" else 80 + origin = f"{parts.scheme}://{host}" + (f":{port}" if port is not None and port != default_port else "") + if origin not in normalized: + normalized.append(origin) + return ",".join(normalized) + def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum=None, - choices=None, sensitive=False): + choices=None, sensitive=False, allow_empty=False, max_length=255, validator=None): value = {"default": default, "type": type_, "label": label, "mode": mode, "env": env, "sensitive": sensitive} if minimum is not None: @@ -15,6 +44,12 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum= value["max"] = maximum if choices is not None: value["choices"] = choices + if allow_empty: + value["allow_empty"] = True + if max_length != 255: + value["max_length"] = max_length + if validator is not None: + value["validator"] = validator return value @@ -26,6 +61,8 @@ def _item(default, type_, label, *, mode="hot", env=None, minimum=None, maximum= "auth_dir": _item(None, "path", "凭证目录", mode="startup", env="CODEBUDDY_AUTH_DIR", sensitive=True), "import_dir": _item(None, "path", "导入目录", mode="startup", env="CODEBUDDY_IMPORT_DIR", sensitive=True), "log_path": _item(None, "path", "兼容文本日志", mode="startup", env="CODEBUDDY2API_LOG", sensitive=True), + "admin_allowed_origins": _item("", "string", "管理页额外信任来源", env="CODEBUDDY2API_ADMIN_ORIGINS", + allow_empty=True, max_length=2000, validator=normalize_allowed_origins), "desensitize": _item(False, "boolean", "提示词脱敏"), "no_compact": _item(False, "boolean", "保留提示词全文"), "keep_tool_metadata": _item(False, "boolean", "保留工具描述", env="CODEBUDDY2API_KEEP_TOOL_METADATA"), @@ -70,13 +107,18 @@ def validate_settings(values, *, legacy=False): valid = ((kind == "boolean" and type(value) is bool) or (kind == "integer" and type(value) is int) or (kind == "number" and type(value) in (int, float) and math.isfinite(value)) - or (kind == "string" and isinstance(value, str) and 0 < len(value) <= 255 and not any(ord(c) < 32 for c in value))) + or (kind == "string" and isinstance(value, str) + and (spec.get("allow_empty") or 0 < len(value)) + and len(value) <= spec.get("max_length", 255) + and not any(ord(c) < 32 for c in value))) if not valid: raise ValueError(f"{key}: 类型或值无效") if "min" in spec and value < spec["min"] or "max" in spec and value > spec["max"]: raise ValueError(f"{key}: 超出允许范围") if "choices" in spec and value not in spec["choices"]: raise ValueError(f"{key}: 不支持的选项") + if spec.get("validator"): + value = spec["validator"](value) clean[key] = value return clean diff --git a/converter.py b/converter.py index 88ad754..7581585 100644 --- a/converter.py +++ b/converter.py @@ -1506,6 +1506,7 @@ async def _protocol_http_exception(request: Request, exc: HTTPException): return JSONResponse(body, status_code=exc.status_code, headers=exc.headers) CONFIG: dict = {"api_key": "", "cred": None, "log_path": None, "ledger": None, "admin_csrf": True, # Startup-only Origin/CSRF policy + "admin_allowed_origins": "", # Extra trusted management Origins (hot) "models_remote": None, # Domestic cloud model inventory "models_intl": None, # Eligible international model inventory "model_cache": None, # Versioned catalog cache @@ -3491,6 +3492,14 @@ def _positive_int(value): return number +def _origins_arg(value): + from app.settings import normalize_allowed_origins + try: + return normalize_allowed_origins(value) + except ValueError: + raise argparse.ArgumentTypeError("必须为逗号分隔的 http/https 来源或域名") from None + + def _boolean_arg(value): if isinstance(value, bool): return value @@ -3517,6 +3526,10 @@ def main(): ap.add_argument("--admin-csrf", type=_boolean_arg, nargs="?", const=True, default=os.environ.get("CODEBUDDY2API_ADMIN_CSRF", "true"), help="管理 Origin/CSRF 校验,默认 true;仅在受信任本地环境设为 false,鉴权仍启用") + ap.add_argument("--admin-allowed-origins", type=_origins_arg, metavar="ORIGINS", + default=os.environ.get("CODEBUDDY2API_ADMIN_ORIGINS"), + help="额外信任的管理页来源(逗号分隔,支持域名或完整来源,裸域名按 https);" + "反代 HTTPS 域名登录报 Origin 校验失败时设置,也可在 WebUI 配置") ap.add_argument("--log", default=None, metavar="PATH", help="额外写入兼容文本日志(如 --log converter.log 或 --log /tmp/cb.log)。" "不传仍记录 SQLite 审计,但不输出文本文件。") @@ -3601,7 +3614,7 @@ def main(): for key in ("max_images", "image_policy", "max_request_bytes", "log_body_limit", "tool_call_max_retry", "max_inbound_bytes", "max_collect_bytes", "max_concurrent", "failover_max", "retry_write_timeout", "upstream_keepalive", "max_inflight_per_account", - "request_context_mode", "model_capability_guard"): + "request_context_mode", "model_capability_guard", "admin_allowed_origins"): CONFIG[key] = getattr(args, key) CONFIG["api_key"] = args.api_key CONFIG["desensitize"] = args.desensitize diff --git a/docker-compose.yml b/docker-compose.yml index 3bcce7c..51c91b1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: # First-Buddy onboarding may send one bounded real WorkBuddy conversation per account. CODEBUDDY2API_AUTO_ACCEPT_BUDDY: ${CODEBUDDY2API_AUTO_ACCEPT_BUDDY:-false} # Unset variables remain configurable through the WebUI. + CODEBUDDY2API_ADMIN_ORIGINS: CODEBUDDY2API_KEEP_TOOL_METADATA: CODEBUDDY2API_MAX_IMAGES: ${CODEBUDDY2API_MAX_IMAGES:-16} CODEBUDDY2API_IMAGE_POLICY: ${CODEBUDDY2API_IMAGE_POLICY:-truncate} diff --git a/docs/advanced.md b/docs/advanced.md index 509be5f..c43a5f0 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -41,7 +41,7 @@ Compose explicitly passes some environment variables and CLI flags, so deleting | `--max-request-bytes` | `33554432` | Positive byte limit for the processed upstream JSON | | `--log-body-limit` | `65536` | Text-log body preview bytes; `0` logs summaries only, not the SQLite diagnostic budget | -Environment variables include `CODEBUDDY_AUTH_DIR`, `CODEBUDDY_IMPORT_DIR`, `CODEBUDDY2API_KEY`, `CODEBUDDY2API_ADMIN_CSRF`, `CODEBUDDY2API_KEEP_TOOL_METADATA`, `CODEBUDDY2API_LOG`, `CODEBUDDY2API_MAX_IMAGES`, `CODEBUDDY2API_IMAGE_POLICY`, `CODEBUDDY2API_MAX_REQUEST_BYTES`, `CODEBUDDY2API_LOG_BODY_LIMIT`, `CODEBUDDY2API_FAILOVER_MAX` and `CODEBUDDY2API_RETRY_WRITE_TIMEOUT`. See [deployment](deployment.md) for startup examples. +Environment variables include `CODEBUDDY_AUTH_DIR`, `CODEBUDDY_IMPORT_DIR`, `CODEBUDDY2API_KEY`, `CODEBUDDY2API_ADMIN_CSRF`, `CODEBUDDY2API_ADMIN_ORIGINS`, `CODEBUDDY2API_KEEP_TOOL_METADATA`, `CODEBUDDY2API_LOG`, `CODEBUDDY2API_MAX_IMAGES`, `CODEBUDDY2API_IMAGE_POLICY`, `CODEBUDDY2API_MAX_REQUEST_BYTES`, `CODEBUDDY2API_LOG_BODY_LIMIT`, `CODEBUDDY2API_FAILOVER_MAX` and `CODEBUDDY2API_RETRY_WRITE_TIMEOUT`. See [deployment](deployment.md) for startup examples. `CODEBUDDY2API_AUTO_ACCEPT_BUDDY` is startup-only and defaults to `false`. It preauthorizes enabled domestic accounts for first-Buddy onboarding, agreement and travel; automatic travel still respects its account switch. `first_buddy` needs no acceptance API: pending states, including `not_accepted`, allow one real domestic WorkBuddy conversation on that account. Prefer an eligible zero-rate model, otherwise the lowest known rate; request at most 32 output tokens with possible credit usage. Other reward tasks, paid boxes, pet switching and international trials are excluded. @@ -130,7 +130,7 @@ Management requires an API key. The WebUI exchanges that key for an HttpOnly man Enabled by default. When OAuth polling omits both `Origin` and `Sec-Fetch-Site`, a same-origin `Referer` (matching scheme, host and port) is accepted, but a valid CSRF token is still required. An existing `Origin` takes precedence; without it, supplied Fetch Metadata must be `same-origin` and cannot fall back to Referer. Login and writes still require Origin. -Normal same-origin access does not require disabling protection. If errors persist, use a consistent access URL, check the proxy's forwarded Host/scheme, and refresh the page and log in again. Only for trusted local deployments, append `--admin-csrf false` to the startup command or set this in your existing `.env`: +Normal same-origin access does not require disabling protection. Behind a reverse proxy that rewrites the forwarded Host/scheme (for example HTTPS on a bound domain while the container sees HTTP), the browser Origin no longer matches what the server sees and login fails Origin checks. Add the public address to `admin_allowed_origins` (WebUI system settings, hot) or set `CODEBUDDY2API_ADMIN_ORIGINS` / `--admin-allowed-origins`: comma separated origins or bare domains (`https://chat.example.com`, `chat.example.com`; bare domains mean HTTPS), up to 32 entries. An explicit CLI or environment value locks the WebUI field. Prefer this over disabling protection; if errors persist, use a consistent access URL, check the proxy's forwarded Host/scheme, and refresh the page and log in again. Only for trusted local deployments, append `--admin-csrf false` to the startup command or set this in your existing `.env`: ```dotenv CODEBUDDY2API_ADMIN_CSRF=false diff --git a/docs/advanced.zh-CN.md b/docs/advanced.zh-CN.md index 4046a7b..2ebf52a 100644 --- a/docs/advanced.zh-CN.md +++ b/docs/advanced.zh-CN.md @@ -41,7 +41,7 @@ Compose 会显式传入部分环境变量及 CLI 参数,删除 `.env` 中的 | `--max-request-bytes` | `33554432` | 处理后的上游 JSON 字节上限,须为正整数 | | `--log-body-limit` | `65536` | 兼容文本日志正文预览字节;`0` 只记摘要,不控制 SQLite 诊断预算 | -环境变量包括 `CODEBUDDY_AUTH_DIR`、`CODEBUDDY_IMPORT_DIR`、`CODEBUDDY2API_KEY`、`CODEBUDDY2API_ADMIN_CSRF`、`CODEBUDDY2API_KEEP_TOOL_METADATA`、`CODEBUDDY2API_LOG`,以及 `CODEBUDDY2API_MAX_IMAGES`、`CODEBUDDY2API_IMAGE_POLICY`、`CODEBUDDY2API_MAX_REQUEST_BYTES`、`CODEBUDDY2API_LOG_BODY_LIMIT`、`CODEBUDDY2API_FAILOVER_MAX`、`CODEBUDDY2API_RETRY_WRITE_TIMEOUT`。启动示例见 [部署指南](deployment.zh-CN.md)。 +环境变量包括 `CODEBUDDY_AUTH_DIR`、`CODEBUDDY_IMPORT_DIR`、`CODEBUDDY2API_KEY`、`CODEBUDDY2API_ADMIN_CSRF`、`CODEBUDDY2API_ADMIN_ORIGINS`、`CODEBUDDY2API_KEEP_TOOL_METADATA`、`CODEBUDDY2API_LOG`,以及 `CODEBUDDY2API_MAX_IMAGES`、`CODEBUDDY2API_IMAGE_POLICY`、`CODEBUDDY2API_MAX_REQUEST_BYTES`、`CODEBUDDY2API_LOG_BODY_LIMIT`、`CODEBUDDY2API_FAILOVER_MAX`、`CODEBUDDY2API_RETRY_WRITE_TIMEOUT`。启动示例见 [部署指南](deployment.zh-CN.md)。 `CODEBUDDY2API_AUTO_ACCEPT_BUDDY` 仅启动读取,默认 `false`;预授权已启用国内账号完成首次领猫任务、协议及旅行,自动旅行仍受账号开关控制。`first_buddy` 无需单独接取,包含 `not_accepted` 在内的待完成状态可直接发起一次本账号的真实国内 WorkBuddy 对话;优先可用零倍率模型,否则取最低已知倍率,最多请求 32 个输出 token,可能消耗少量积分。不执行其他奖励任务、不付费开盒、不切猫、不领取国际试用积分。 @@ -130,7 +130,7 @@ scoped 模式可选传入 `X-Codebuddy-Session-ID`、`metadata.conversation_id` 默认开启。OAuth 轮询在 `Origin`、`Sec-Fetch-Site` 均缺失时,兼容同源 `Referer`(协议、主机、端口一致),仍要求有效 CSRF token。已有 `Origin` 优先校验;无 `Origin` 但有 Fetch Metadata 时,只接受 `same-origin`,不会再用 Referer 回退。登录和写操作仍要求 Origin。 -正常同源访问不需要关闭保护。若仍报错,先统一访问地址、检查反代传递的 Host 和协议,并刷新页面重新登录。仅在受信任本地环境需要关闭时,在原启动命令追加 `--admin-csrf false`,或在已有 `.env` 中设置: +正常同源访问不需要关闭保护。反代改写转发的 Host 或协议时(例如域名 HTTPS 访问而容器内看到 HTTP),浏览器 Origin 与服务端看到的地址不一致,登录会报 Origin 校验失败。把对外地址加入 `admin_allowed_origins`(WebUI 系统设置,即时生效),或设置 `CODEBUDDY2API_ADMIN_ORIGINS` / `--admin-allowed-origins`:逗号分隔的来源或裸域名(如 `https://chat.example.com`、`chat.example.com`,裸域名按 HTTPS),最多 32 条。CLI 或环境变量显式设置后 WebUI 字段锁定。优先使用此白名单而非关闭保护;若仍报错,先统一访问地址、检查反代传递的 Host 和协议,并刷新页面重新登录。仅在受信任本地环境需要关闭时,在原启动命令追加 `--admin-csrf false`,或在已有 `.env` 中设置: ```dotenv CODEBUDDY2API_ADMIN_CSRF=false diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index a3847e4..a8addc8 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -13,7 +13,7 @@ from fastapi.testclient import TestClient from app.admin_api import CLEAR_CONFIRMATION, install_admin -from app.admin_auth import COOKIE_NAME, same_origin +from app.admin_auth import COOKIE_NAME, origin_allowlist, same_origin from app.control_store import ControlStore @@ -128,6 +128,66 @@ def test_same_origin_referer_fallback_is_strict_and_get_only(self): "headers": [(key.lower().encode(), value.encode()) for key, value in headers.items()]}) self.assertIs(same_origin(request), expected) + + def test_same_origin_allowlist_matches_exact_origin_only(self): + from starlette.requests import Request + + def check(headers, allowed, method="GET"): + request = Request({"type": "http", "method": method, "scheme": "http", + "server": ("testserver", 80), "path": "/admin/session", "query_string": b"", + "headers": [(key.lower().encode(), value.encode()) for key, value in headers.items()]}) + return same_origin(request, allowed) + + allowed = origin_allowlist("https://chat.example.com,http://10.0.0.1:8787") + self.assertEqual(allowed, frozenset({("https", "chat.example.com", 443), ("http", "10.0.0.1", 8787)})) + self.assertTrue(check({"Origin": "https://chat.example.com"}, allowed)) + self.assertTrue(check({"Origin": "https://CHAT.example.com:443"}, allowed)) + self.assertTrue(check({"Origin": "http://10.0.0.1:8787"}, allowed)) + self.assertFalse(check({"Origin": "http://chat.example.com"}, allowed)) + self.assertFalse(check({"Origin": "https://chat.example.com:8443"}, allowed)) + self.assertFalse(check({"Origin": "https://sub.chat.example.com"}, allowed)) + self.assertFalse(check({"Origin": "https://chat.example.com/path"}, allowed)) + self.assertFalse(check({"Origin": "https://evil.invalid"}, allowed)) + self.assertFalse(check({"Origin": "null"}, allowed)) + self.assertFalse(check({}, allowed, method="POST")) + self.assertFalse(check({"Referer": "https://chat.example.com/dashboard"}, allowed)) + self.assertTrue(check({"Origin": "http://testserver"}, ())) + + def test_allowed_origins_permit_foreign_origin_login_and_cookie_writes(self): + self.config["admin_allowed_origins"] = "https://chat.example.com" + foreign = {"Origin": "https://chat.example.com"} + self.assertEqual(self.client.post("/admin/session", json={"api_key": "synthetic-key"}).status_code, 403) + self.assertEqual(self.client.post("/admin/session", json={"api_key": "synthetic-key"}, + headers={"Origin": "https://evil.invalid"}).status_code, 403) + login = self.client.post("/admin/session", json={"api_key": "synthetic-key"}, headers=foreign) + self.assertEqual(login.status_code, 200, login.text) + csrf = {**foreign, "X-CSRF-Token": login.json()["csrf_token"]} + self.assertEqual(self.client.post("/admin/legacy", headers=csrf).status_code, 200) + self.assertEqual(self.client.post("/admin/legacy", headers={**csrf, "Origin": "https://evil.invalid"}).status_code, 403) + + def test_allowed_origins_setting_validates_normalizes_and_applies_hot(self): + response = self.client.patch("/admin/settings", headers=self.headers, json={ + "revision": 0, "values": {"admin_allowed_origins": "chat.example.com, http://10.0.0.1:8787/ https://dup.example.com,,https://dup.example.com"}}) + self.assertEqual(response.status_code, 200, response.text) + stored = self.store.snapshot()["settings"]["admin_allowed_origins"] + self.assertEqual(stored, "https://chat.example.com,http://10.0.0.1:8787,https://dup.example.com") + self.assertEqual(self.config["admin_allowed_origins"], stored) + login = self.client.post("/admin/session", json={"api_key": "synthetic-key"}, + headers={"Origin": "https://chat.example.com"}) + self.assertEqual(login.status_code, 200, login.text) + cleared = self.client.patch("/admin/settings", headers=self.headers, json={ + "revision": 1, "values": {"admin_allowed_origins": ""}}) + self.assertEqual(cleared.status_code, 200, cleared.text) + self.assertEqual(self.config["admin_allowed_origins"], "") + self.assertEqual(self.client.post("/admin/session", json={"api_key": "synthetic-key"}, + headers={"Origin": "https://chat.example.com"}).status_code, 403) + for invalid in ("ftp://chat.example.com", "https://chat.example.com/path", "http://user@chat.example.com", + "https://chat.example.com#fragment", "https://chat.example.com:0", "::"): + with self.subTest(invalid=invalid): + rejected = self.client.patch("/admin/settings", headers=self.headers, json={ + "revision": self.store.snapshot()["revision"], "values": {"admin_allowed_origins": invalid}}) + self.assertEqual(rejected.status_code, 400) + self.assertEqual(self.store.snapshot()["settings"]["admin_allowed_origins"], "") def test_lan_oauth_poll_uses_referer_without_disabling_csrf(self): origin = "http://192.168.1.10:8787" client = self.enterContext(TestClient(self.app, base_url=origin)) diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 6d82a43..35095e4 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -81,13 +81,16 @@ def test_env_template_matches_runtime_defaults_and_contains_no_shared_key(self): self.assertNotIn("CODEBUDDY2API_AUTO_TRIAL", values) self.assertNotIn("CODEBUDDY2API_AUTO_TRIAL", (ROOT / "docker-compose.yml").read_text()) self.assertEqual(values["CODEBUDDY2API_ADMIN_CSRF"], str(RUNTIME_DEFAULTS["admin_csrf"]).lower()) + self.assertNotIn("CODEBUDDY2API_ADMIN_ORIGINS", values) self.assertNotIn("CODEBUDDY2API_KEEP_TOOL_METADATA", values) def test_tool_metadata_compose_environment_is_optional(self): key = "CODEBUDDY2API_KEEP_TOOL_METADATA" self.assertRegex((ROOT / "docker-compose.yml").read_text(), rf"(?m)^ +{key}: *$") self.assertRegex((ROOT / ".env.example").read_text(), rf"(?m)^# {key}=(true|false)$") - + origins = "CODEBUDDY2API_ADMIN_ORIGINS" + self.assertRegex((ROOT / "docker-compose.yml").read_text(), rf"(?m)^ +{origins}: *$") + self.assertRegex((ROOT / ".env.example").read_text(), rf"(?m)^# {origins}=https://") def test_docker_copies_and_allows_all_local_runtime_imports(self): files = docker_sources() self.assertTrue({"app/client_profiles.py", "app/site_routing.py", "app/trial_rewards.py"} <= files) diff --git a/tests/test_environment_config.py b/tests/test_environment_config.py index 24d872d..3057c18 100644 --- a/tests/test_environment_config.py +++ b/tests/test_environment_config.py @@ -135,6 +135,25 @@ def test_capability_guard_defaults_precedence_and_validation(self): with self.assertRaises(SystemExit): self.start({'CODEBUDDY2API_MODEL_CAPABILITY_GUARD': 'invalid'}) + def test_admin_allowed_origins_precedence_normalization_and_locking(self): + key = 'CODEBUDDY2API_ADMIN_ORIGINS' + self.assertEqual(self.start()[2]['admin_allowed_origins'], '') + _, items, config = self.start(saved={'admin_allowed_origins': 'saved.example.com'}) + self.assertEqual(config['admin_allowed_origins'], 'https://saved.example.com') + self.assertEqual(items['admin_allowed_origins']['source'], 'management') + self.assertFalse(items['admin_allowed_origins']['locked']) + _, items, config = self.start({key: 'env.example.com, http://10.0.0.1:8787'}, + saved={'admin_allowed_origins': 'saved.example.com'}) + self.assertEqual(config['admin_allowed_origins'], 'https://env.example.com,http://10.0.0.1:8787') + self.assertEqual(items['admin_allowed_origins']['source'], 'environment') + self.assertTrue(items['admin_allowed_origins']['locked']) + _, items, config = self.start({key: 'env.example.com'}, cli=('--admin-allowed-origins', 'cli.example.com'), + saved={'admin_allowed_origins': 'saved.example.com'}) + self.assertEqual(config['admin_allowed_origins'], 'https://cli.example.com') + self.assertEqual(items['admin_allowed_origins']['source'], 'cli') + with self.assertRaises(SystemExit): + self.start({key: 'ftp://example.com'}) + def test_request_context_mode_precedence_and_validation(self): _, items, config = self.start(saved={'request_context_mode': 'scoped'}) @@ -186,6 +205,7 @@ def test_compose_forwards_dotenv_limits_and_retries_without_changing_internal_bi 'CODEBUDDY2API_UPSTREAM_KEEPALIVE': 'true', 'CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT': '2', 'CODEBUDDY2API_REQUEST_CONTEXT_MODE': 'scoped', 'CODEBUDDY2API_MODEL_CAPABILITY_GUARD': 'false', + 'CODEBUDDY2API_ADMIN_ORIGINS': 'https://chat.example.com', 'CODEBUDDY2API_KEEP_TOOL_METADATA': 'false', 'CODEBUDDY_IMPORT_DIR': '/data/auth/incoming'} service = self.compose(values) port = service['ports'][0] @@ -200,7 +220,8 @@ def test_compose_unset_optional_settings_do_not_override_webui(self): service = self.compose({}) for name in ('CODEBUDDY2API_KEEP_TOOL_METADATA', 'CODEBUDDY2API_FAILOVER_MAX', 'CODEBUDDY2API_RETRY_WRITE_TIMEOUT', 'CODEBUDDY2API_UPSTREAM_KEEPALIVE', 'CODEBUDDY2API_MAX_INFLIGHT_PER_ACCOUNT', - 'CODEBUDDY2API_REQUEST_CONTEXT_MODE', 'CODEBUDDY2API_MODEL_CAPABILITY_GUARD'): + 'CODEBUDDY2API_REQUEST_CONTEXT_MODE', 'CODEBUDDY2API_MODEL_CAPABILITY_GUARD', + 'CODEBUDDY2API_ADMIN_ORIGINS'): self.assertIsNone(service['environment'].get(name)) self.assertEqual(service['ports'][0]['host_ip'], '127.0.0.1') self.assertEqual(service['environment']['CODEBUDDY_IMPORT_DIR'], '/data/auth/imports') diff --git a/tests/test_runtime_endpoints.py b/tests/test_runtime_endpoints.py index c6ed1b9..a7133d0 100644 --- a/tests/test_runtime_endpoints.py +++ b/tests/test_runtime_endpoints.py @@ -816,12 +816,13 @@ def configure(self, env=None, flags=(), invalid=False, stored=None, expected_hos if expected_host is not None: self.assertEqual(server.call_args.kwargs["host"], expected_host) return {key: converter.CONFIG[key] for key in ( - "max_images", "image_policy", "max_request_bytes", "log_body_limit", "admin_csrf", "keep_tool_metadata")} + "max_images", "image_policy", "max_request_bytes", "log_body_limit", "admin_csrf", + "keep_tool_metadata", "admin_allowed_origins")} def test_defaults(self): self.assertEqual(self.configure(), {"max_images": 16, "image_policy": "truncate", "max_request_bytes": 33554432, "log_body_limit": 65536, - "admin_csrf": True, "keep_tool_metadata": False}) + "admin_csrf": True, "keep_tool_metadata": False, "admin_allowed_origins": ""}) def test_open_binding_without_key_requires_explicit_opt_in(self): # Reject unauthenticated public binding by default. @@ -849,7 +850,7 @@ def test_environment_and_explicit_cli_precedence(self): "CODEBUDDY2API_MAX_REQUEST_BYTES": "100000", "CODEBUDDY2API_LOG_BODY_LIMIT": "0"} self.assertEqual(self.configure(env), {"max_images": 8, "image_policy": "error", "max_request_bytes": 100000, "log_body_limit": 0, - "admin_csrf": True, "keep_tool_metadata": False}) + "admin_csrf": True, "keep_tool_metadata": False, "admin_allowed_origins": ""}) env["CODEBUDDY2API_IMAGE_POLICY"] = "invalid-overridden" self.assertEqual(self.configure(env, ("--max-images", "0", "--image-policy", "truncate"))["max_images"], 0) @@ -885,14 +886,30 @@ def test_tool_metadata_flag_and_environment_precedence(self): self.configure({key: ""}, invalid=True) self.configure(flags=("--keep-tool-metadata", "invalid"), invalid=True) + def test_admin_allowed_origins_flag_and_environment_precedence(self): + key = "CODEBUDDY2API_ADMIN_ORIGINS" + cases = [ + ({}, (), ""), + ({key: "chat.example.com"}, (), "https://chat.example.com"), + ({key: "env.example.com"}, ("--admin-allowed-origins", "cli.example.com:8443"), "https://cli.example.com:8443"), + ({}, ("--admin-allowed-origins", "a.example.com,http://b.example.com:8080"), "https://a.example.com,http://b.example.com:8080"), + ({key: "invalid-overridden"}, ("--admin-allowed-origins", "cli.example.com"), "https://cli.example.com"), + ] + for env, flags, expected in cases: + with self.subTest(env=env, flags=flags): + self.assertEqual(self.configure(env, flags)["admin_allowed_origins"], expected) + def test_invalid_config_fails_before_side_effects(self): for env in ({"CODEBUDDY2API_MAX_IMAGES": "-1"}, {"CODEBUDDY2API_MAX_IMAGES": "1.5"}, {"CODEBUDDY2API_IMAGE_POLICY": "drop"}, {"CODEBUDDY2API_MAX_REQUEST_BYTES": "0"}, - {"CODEBUDDY2API_LOG_BODY_LIMIT": "-1"}, {"CODEBUDDY2API_ADMIN_CSRF": "invalid"}): + {"CODEBUDDY2API_LOG_BODY_LIMIT": "-1"}, {"CODEBUDDY2API_ADMIN_CSRF": "invalid"}, + {"CODEBUDDY2API_ADMIN_ORIGINS": "ftp://example.com"}, + {"CODEBUDDY2API_ADMIN_ORIGINS": "https://example.com/path"}): with self.subTest(env=env): self.configure(env, invalid=True) self.configure(flags=("--max-images", "-1"), invalid=True) self.configure(flags=("--admin-csrf", "invalid"), invalid=True) + self.configure(flags=("--admin-allowed-origins", "example.com:0"), invalid=True) class LogIntegrationTests(unittest.TestCase):