Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/admin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
33 changes: 27 additions & 6 deletions app/admin_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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)
Expand Down
46 changes: 44 additions & 2 deletions app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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


Expand All @@ -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"),
Expand Down Expand Up @@ -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

Expand Down
15 changes: 14 additions & 1 deletion converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 审计,但不输出文本文件。")
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
4 changes: 2 additions & 2 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/advanced.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,可能消耗少量积分。不执行其他奖励任务、不付费开盒、不切猫、不领取国际试用积分。

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading