From a15c1fde4f1b0a466f21ed6feeb1286873c9614a Mon Sep 17 00:00:00 2001 From: 3aKHP <178400596+3aKHP@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:53:52 +0800 Subject: [PATCH 1/4] fix(security): login throttle and session hardening - Resolve client IP by walking X-Forwarded-For right-to-left and taking the rightmost untrusted hop; client-supplied XFF values can no longer bypass login throttling or pin bans on arbitrary IPs - Replace prefix-string trusted-proxy check with ipaddress is_private/is_loopback so public ranges like 172.64.0.0/10 are no longer trusted (RFC1918 is 172.16.0.0/12) - Cap the login failure/ban trackers and sweep expired entries so forged source IPs cannot grow web process memory unboundedly - Add absolute session lifetime WEB_ADMIN_SESSION_MAX_AGE_HOURS (default 168h): sliding renewal can no longer extend a session past creation + max age; existing sessions are invalidated on upgrade - Store only the SHA-256 hash of session ids in web_admin_sessions.db; plaintext ids stay in the browser cookie - Keep the frontend authenticated state when the logout request fails, and tell the admin the session is still valid instead of showing a logged-out UI - docs/admin/web-admin.md: recommend overwrite semantics for X-Forwarded-For (proxy_set_header X-Forwarded-For $remote_addr) and add an nginx limit_req example for /ops/ --- .env.example | 2 + docs/admin/deployment.md | 1 + docs/admin/web-admin.md | 23 +++- frontend/src/composables/useAuth.ts | 7 +- .../references/docs-admin-deployment.md | 1 + .../references/docs-admin-web-admin.md | 23 +++- src/quickquip/app/web/auth.py | 105 +++++++++++---- src/quickquip/app/web/client_ip.py | 35 +++-- src/quickquip/app/web/session_store.py | 14 +- src/quickquip/app/web/settings.py | 11 ++ tests/unit/web/test_auth_security.py | 127 ++++++++++++++++++ tests/unit/web/test_client_ip.py | 59 ++++++++ 12 files changed, 357 insertions(+), 51 deletions(-) create mode 100644 tests/unit/web/test_auth_security.py create mode 100644 tests/unit/web/test_client_ip.py diff --git a/.env.example b/.env.example index ad863c5d..0802f1bc 100644 --- a/.env.example +++ b/.env.example @@ -42,4 +42,6 @@ TIEBA_BROWSER_HEADLESS=true TIEBA_BROWSER_CHANNEL= WEB_ADMIN_PASSWORD=change-this-admin-password WEB_ADMIN_SESSION_TTL_HOURS=168 +# 会话绝对寿命(小时):自创建起超过该时长必须重新登录,滑动续期不能突破该上限。 +WEB_ADMIN_SESSION_MAX_AGE_HOURS=168 WEB_ADMIN_COOKIE_SECURE=auto diff --git a/docs/admin/deployment.md b/docs/admin/deployment.md index f874c097..ee5dfe9b 100644 --- a/docs/admin/deployment.md +++ b/docs/admin/deployment.md @@ -229,6 +229,7 @@ ssh -N -L 5104:127.0.0.1:5104 user@server ```env WEB_ADMIN_SESSION_TTL_HOURS=168 +WEB_ADMIN_SESSION_MAX_AGE_HOURS=168 WEB_ADMIN_COOKIE_SECURE=auto ``` diff --git a/docs/admin/web-admin.md b/docs/admin/web-admin.md index 37946d8d..919ebdb7 100644 --- a/docs/admin/web-admin.md +++ b/docs/admin/web-admin.md @@ -50,9 +50,9 @@ FastAPI 管理接口独立校验应用会话。内层登录带速率限制:同 - 存储位置:`data/web_admin_sessions.db` - 介质:SQLite -- 内容:`session_id`、创建时间、过期时间、最近访问时间、客户端 IP、User-Agent +- 内容:`session_id` 的 SHA-256 哈希(明文 id 只存在于浏览器 cookie)、创建时间、过期时间、最近访问时间、客户端 IP、User-Agent -管理员登录后,浏览器通过会话 Cookie 访问后台;口令只用于登录校验。 +管理员登录后,浏览器通过会话 Cookie 访问后台;口令只用于登录校验。会话随活动滑动续期(`WEB_ADMIN_SESSION_TTL_HOURS`),同时受绝对寿命限制(`WEB_ADMIN_SESSION_MAX_AGE_HOURS`):自创建起超过绝对寿命的会话会被删除并要求重新登录。 ### 3. cookie 属性 @@ -84,6 +84,7 @@ Web Admin 使用以下环境变量: ```env WEB_ADMIN_PASSWORD=change-this-admin-password WEB_ADMIN_SESSION_TTL_HOURS=168 +WEB_ADMIN_SESSION_MAX_AGE_HOURS=168 WEB_ADMIN_COOKIE_SECURE=auto ``` @@ -91,6 +92,7 @@ WEB_ADMIN_COOKIE_SECURE=auto - `WEB_ADMIN_PASSWORD` 应用层登录口令,必填 - `WEB_ADMIN_SESSION_TTL_HOURS` session 续期窗口,默认 `168` +- `WEB_ADMIN_SESSION_MAX_AGE_HOURS` session 绝对寿命,默认 `168`,自创建起超过该时长必须重新登录 - `WEB_ADMIN_COOKIE_SECURE` `auto | true | false` 在根目录 `.env` 设置这些变量后,源码安装重新启动 Web 进程,容器安装重新创建 Web 服务。源码安装先在 `frontend/` 构建前端,再从项目根运行 `python web_api.py`;容器安装与变量更新步骤见[部署指南](deployment.md)。`web_api.py` 启动时读取根 `.env`,已有进程环境变量优先。 @@ -104,11 +106,25 @@ WEB_ADMIN_COOKIE_SECURE=auto ```nginx proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-For $remote_addr; ``` 这样 `WEB_ADMIN_COOKIE_SECURE=auto` 才能正确判断当前请求应当下发 `Secure` cookie。 +`X-Forwarded-For` 使用 `$remote_addr` 覆盖语义:后端从直连对端为内网地址的请求中取 XFF 最右一个非内网跳作为真实客户端 IP,登录限流按该 IP 计数。`$proxy_add_x_forwarded_for` 会把客户端自带的 XFF 保留在链上,覆盖语义可以让链上只有 nginx 实际看到的对端地址。 + +外层 `auth_basic` 本身没有速率限制,建议在 nginx 站点中为 `/ops/` 位置补充请求限流,例如: + +```nginx +limit_req_zone $binary_remote_addr zone=quickquip_ops:10m rate=10r/m; + +location /ops/ { + limit_req zone=quickquip_ops burst=20 nodelay; + auth_basic "quickquip"; + ... +} +``` + 如果你的站点已经明确只通过 HTTPS 暴露,但暂时不方便补这些 header,也可以直接设置: ```env @@ -124,6 +140,7 @@ WEB_ADMIN_COOKIE_SECURE=true - 浏览器会话使用 `SameSite=Strict` - 写操作会额外检查 `Origin` / `Referer` 是否与当前请求宿主一致 - FastAPI 独立校验应用登录会话 +- 后端对全部响应统一下发安全响应头(CSP、`X-Frame-Options`、`X-Content-Type-Options`、`Referrer-Policy`) 外层站点访问控制和应用层 session 共同组成后台的纵深防御边界。 diff --git a/frontend/src/composables/useAuth.ts b/frontend/src/composables/useAuth.ts index 96ae2677..a3db0315 100644 --- a/frontend/src/composables/useAuth.ts +++ b/frontend/src/composables/useAuth.ts @@ -2,7 +2,7 @@ import { ref } from 'vue' import type { Ref } from 'vue' import { getAuthState, login as apiLogin, logout as apiLogout } from '../api/auth' import { setUnauthorizedHandler } from '../api/index' -import { toastMsg } from '../toast' +import { toast, toastMsg } from '../toast' interface ApiError { status?: number @@ -55,11 +55,12 @@ async function handleLogout(): Promise { authError.value = '' try { await apiLogout() + authenticated.value = false } catch (error: unknown) { + // 退出请求失败时服务端会话可能仍有效,保留登录态并如实提示 const err = error as ApiError - authError.value = err.data?.detail || err.message || '退出失败' + toast(`退出失败,当前会话仍有效:${err.data?.detail || err.message || '请稍后重试'}`, 'error') } finally { - authenticated.value = false authBusy.value = false } } diff --git a/skills.example/self-docs/references/docs-admin-deployment.md b/skills.example/self-docs/references/docs-admin-deployment.md index 824871b7..287a64d5 100644 --- a/skills.example/self-docs/references/docs-admin-deployment.md +++ b/skills.example/self-docs/references/docs-admin-deployment.md @@ -231,6 +231,7 @@ ssh -N -L 5104:127.0.0.1:5104 user@server ```env WEB_ADMIN_SESSION_TTL_HOURS=168 +WEB_ADMIN_SESSION_MAX_AGE_HOURS=168 WEB_ADMIN_COOKIE_SECURE=auto ``` diff --git a/skills.example/self-docs/references/docs-admin-web-admin.md b/skills.example/self-docs/references/docs-admin-web-admin.md index 1511dbef..12eaceff 100644 --- a/skills.example/self-docs/references/docs-admin-web-admin.md +++ b/skills.example/self-docs/references/docs-admin-web-admin.md @@ -52,9 +52,9 @@ FastAPI 管理接口独立校验应用会话。内层登录带速率限制:同 - 存储位置:`data/web_admin_sessions.db` - 介质:SQLite -- 内容:`session_id`、创建时间、过期时间、最近访问时间、客户端 IP、User-Agent +- 内容:`session_id` 的 SHA-256 哈希(明文 id 只存在于浏览器 cookie)、创建时间、过期时间、最近访问时间、客户端 IP、User-Agent -管理员登录后,浏览器通过会话 Cookie 访问后台;口令只用于登录校验。 +管理员登录后,浏览器通过会话 Cookie 访问后台;口令只用于登录校验。会话随活动滑动续期(`WEB_ADMIN_SESSION_TTL_HOURS`),同时受绝对寿命限制(`WEB_ADMIN_SESSION_MAX_AGE_HOURS`):自创建起超过绝对寿命的会话会被删除并要求重新登录。 ### 3. cookie 属性 @@ -86,6 +86,7 @@ Web Admin 使用以下环境变量: ```env WEB_ADMIN_PASSWORD=change-this-admin-password WEB_ADMIN_SESSION_TTL_HOURS=168 +WEB_ADMIN_SESSION_MAX_AGE_HOURS=168 WEB_ADMIN_COOKIE_SECURE=auto ``` @@ -93,6 +94,7 @@ WEB_ADMIN_COOKIE_SECURE=auto - `WEB_ADMIN_PASSWORD` 应用层登录口令,必填 - `WEB_ADMIN_SESSION_TTL_HOURS` session 续期窗口,默认 `168` +- `WEB_ADMIN_SESSION_MAX_AGE_HOURS` session 绝对寿命,默认 `168`,自创建起超过该时长必须重新登录 - `WEB_ADMIN_COOKIE_SECURE` `auto | true | false` 在根目录 `.env` 设置这些变量后,源码安装重新启动 Web 进程,容器安装重新创建 Web 服务。源码安装先在 `frontend/` 构建前端,再从项目根运行 `python web_api.py`;容器安装与变量更新步骤见[部署指南](deployment.md)。`web_api.py` 启动时读取根 `.env`,已有进程环境变量优先。 @@ -106,11 +108,25 @@ WEB_ADMIN_COOKIE_SECURE=auto ```nginx proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; -proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-For $remote_addr; ``` 这样 `WEB_ADMIN_COOKIE_SECURE=auto` 才能正确判断当前请求应当下发 `Secure` cookie。 +`X-Forwarded-For` 使用 `$remote_addr` 覆盖语义:后端从直连对端为内网地址的请求中取 XFF 最右一个非内网跳作为真实客户端 IP,登录限流按该 IP 计数。`$proxy_add_x_forwarded_for` 会把客户端自带的 XFF 保留在链上,覆盖语义可以让链上只有 nginx 实际看到的对端地址。 + +外层 `auth_basic` 本身没有速率限制,建议在 nginx 站点中为 `/ops/` 位置补充请求限流,例如: + +```nginx +limit_req_zone $binary_remote_addr zone=quickquip_ops:10m rate=10r/m; + +location /ops/ { + limit_req zone=quickquip_ops burst=20 nodelay; + auth_basic "quickquip"; + ... +} +``` + 如果你的站点已经明确只通过 HTTPS 暴露,但暂时不方便补这些 header,也可以直接设置: ```env @@ -126,6 +142,7 @@ WEB_ADMIN_COOKIE_SECURE=true - 浏览器会话使用 `SameSite=Strict` - 写操作会额外检查 `Origin` / `Referer` 是否与当前请求宿主一致 - FastAPI 独立校验应用登录会话 +- 后端对全部响应统一下发安全响应头(CSP、`X-Frame-Options`、`X-Content-Type-Options`、`Referrer-Policy`) 外层站点访问控制和应用层 session 共同组成后台的纵深防御边界。 diff --git a/src/quickquip/app/web/auth.py b/src/quickquip/app/web/auth.py index e4172059..82a631f0 100644 --- a/src/quickquip/app/web/auth.py +++ b/src/quickquip/app/web/auth.py @@ -1,6 +1,5 @@ from __future__ import annotations -from collections import defaultdict from datetime import datetime, timedelta, timezone from functools import lru_cache import hmac @@ -17,6 +16,7 @@ get_web_admin_cookie_secure_mode, get_web_admin_password, get_web_admin_session_db_path, + get_web_admin_session_max_age_hours, get_web_admin_session_ttl_hours, load_web_env, ) @@ -24,37 +24,68 @@ load_web_env() # --- 登录速率限制(H2)--- -# 每 IP 最多 5 次失败 / 60 秒;超过后封禁 300 秒 +# 每 IP 最多 5 次失败 / 60 秒;超过后封禁 300 秒。 +# 两个字典有容量上限并周期性清扫过期键,伪造来源无法让它们无界增长; +# 容量占满时放弃追踪新键(口令校验与既有封禁不受影响)。 _LOGIN_WINDOW = 60 # 秒 _LOGIN_MAX_FAILS = 5 _LOGIN_BAN_SECONDS = 300 +_LOGIN_TRACK_LIMIT = 4096 +_LOGIN_SWEEP_INTERVAL = 60 # 秒 _login_lock = threading.Lock() -_login_fails: dict[str, list[float]] = defaultdict(list) # ip -> [timestamp, ...] -_login_bans: dict[str, float] = {} # ip -> ban_until +_login_fails: dict[str, list[float]] = {} # ip -> [timestamp, ...] +_login_bans: dict[str, float] = {} # ip -> ban_until +_login_last_sweep = 0.0 router = APIRouter() logger = logging.getLogger(__name__) +def _sweep_login_state(now: float) -> None: + """清扫窗口外的失败记录与已过期的封禁。调用方须持有 _login_lock。""" + global _login_last_sweep + if now - _login_last_sweep < _LOGIN_SWEEP_INTERVAL: + return + _login_last_sweep = now + for ip in [ + ip + for ip, stamps in _login_fails.items() + if all(now - t >= _LOGIN_WINDOW for t in stamps) + ]: + _login_fails.pop(ip, None) + for ip in [ip for ip, ban_until in _login_bans.items() if now >= ban_until]: + _login_bans.pop(ip, None) + + def _check_login_rate_limit(ip: str) -> None: """H2: 登录速率限制。超限时抛出 429。""" now = time.monotonic() with _login_lock: + _sweep_login_state(now) ban_until = _login_bans.get(ip, 0) if now < ban_until: raise HTTPException(status_code=429, detail="too many failed attempts, try later") # 清理窗口外的记录 - _login_fails[ip] = [t for t in _login_fails[ip] if now - t < _LOGIN_WINDOW] + fails = _login_fails.get(ip) + if fails is not None: + _login_fails[ip] = [t for t in fails if now - t < _LOGIN_WINDOW] def _record_login_failure(ip: str) -> None: now = time.monotonic() with _login_lock: - _login_fails[ip].append(now) - if len(_login_fails[ip]) >= _LOGIN_MAX_FAILS: + _sweep_login_state(now) + fails = _login_fails.get(ip) + if fails is None: + if len(_login_fails) >= _LOGIN_TRACK_LIMIT: + return + fails = [] + _login_fails[ip] = fails + fails.append(now) + if len(fails) >= _LOGIN_MAX_FAILS: _login_bans[ip] = now + _LOGIN_BAN_SECONDS - _login_fails[ip] = [] + _login_fails.pop(ip, None) logger.warning("web admin login: ip=%s banned for %ds", ip, _LOGIN_BAN_SECONDS) @@ -84,6 +115,10 @@ def _session_ttl() -> timedelta: return timedelta(hours=get_web_admin_session_ttl_hours()) +def _session_max_age() -> timedelta: + return timedelta(hours=get_web_admin_session_max_age_hours()) + + def _format_dt(value: datetime) -> str: return value.isoformat() @@ -109,8 +144,9 @@ def _cookie_secure(request: Request) -> bool: return _request_scheme(request) == "https" -def _set_session_cookie(response: Response, request: Request, session_id: str) -> None: - max_age = int(_session_ttl().total_seconds()) +def _set_session_cookie( + response: Response, request: Request, session_id: str, *, max_age: int +) -> None: response.set_cookie( key=COOKIE_NAME, value=session_id, @@ -168,9 +204,8 @@ def _enforce_same_origin(request: Request) -> None: raise HTTPException(status_code=403, detail="missing origin header") -def require_admin_session(request: Request, response: Response) -> dict[str, str]: - _enforce_same_origin(request) - +def _load_fresh_session(request: Request, response: Response) -> tuple[dict[str, str], datetime]: + """读取会话并滑动续期;续期以 created_at + 绝对寿命为上界,超过即删除并要求重新登录。""" session_id = request.cookies.get(COOKIE_NAME, "").strip() if not session_id: raise HTTPException(status_code=401, detail="admin login required") @@ -180,10 +215,29 @@ def require_admin_session(request: Request, response: Response) -> dict[str, str if session is None: raise HTTPException(status_code=401, detail="admin login required") - expires_at = _utc_now() + _session_ttl() + now = _utc_now() + try: + created_at = datetime.fromisoformat(session["created_at"]) + except (KeyError, ValueError): + created_at = now + absolute_deadline = created_at + _session_max_age() + if absolute_deadline <= now: + store.delete_session(session_id) + raise HTTPException(status_code=401, detail="session expired, please login again") + + expires_at = min(now + _session_ttl(), absolute_deadline) store.touch_session(session_id, expires_at=_format_dt(expires_at)) - _set_session_cookie(response, request, session_id) + _set_session_cookie( + response, request, session_id, + max_age=max(1, int((expires_at - now).total_seconds())), + ) request.state.admin_session = session + return session, expires_at + + +def require_admin_session(request: Request, response: Response) -> dict[str, str]: + _enforce_same_origin(request) + session, _ = _load_fresh_session(request, response) return session @@ -192,17 +246,7 @@ def require_admin_session(request: Request, response: Response) -> dict[str, str @router.get("/auth/me") def get_auth_state(request: Request, response: Response): - session_id = request.cookies.get(COOKIE_NAME, "").strip() - if not session_id: - raise HTTPException(status_code=401, detail="admin login required") - - session = _session_store().get_session(session_id) - if session is None: - raise HTTPException(status_code=401, detail="admin login required") - - expires_at = _utc_now() + _session_ttl() - _session_store().touch_session(session_id, expires_at=_format_dt(expires_at)) - _set_session_cookie(response, request, session_id) + _, expires_at = _load_fresh_session(request, response) return { "authenticated": True, "expires_at": _format_dt(expires_at), @@ -229,12 +273,17 @@ def login(body: LoginBody, request: Request, response: Response): if existing_session_id: store.delete_session(existing_session_id) + now = _utc_now() + expires_at = now + min(_session_ttl(), _session_max_age()) session = store.create_session( - expires_at=_format_dt(_utc_now() + _session_ttl()), + expires_at=_format_dt(expires_at), client_ip=client_ip, user_agent=request.headers.get("user-agent", ""), ) - _set_session_cookie(response, request, session["session_id"]) + _set_session_cookie( + response, request, session["session_id"], + max_age=max(1, int((expires_at - now).total_seconds())), + ) logger.info("web admin login succeeded: ip=%s", client_ip) return { "ok": True, diff --git a/src/quickquip/app/web/client_ip.py b/src/quickquip/app/web/client_ip.py index 01384a18..ef472b45 100644 --- a/src/quickquip/app/web/client_ip.py +++ b/src/quickquip/app/web/client_ip.py @@ -1,21 +1,36 @@ """Shared helper to resolve the real client IP from a FastAPI request. -Sits behind a Docker bridge (request.client.host is always the gateway). When -the direct peer is a private / loopback address this helper trusts the -leftmost X-Forwarded-For value; otherwise it returns the direct TCP peer IP. +Sits behind a Docker bridge or nginx reverse proxy (request.client.host is +then always the gateway / loopback). When the direct peer is a private or +loopback address this helper walks the X-Forwarded-For chain right-to-left +and takes the rightmost untrusted hop: proxies append the peer they saw, so +the first non-private entry from the right is the real client even when the +client itself prepended a spoofed XFF value. A public direct peer means no +trusted proxy is in the path, and XFF is ignored entirely. """ +import ipaddress + from fastapi import Request -_TRUSTED_PREFIXES = ("127.", "::1", "10.", "172.", "192.168.") + +def _is_trusted_proxy(ip_text: str) -> bool: + try: + address = ipaddress.ip_address(ip_text) + except ValueError: + return False + return address.is_private or address.is_loopback def get_client_ip(request: Request) -> str: direct_ip = request.client.host if request.client else "" - if direct_ip and any(direct_ip.startswith(p) for p in _TRUSTED_PREFIXES): - forwarded_for = request.headers.get("x-forwarded-for", "") - if forwarded_for: - candidate = forwarded_for.split(",")[0].strip() - if candidate and "\n" not in candidate and "\r" not in candidate: - return candidate + if not direct_ip or not _is_trusted_proxy(direct_ip): + return direct_ip + forwarded_for = request.headers.get("x-forwarded-for", "") + for hop in reversed(forwarded_for.split(",")): + candidate = hop.strip() + if not candidate or "\n" in candidate or "\r" in candidate: + continue + if not _is_trusted_proxy(candidate): + return candidate return direct_ip diff --git a/src/quickquip/app/web/session_store.py b/src/quickquip/app/web/session_store.py index 5cfc1825..8d5bdd1b 100644 --- a/src/quickquip/app/web/session_store.py +++ b/src/quickquip/app/web/session_store.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import logging from datetime import datetime, timezone from pathlib import Path @@ -13,6 +14,11 @@ def _utc_now() -> str: return datetime.now(timezone.utc).isoformat() +def _hash_session_id(session_id: str) -> str: + """落库主键只存会话 id 的 SHA-256;明文 id 仅存在于 cookie 与内存中。""" + return hashlib.sha256(session_id.encode("utf-8")).hexdigest() + + class WebAdminSessionStore: def __init__(self, path: str | Path): self.path = Path(path) @@ -78,7 +84,7 @@ def create_session( VALUES (?, ?, ?, ?, ?, ?) """, ( - session_id, + _hash_session_id(session_id), now, expires_at, now, @@ -104,7 +110,7 @@ def get_session(self, session_id: str) -> dict[str, str] | None: FROM admin_sessions WHERE session_id = ? """, - (session_id,), + (_hash_session_id(session_id),), ).fetchone() if row is None: return None @@ -121,7 +127,7 @@ def touch_session(self, session_id: str, *, expires_at: str) -> bool: SET expires_at = ?, last_seen_at = ? WHERE session_id = ? """, - (expires_at, now, session_id), + (expires_at, now, _hash_session_id(session_id)), ) return int(cursor.rowcount) > 0 @@ -131,6 +137,6 @@ def delete_session(self, session_id: str) -> bool: with self._connect() as conn: cursor = conn.execute( "DELETE FROM admin_sessions WHERE session_id = ?", - (session_id,), + (_hash_session_id(session_id),), ) return int(cursor.rowcount) > 0 diff --git a/src/quickquip/app/web/settings.py b/src/quickquip/app/web/settings.py index aef74239..3b2bec69 100644 --- a/src/quickquip/app/web/settings.py +++ b/src/quickquip/app/web/settings.py @@ -49,6 +49,17 @@ def get_web_admin_session_ttl_hours() -> int: return min(max(value, 1), 24 * 365) +def get_web_admin_session_max_age_hours() -> int: + """WEB_ADMIN_SESSION_MAX_AGE_HOURS(.env),会话绝对寿命,超时强制重新登录。""" + load_web_env() + raw = os.environ.get("WEB_ADMIN_SESSION_MAX_AGE_HOURS", "168").strip() + try: + value = int(raw) + except ValueError: + return 168 + return min(max(value, 1), 24 * 365) + + def get_web_admin_cookie_secure_mode() -> str: load_web_env() raw = os.environ.get("WEB_ADMIN_COOKIE_SECURE", "auto").strip().lower() diff --git a/tests/unit/web/test_auth_security.py b/tests/unit/web/test_auth_security.py new file mode 100644 index 00000000..6c0865ac --- /dev/null +++ b/tests/unit/web/test_auth_security.py @@ -0,0 +1,127 @@ +"""登录限流容量上限/清扫与会话生命周期(绝对过期、哈希落库)测试。""" +from __future__ import annotations + +import hashlib +import sqlite3 +from datetime import datetime, timedelta, timezone + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from quickquip.app.web import auth +from quickquip.app.web.session_store import WebAdminSessionStore + + +@pytest.fixture(autouse=True) +def reset_login_state(monkeypatch): + monkeypatch.setattr(auth, "_login_fails", {}) + monkeypatch.setattr(auth, "_login_bans", {}) + monkeypatch.setattr(auth, "_login_last_sweep", 0.0) + + +class TestLoginRateLimitBounds: + def test_ban_still_triggers_below_cap(self): + for _ in range(auth._LOGIN_MAX_FAILS): + auth._record_login_failure("1.2.3.4") + with pytest.raises(Exception) as exc_info: + auth._check_login_rate_limit("1.2.3.4") + assert getattr(exc_info.value, "status_code", None) == 429 + + def test_new_key_not_tracked_when_fails_dict_at_cap(self, monkeypatch): + monkeypatch.setattr(auth, "_LOGIN_TRACK_LIMIT", 2) + auth._record_login_failure("1.1.1.1") + auth._record_login_failure("2.2.2.2") + auth._record_login_failure("3.3.3.3") + assert set(auth._login_fails) == {"1.1.1.1", "2.2.2.2"} + + def test_sweep_removes_stale_fails_and_expired_bans(self, monkeypatch): + monkeypatch.setattr(auth, "_LOGIN_SWEEP_INTERVAL", 0) + now = 1000.0 + auth._login_fails["old"] = [now - auth._LOGIN_WINDOW - 1] + auth._login_fails["fresh"] = [now - 1] + auth._login_bans["expired"] = now - 1 + auth._login_bans["active"] = now + 100 + monkeypatch.setattr(auth.time, "monotonic", lambda: now) + auth._check_login_rate_limit("unrelated") + assert set(auth._login_fails) == {"fresh"} + assert set(auth._login_bans) == {"active"} + + def test_sweep_frees_capacity_for_new_keys(self, monkeypatch): + monkeypatch.setattr(auth, "_LOGIN_TRACK_LIMIT", 1) + monkeypatch.setattr(auth, "_LOGIN_SWEEP_INTERVAL", 0) + now = 1000.0 + auth._login_fails["stale"] = [now - auth._LOGIN_WINDOW - 1] + monkeypatch.setattr(auth.time, "monotonic", lambda: now) + auth._record_login_failure("new") + assert set(auth._login_fails) == {"new"} + + +@pytest.fixture +def client(monkeypatch, tmp_path, config_service): + monkeypatch.setattr(auth, "get_web_admin_password", lambda: "test-admin-password") + sessions = WebAdminSessionStore(tmp_path / "sessions.db") + monkeypatch.setattr(auth, "_session_store", lambda: sessions) + app = FastAPI() + app.include_router(auth.router, prefix="/ops/api") + with TestClient(app, headers={"origin": "http://testserver"}) as result: + result.sessions = sessions + yield result + + +def _login(client) -> str: + response = client.post("/ops/api/auth/login", json={"password": "test-admin-password"}) + assert response.status_code == 200 + return response.cookies[auth.COOKIE_NAME] + + +def _backdate_created_at(store: WebAdminSessionStore, session_id: str, *, hours: float) -> None: + created = datetime.now(timezone.utc) - timedelta(hours=hours) + key = hashlib.sha256(session_id.encode("utf-8")).hexdigest() + with sqlite3.connect(store.path) as conn: + conn.execute( + "UPDATE admin_sessions SET created_at = ? WHERE session_id = ?", + (created.isoformat(), key), + ) + + +class TestSessionAbsoluteExpiry: + def test_session_past_absolute_max_age_is_deleted(self, client, monkeypatch): + monkeypatch.setattr(auth, "get_web_admin_session_ttl_hours", lambda: 24) + monkeypatch.setattr(auth, "get_web_admin_session_max_age_hours", lambda: 24) + session_id = _login(client) + _backdate_created_at(client.sessions, session_id, hours=25) + + response = client.get("/ops/api/auth/me") + + assert response.status_code == 401 + assert client.sessions.get_session(session_id) is None + + def test_sliding_renewal_is_capped_by_absolute_deadline(self, client, monkeypatch): + monkeypatch.setattr(auth, "get_web_admin_session_ttl_hours", lambda: 24) + monkeypatch.setattr(auth, "get_web_admin_session_max_age_hours", lambda: 24) + session_id = _login(client) + _backdate_created_at(client.sessions, session_id, hours=23.5) + + response = client.get("/ops/api/auth/me") + + assert response.status_code == 200 + expires_at = datetime.fromisoformat(response.json()["expires_at"]) + # 续期结果被绝对寿命截断在 30 分钟附近,而不是完整滑动 24 小时 + remaining = (expires_at - datetime.now(timezone.utc)).total_seconds() + assert 0 < remaining < 3600 + + +class TestSessionIdHashing: + def test_db_stores_only_sha256_of_session_id(self, client): + session_id = _login(client) + with sqlite3.connect(client.sessions.path) as conn: + rows = conn.execute("SELECT session_id FROM admin_sessions").fetchall() + assert len(rows) == 1 + assert rows[0][0] == hashlib.sha256(session_id.encode("utf-8")).hexdigest() + assert rows[0][0] != session_id + + def test_logout_invalidates_server_side_session(self, client): + _login(client) + assert client.post("/ops/api/auth/logout").status_code == 200 + assert client.get("/ops/api/auth/me").status_code == 401 diff --git a/tests/unit/web/test_client_ip.py b/tests/unit/web/test_client_ip.py new file mode 100644 index 00000000..f1d1d90c --- /dev/null +++ b/tests/unit/web/test_client_ip.py @@ -0,0 +1,59 @@ +"""client_ip 信任链测试:仅当直连对端是内网/回环地址时采信 XFF,并取最右一个不可信跳。""" +from __future__ import annotations + +from types import SimpleNamespace + +from quickquip.app.web.client_ip import get_client_ip + + +def _request(host: str, xff: str | None = None): + headers = {"x-forwarded-for": xff} if xff is not None else {} + return SimpleNamespace(client=SimpleNamespace(host=host), headers=headers) + + +class TestDirectPeer: + def test_public_direct_peer_ignores_xff(self): + request = _request("9.9.9.9", xff="1.1.1.1") + assert get_client_ip(request) == "9.9.9.9" + + def test_private_direct_peer_without_xff_returns_peer(self): + assert get_client_ip(_request("10.0.0.1")) == "10.0.0.1" + assert get_client_ip(_request("127.0.0.1")) == "127.0.0.1" + + def test_public_172_outside_rfc1918_is_not_trusted(self): + # 172.16.0.0/12 以外的 172.x 是公网地址,不得进入 XFF 信任分支 + request = _request("172.15.0.1", xff="1.1.1.1") + assert get_client_ip(request) == "172.15.0.1" + + def test_172_16_boundary(self): + assert get_client_ip(_request("172.16.0.1", xff="8.8.8.8")) == "8.8.8.8" + assert get_client_ip(_request("172.31.255.254", xff="8.8.8.8")) == "8.8.8.8" + assert get_client_ip(_request("172.32.0.1", xff="8.8.8.8")) == "172.32.0.1" + + def test_loopback_v6_trusted(self): + assert get_client_ip(_request("::1", xff="8.8.8.8")) == "8.8.8.8" + + +class TestXffChain: + def test_overwrite_semantics_single_hop(self): + assert get_client_ip(_request("10.0.0.1", xff="8.8.8.8")) == "8.8.8.8" + + def test_append_semantics_takes_real_client_not_spoofed_left(self): + # $proxy_add_x_forwarded_for 形态:客户端伪造值在左,nginx 追加的真实对端在右 + request = _request("10.0.0.1", xff="1.2.3.4, 8.8.8.8") + assert get_client_ip(request) == "8.8.8.8" + + def test_skips_trusted_hops_from_right(self): + # 链尾是内网代理时继续向左找第一个公网跳 + request = _request("10.0.0.1", xff="8.8.8.8, 172.18.0.2") + assert get_client_ip(request) == "8.8.8.8" + + def test_all_hops_trusted_falls_back_to_direct_peer(self): + request = _request("10.0.0.1", xff="192.168.1.2, 172.18.0.2") + assert get_client_ip(request) == "10.0.0.1" + + def test_blank_and_garbled_hops_skipped_or_returned_verbatim(self): + # 空跳被跳过;无法解析为 IP 的跳按不可信处理,原样作为限流键 + assert get_client_ip(_request("10.0.0.1", xff=" ")) == "10.0.0.1" + assert get_client_ip(_request("10.0.0.1", xff="garbled, 8.8.8.8")) == "8.8.8.8" + assert get_client_ip(_request("10.0.0.1", xff="garbled")) == "garbled" From 8a8342d38fecd5b2706b788c86a5c24fc08a1eac Mon Sep 17 00:00:00 2001 From: 3aKHP <178400596+3aKHP@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:54:02 +0800 Subject: [PATCH 2/4] fix(security): add security headers, hide API docs - Serve CSP, X-Frame-Options, X-Content-Type-Options and Referrer-Policy on every Web Admin response (static pages and API) - Stop exposing /docs, /redoc and /openapi.json on the admin API; the public machine contract for llm-configuration keeps being exported in-process by scripts/ci/export_llm_configuration_openapi.py --- docs/dev/llm-configuration-api.md | 2 +- .../docs-dev-llm-configuration-api.md | 2 +- src/quickquip/app/web/app.py | 29 ++++++++- tests/unit/web/test_app_security.py | 61 +++++++++++++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 tests/unit/web/test_app_security.py diff --git a/docs/dev/llm-configuration-api.md b/docs/dev/llm-configuration-api.md index cb20eedc..84c98ae6 100644 --- a/docs/dev/llm-configuration-api.md +++ b/docs/dev/llm-configuration-api.md @@ -2,7 +2,7 @@ 本文供配置 API 调用方和维护者查阅。LLM 配置使用 `data/llm_config.db`,Web Admin、CLI 与 Bot 通过同一个应用服务保存草稿、建立快照和提交控制任务。人格文件、Skill 内容、群覆盖、历史记录、用量和 Trace 由各自存储维护。 -接口前缀为 `/ops/api/llm-configuration`,需要现有管理员会话 Cookie `quickquip_admin_session`。写请求必须携带同源 `Origin` 或 `Referer`。请求体上限为 1 MiB,分块传输也执行此限制。完整机器契约见 [OpenAPI JSON](llm-configuration.openapi.json);运行中的管理 API 在 `/openapi.json` 提供同一字段约束。 +接口前缀为 `/ops/api/llm-configuration`,需要现有管理员会话 Cookie `quickquip_admin_session`。写请求必须携带同源 `Origin` 或 `Referer`。请求体上限为 1 MiB,分块传输也执行此限制。完整机器契约见 [OpenAPI JSON](llm-configuration.openapi.json),由 `python scripts/ci/export_llm_configuration_openapi.py` 在进程内构造应用导出,随约束或路由变更同步提交;运行中的管理 API 不提供 `/docs`、`/redoc`、`/openapi.json` 文档端点。 前端设计与操作流程见[前端交付契约](llm-configuration-frontend.md),其中说明中文字段含义、参数继承控件、保存与应用、冲突处理及设计验收场景。 diff --git a/skills.example/self-docs/references/docs-dev-llm-configuration-api.md b/skills.example/self-docs/references/docs-dev-llm-configuration-api.md index e7d2a609..854c0746 100644 --- a/skills.example/self-docs/references/docs-dev-llm-configuration-api.md +++ b/skills.example/self-docs/references/docs-dev-llm-configuration-api.md @@ -4,7 +4,7 @@ 本文供配置 API 调用方和维护者查阅。LLM 配置使用 `data/llm_config.db`,Web Admin、CLI 与 Bot 通过同一个应用服务保存草稿、建立快照和提交控制任务。人格文件、Skill 内容、群覆盖、历史记录、用量和 Trace 由各自存储维护。 -接口前缀为 `/ops/api/llm-configuration`,需要现有管理员会话 Cookie `quickquip_admin_session`。写请求必须携带同源 `Origin` 或 `Referer`。请求体上限为 1 MiB,分块传输也执行此限制。完整机器契约见 [OpenAPI JSON](llm-configuration.openapi.json);运行中的管理 API 在 `/openapi.json` 提供同一字段约束。 +接口前缀为 `/ops/api/llm-configuration`,需要现有管理员会话 Cookie `quickquip_admin_session`。写请求必须携带同源 `Origin` 或 `Referer`。请求体上限为 1 MiB,分块传输也执行此限制。完整机器契约见 [OpenAPI JSON](llm-configuration.openapi.json),由 `python scripts/ci/export_llm_configuration_openapi.py` 在进程内构造应用导出,随约束或路由变更同步提交;运行中的管理 API 不提供 `/docs`、`/redoc`、`/openapi.json` 文档端点。 前端设计与操作流程见[前端交付契约](llm-configuration-frontend.md),其中说明中文字段含义、参数继承控件、保存与应用、冲突处理及设计验收场景。 diff --git a/src/quickquip/app/web/app.py b/src/quickquip/app/web/app.py index 238482ab..62a0aef9 100644 --- a/src/quickquip/app/web/app.py +++ b/src/quickquip/app/web/app.py @@ -50,6 +50,29 @@ async def _root() -> RedirectResponse: return RedirectResponse(f"{_OPS_PREFIX}/") +# 全站安全响应头:CSP 允许同源 + data:/https: 图片(词云 base64、贴吧 imgproxy、 +# 总结外链图)与内联样式(组件 style 绑定);SSE 走同源 connect-src。 +_SECURITY_HEADERS = { + "Content-Security-Policy": ( + "default-src 'self'; img-src 'self' data: https:; " + "style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; " + "object-src 'none'; base-uri 'self'; frame-ancestors 'none'" + ), + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "X-Frame-Options": "DENY", +} + + +def _register_security_headers(app: FastAPI) -> None: + @app.middleware("http") + async def _security_headers(request, call_next): + response = await call_next(request) + for name, value in _SECURITY_HEADERS.items(): + response.headers.setdefault(name, value) + return response + + def create_app() -> FastAPI: load_web_env() from quickquip.llm.client_fingerprints.catalog import get_catalog @@ -60,7 +83,11 @@ def create_app() -> FastAPI: tieba_service.load() - app = FastAPI(title="QuickQuip Admin") + # 管理 API 的 OpenAPI/文档端点不对网络暴露;llm-configuration 的公开机器契约 + # 由 scripts/ci/export_llm_configuration_openapi.py 在进程内导出维护。 + app = FastAPI(title="QuickQuip Admin", docs_url=None, redoc_url=None, openapi_url=None) + + _register_security_headers(app) app.include_router(auth.router, prefix="/ops/api") app.include_router(stats.router, prefix="/ops/api", dependencies=auth.protected_dependencies) diff --git a/tests/unit/web/test_app_security.py b/tests/unit/web/test_app_security.py new file mode 100644 index 00000000..4b9cbc4f --- /dev/null +++ b/tests/unit/web/test_app_security.py @@ -0,0 +1,61 @@ +"""应用级安全边界:API 文档端点不对外暴露,全部响应携带安全响应头。""" +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from quickquip.app.web import app as web_app + + +@pytest.fixture +def created_client(monkeypatch): + monkeypatch.setattr( + "quickquip.llm.client_fingerprints.catalog.get_catalog", lambda: None + ) + from quickquip.app import message_pipeline + + monkeypatch.setattr(message_pipeline.tieba_service, "load", lambda: None) + with TestClient(web_app.create_app()) as client: + yield client + + +class TestApiDocsDisabled: + @pytest.mark.parametrize("path", ["/docs", "/redoc", "/openapi.json"]) + def test_docs_endpoints_not_exposed(self, created_client, path): + assert created_client.get(path).status_code == 404 + + +class TestSecurityHeaders: + EXPECTED = { + "content-security-policy": "default-src 'self'; img-src 'self' data: https:; " + "style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; " + "object-src 'none'; base-uri 'self'; frame-ancestors 'none'", + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + "x-frame-options": "DENY", + } + + def test_headers_on_api_responses(self, created_client): + response = created_client.get("/ops/api/auth/me") + assert response.status_code == 401 + for name, value in self.EXPECTED.items(): + assert response.headers.get(name) == value + + def test_headers_on_root_redirect(self, created_client): + response = created_client.get("/", follow_redirects=False) + assert response.status_code == 307 + for name, value in self.EXPECTED.items(): + assert response.headers.get(name) == value + + def test_headers_registered_on_bare_app(self): + bare = FastAPI() + web_app._register_security_headers(bare) + + @bare.get("/ping") + def _ping(): + return {"ok": True} + + response = TestClient(bare).get("/ping") + for name, value in self.EXPECTED.items(): + assert response.headers.get(name) == value From 32def544f4588667369a5fecfd90d0216841ec3c Mon Sep 17 00:00:00 2001 From: 3aKHP <178400596+3aKHP@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:54:11 +0800 Subject: [PATCH 3/4] fix(security): tieba sync via POST, sanitize 5xx - Tieba sync has real side effects (external fetching, pool rewrite), so it is now triggered with POST instead of GET, restoring the GET-has-no-side-effects assumption behind the same-origin check exemption; the frontend consumes the progress stream with fetch + AbortController (EventSource only supports GET) and aborts it when the page scope is disposed - Stop echoing internal exception text in 5xx responses of tieba peek and wordcloud rendering (may contain server paths); clients get a generic message while the real exception goes to the service log - Tieba thread links accept only http/https; any other scheme returned upstream falls back to the canonical thread URL --- frontend/src/api/index.ts | 2 +- frontend/src/api/tieba.ts | 65 ++++++++++++++++++----- frontend/src/composables/useTiebaSync.ts | 23 +++++--- src/quickquip/app/web/routes/tieba.py | 10 +++- src/quickquip/app/web/routes/wordcloud.py | 13 ++--- src/quickquip/tieba/crawler.py | 8 ++- tests/unit/tieba/test_crawler.py | 32 +++++++++++ tests/unit/web/test_tieba_sync_route.py | 52 ++++++++++++++++++ 8 files changed, 174 insertions(+), 31 deletions(-) create mode 100644 tests/unit/web/test_tieba_sync_route.py diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 1dbb9cf0..ab6180df 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,4 +1,4 @@ -const BASE = import.meta.env.BASE_URL.replace(/\/$/, '') +export const BASE = import.meta.env.BASE_URL.replace(/\/$/, '') class ApiError extends Error { status: number diff --git a/frontend/src/api/tieba.ts b/frontend/src/api/tieba.ts index d9539cff..8d892965 100644 --- a/frontend/src/api/tieba.ts +++ b/frontend/src/api/tieba.ts @@ -1,4 +1,4 @@ -import { request } from './index' +import { request, BASE } from './index' /** 对应 TiebaThread.to_dict();peek 接口原样返回此形状 */ export interface TiebaThread { @@ -63,22 +63,59 @@ export function tiebaImgProxyUrl(url: string) { return `/ops/api/tieba/imgproxy?url=${encodeURIComponent(url)}` } -export function openTiebaSyncStream(forum: string, onMessage: (data: string) => void, onDone: () => void) { +export interface TiebaSyncStream { + /** 中止同步流(组件卸载时调用);服务端的抓取任务可能继续执行至完成 */ + close: () => void +} + +/** + * 触发贴吧同步并消费其 SSE 进度流。同步有副作用,走 POST + fetch 流式读取 + * (EventSource 只支持 GET),返回的句柄用于主动关闭。 + * 结束语义:收到 [done]、流结束或连接出错即视为完成(不重连),回调 onDone。 + */ +export function startTiebaSync( + forum: string, + onMessage: (data: string) => void, + onDone: () => void, +): TiebaSyncStream { + const controller = new AbortController() const params = forum ? `?forum=${encodeURIComponent(forum)}` : '' - const es = new EventSource(`/ops/api/tieba/sync${params}`) - es.onmessage = (e) => { - if (e.data === '[done]') { - es.close() - onDone() - } else { - onMessage(e.data) - } - } - es.onerror = () => { - es.close() + let doneFired = false + const fireDone = () => { + if (doneFired) return + doneFired = true onDone() } - return es + + fetch(`${BASE}/api/tieba/sync${params}`, { + method: 'POST', + credentials: 'same-origin', + signal: controller.signal, + }) + .then(async (res) => { + if (!res.ok || !res.body) return + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + let sep = buffer.indexOf('\n\n') + while (sep >= 0) { + const frame = buffer.slice(0, sep) + buffer = buffer.slice(sep + 2) + sep = buffer.indexOf('\n\n') + const data = frame.startsWith('data: ') ? frame.slice(6) : null + if (data === '[done]') return + if (data) onMessage(data) + } + } + }) + .catch(() => {}) + .finally(fireDone) + + return { close: () => controller.abort() } } export async function listTiebaForums(): Promise { diff --git a/frontend/src/composables/useTiebaSync.ts b/frontend/src/composables/useTiebaSync.ts index 1594f8cb..815eac3b 100644 --- a/frontend/src/composables/useTiebaSync.ts +++ b/frontend/src/composables/useTiebaSync.ts @@ -1,27 +1,30 @@ -import { ref, nextTick } from 'vue' -import { openTiebaSyncStream } from '../api/tieba' +import { ref, nextTick, onScopeDispose } from 'vue' +import { startTiebaSync, type TiebaSyncStream } from '../api/tieba' /** - * 贴吧同步流(SSE):连接、日志缓冲与自动滚动。 - * 结束语义与 api/tieba.openTiebaSyncStream 一致:收到 [done] 或连接出错 - * 即关闭流并视为完成(不重连),随后回调 onSynced 让调用方刷新数据。 - * 组件卸载时不主动关流(沿用拆分前视图的行为;SSE 生命周期强化留待后续)。 + * 贴吧同步流(POST + fetch 流式读取):连接、日志缓冲与自动滚动。 + * 结束语义与 api/tieba.startTiebaSync 一致:收到 [done]、流结束或连接出错 + * 即视为完成(不重连),随后回调 onSynced 让调用方刷新数据。 + * 组件卸载时主动中止流;服务端的抓取任务可能继续执行至完成。 */ export function useTiebaSync(onSynced: () => void) { const syncing = ref(false) const syncLog = ref([]) const logEl = ref(null) + let stream: TiebaSyncStream | null = null function startSync(forum: string | null) { + stream?.close() syncing.value = true syncLog.value = [`▶ 开始同步${forum ? ' ' + forum + '吧' : '全部贴吧'}…`] - openTiebaSyncStream( + stream = startTiebaSync( forum || '', (msg) => { syncLog.value.push(msg) nextTick(() => { if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight }) }, () => { + stream = null syncing.value = false onSynced() }, @@ -32,5 +35,11 @@ export function useTiebaSync(onSynced: () => void) { syncLog.value = [] } + onScopeDispose(() => { + stream?.close() + stream = null + syncing.value = false + }) + return { syncing, syncLog, logEl, startSync, clearLog } } diff --git a/src/quickquip/app/web/routes/tieba.py b/src/quickquip/app/web/routes/tieba.py index 164c7625..41cd512e 100644 --- a/src/quickquip/app/web/routes/tieba.py +++ b/src/quickquip/app/web/routes/tieba.py @@ -1,4 +1,5 @@ import asyncio +import logging import re import urllib.request @@ -12,6 +13,7 @@ } router = APIRouter() +logger = logging.getLogger(__name__) _FORUM_RE = re.compile(r"^[^\s/\\:]{1,32}$") _TID_RE = re.compile(r"^\d{1,20}$") @@ -140,7 +142,9 @@ def proxy_image(url: str = Query(..., max_length=512)): return Response(content=data, media_type=content_type) -@router.get("/tieba/sync") +# 同步有真实副作用(对外抓取、改写帖子池状态),必须是 POST; +# 同源校验对 GET/HEAD/OPTIONS 豁免的前提正是 GET 无副作用。 +@router.post("/tieba/sync") async def sync_tieba(forum: str | None = Query(default=None, max_length=32)): if forum is not None: _validate_forum(forum) @@ -186,9 +190,11 @@ async def peek_tieba(forum: str = Query(..., max_length=32)): try: thread = await tieba_service.peek_random_thread(forum) except TiebaLoginRequiredError as exc: + # 登录态失效的文案由领域异常自带,可直接回显给管理员 raise HTTPException(status_code=409, detail=str(exc)) from exc except TiebaServiceError as exc: - raise HTTPException(status_code=502, detail=str(exc)) from exc + logger.warning("tieba peek failed for forum %s: %s", forum, exc) + raise HTTPException(status_code=502, detail="贴吧访问失败,请查看服务日志") from exc if thread is None: raise HTTPException(status_code=404, detail="thread not found") return thread.to_dict() diff --git a/src/quickquip/app/web/routes/wordcloud.py b/src/quickquip/app/web/routes/wordcloud.py index 4a1aa212..bc2fd19c 100644 --- a/src/quickquip/app/web/routes/wordcloud.py +++ b/src/quickquip/app/web/routes/wordcloud.py @@ -84,9 +84,9 @@ async def render_wordcloud( try: freq = await asyncio.to_thread(build_word_frequencies, messages, WORDCLOUD_STOPWORDS) - except Exception as e: + except Exception: logger.exception("wordcloud: build_word_frequencies failed for group %s", group) - raise HTTPException(status_code=500, detail=f"词频统计失败:{e}") + raise HTTPException(status_code=500, detail="词频统计失败,请查看服务日志") word_count = sum(freq.values()) if word_count < WORDCLOUD_MIN_WORDS: @@ -97,11 +97,12 @@ async def render_wordcloud( try: png_bytes = await asyncio.to_thread(render_wordcloud_bytes, freq) - except FileNotFoundError as e: - raise HTTPException(status_code=500, detail=str(e)) - except Exception as e: + except FileNotFoundError: logger.exception("wordcloud: render failed for group %s", group) - raise HTTPException(status_code=500, detail=f"渲染失败:{e}") + raise HTTPException(status_code=500, detail="渲染失败:字体文件缺失,请查看服务日志") + except Exception: + logger.exception("wordcloud: render failed for group %s", group) + raise HTTPException(status_code=500, detail="渲染失败,请查看服务日志") top_words = sorted(freq.items(), key=lambda kv: kv[1], reverse=True)[:top_k] return { diff --git a/src/quickquip/tieba/crawler.py b/src/quickquip/tieba/crawler.py index 798748c2..f31670a9 100644 --- a/src/quickquip/tieba/crawler.py +++ b/src/quickquip/tieba/crawler.py @@ -222,7 +222,13 @@ def extract_thread_detail_from_data( return None title = clean_thread_title(str(thread.get("title", "")) or fallback_title) or fallback_title - thread_url = str(thread.get("thread_share_link", "")).strip() or f"https://tieba.baidu.com/p/{tid}" + # 上游 thread_share_link 只接受 http(s) scheme,其余(含 javascript: 形态)丢弃 + raw_url = str(thread.get("thread_share_link", "")).strip() + thread_url = ( + raw_url + if raw_url.lower().startswith(("http://", "https://")) + else f"https://tieba.baidu.com/p/{tid}" + ) author_name = clean_text( str(thread.get("author", {}).get("name_show", "")) diff --git a/tests/unit/tieba/test_crawler.py b/tests/unit/tieba/test_crawler.py index f1357765..6aef5e1a 100644 --- a/tests/unit/tieba/test_crawler.py +++ b/tests/unit/tieba/test_crawler.py @@ -83,6 +83,38 @@ def test_ignores_non_http_urls(self, crawler: TiebaCrawler): assert images == [] +def _thread_data(share_link: str) -> dict: + return { + "thread": { + "id": "123", + "title": "标题", + "thread_share_link": share_link, + "author": {"name_show": "作者"}, + }, + "first_floor": {"content": [{"type": 0, "text": "正文"}]}, + } + + +class TestThreadUrlNormalization: + def test_http_and_https_links_kept(self, crawler: TiebaCrawler): + for link in ("https://tieba.baidu.com/p/123", "http://tieba.baidu.com/p/123"): + thread = crawler.extract_thread_detail_from_data(_thread_data(link), "回退", "测试") + assert thread is not None + assert thread.thread_url == link + + def test_non_http_scheme_dropped_to_canonical_url(self, crawler: TiebaCrawler): + thread = crawler.extract_thread_detail_from_data( + _thread_data("javascript:alert(1)"), "回退", "测试" + ) + assert thread is not None + assert thread.thread_url == "https://tieba.baidu.com/p/123" + + def test_empty_link_falls_back_to_canonical_url(self, crawler: TiebaCrawler): + thread = crawler.extract_thread_detail_from_data(_thread_data(" "), "回退", "测试") + assert thread is not None + assert thread.thread_url == "https://tieba.baidu.com/p/123" + + async def test_collect_threads_fails_when_profile_locked( crawler: TiebaCrawler, monkeypatch ) -> None: diff --git a/tests/unit/web/test_tieba_sync_route.py b/tests/unit/web/test_tieba_sync_route.py new file mode 100644 index 00000000..3cd9cedd --- /dev/null +++ b/tests/unit/web/test_tieba_sync_route.py @@ -0,0 +1,52 @@ +"""贴吧同步路由:POST 触发同步并回传 SSE 进度流,GET 不再可用(无副作用)。""" +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from quickquip.app.web.routes import tieba + + +class _FakeTiebaService: + def __init__(self): + self.sync_calls: list[dict] = [] + + async def sync_now(self, *, force, forum_keyword, on_progress): + self.sync_calls.append({"force": force, "forum_keyword": forum_keyword}) + if on_progress: + on_progress("▶ 开始同步") + on_progress("✓ 同步完成") + return {"results": [], "count": 0, "status": "ok", "message": ""} + + +@pytest.fixture +def service(monkeypatch): + fake = _FakeTiebaService() + monkeypatch.setattr("quickquip.app.message_pipeline.tieba_service", fake) + return fake + + +@pytest.fixture +def client(): + app = FastAPI() + app.include_router(tieba.router, prefix="/ops/api") + with TestClient(app) as result: + yield result + + +def test_post_triggers_sync_and_streams_progress(client, service): + response = client.post("/ops/api/tieba/sync", params={"forum": "测试"}) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + assert "data: ▶ 开始同步" in response.text + assert "data: [done]" in response.text + assert service.sync_calls == [{"force": True, "forum_keyword": "测试"}] + + +def test_get_sync_route_does_not_exist(client, service): + response = client.get("/ops/api/tieba/sync") + + assert response.status_code == 405 + assert service.sync_calls == [] From 8be828686aaa75d69210befec7674aa3088c8a85 Mon Sep 17 00:00:00 2001 From: 3aKHP <178400596+3aKHP@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:47:21 +0800 Subject: [PATCH 4/4] fix(security): bound ban tracker and review follow-ups - Cap both login trackers at 4096 entries with oldest-key eviction: the fails table previously stopped tracking new sources at capacity (a throttling vacuum for brute force), and the bans table had no cap at all; comment now matches behavior - Treat a corrupted session created_at as expired (delete + 401) instead of fail-open - Log tieba peek 502 failures with logger.exception for traceback parity with the wordcloud routes - Drop the dead app.openapi enrich monkeypatch: no HTTP entry remains since the docs endpoints were hidden, and the in-process export path is unaffected - docs: configuration.md gains the WEB_ADMIN_SESSION_MAX_AGE_HOURS row; web-admin.md and the client_ip docstring now state the trusted-proxy assumption (a public front proxy such as a CDN/LB makes the limiter key the proxy egress; never bind the admin directly to an untrusted L2 network) --- docs/admin/configuration.md | 1 + docs/admin/web-admin.md | 2 ++ .../references/docs-admin-configuration.md | 1 + .../references/docs-admin-web-admin.md | 2 ++ skills.example/self-docs/references/index.md | 2 +- src/quickquip/app/web/app.py | 4 --- src/quickquip/app/web/auth.py | 15 +++++++--- src/quickquip/app/web/client_ip.py | 5 ++++ src/quickquip/app/web/routes/tieba.py | 2 +- tests/unit/web/test_auth_security.py | 30 +++++++++++++++++-- 10 files changed, 52 insertions(+), 12 deletions(-) diff --git a/docs/admin/configuration.md b/docs/admin/configuration.md index 3b8d4850..e3c25709 100644 --- a/docs/admin/configuration.md +++ b/docs/admin/configuration.md @@ -89,6 +89,7 @@ MCP server 的普通字段支持 `${ENV_VAR}` 与 `${ENV_VAR:-default}`;认证 | `WEB_ADMIN_HOST` | 监听地址 | `127.0.0.1` | | `WEB_ADMIN_PORT` | 监听端口 | `5104` | | `WEB_ADMIN_SESSION_TTL_HOURS` | session 有效期(小时) | `168` | +| `WEB_ADMIN_SESSION_MAX_AGE_HOURS` | session 绝对寿命(小时),自创建起超过后必须重新登录 | `168` | | `WEB_ADMIN_COOKIE_SECURE` | 是否下发 Secure cookie:`auto` / `true` / `false` | `auto` | ### Docker 构建相关 diff --git a/docs/admin/web-admin.md b/docs/admin/web-admin.md index 919ebdb7..203f476c 100644 --- a/docs/admin/web-admin.md +++ b/docs/admin/web-admin.md @@ -113,6 +113,8 @@ proxy_set_header X-Forwarded-For $remote_addr; `X-Forwarded-For` 使用 `$remote_addr` 覆盖语义:后端从直连对端为内网地址的请求中取 XFF 最右一个非内网跳作为真实客户端 IP,登录限流按该 IP 计数。`$proxy_add_x_forwarded_for` 会把客户端自带的 XFF 保留在链上,覆盖语义可以让链上只有 nginx 实际看到的对端地址。 +该算法假定可信代理全是内网地址。如果 nginx 前面还有公网前置代理(CDN、云负载均衡),取到的将是前置代理的出口地址而非终端客户端:限流与封禁按出口地址共享计数,误伤同出口的其他管理员;这种拓扑下应在最外层代理做限流。反过来,不要把后台直接绑定到不信任的二层网络(源码安装时显式设置 `WEB_ADMIN_HOST=0.0.0.0` 且无代理)——此时对端是内网地址而路径上没有代理追加真实来源,XFF 完全由客户端控制,限流键可被任意伪造。 + 外层 `auth_basic` 本身没有速率限制,建议在 nginx 站点中为 `/ops/` 位置补充请求限流,例如: ```nginx diff --git a/skills.example/self-docs/references/docs-admin-configuration.md b/skills.example/self-docs/references/docs-admin-configuration.md index 738783f0..24a32a4e 100644 --- a/skills.example/self-docs/references/docs-admin-configuration.md +++ b/skills.example/self-docs/references/docs-admin-configuration.md @@ -91,6 +91,7 @@ MCP server 的普通字段支持 `${ENV_VAR}` 与 `${ENV_VAR:-default}`;认证 | `WEB_ADMIN_HOST` | 监听地址 | `127.0.0.1` | | `WEB_ADMIN_PORT` | 监听端口 | `5104` | | `WEB_ADMIN_SESSION_TTL_HOURS` | session 有效期(小时) | `168` | +| `WEB_ADMIN_SESSION_MAX_AGE_HOURS` | session 绝对寿命(小时),自创建起超过后必须重新登录 | `168` | | `WEB_ADMIN_COOKIE_SECURE` | 是否下发 Secure cookie:`auto` / `true` / `false` | `auto` | ### Docker 构建相关 diff --git a/skills.example/self-docs/references/docs-admin-web-admin.md b/skills.example/self-docs/references/docs-admin-web-admin.md index 12eaceff..8a5d80fa 100644 --- a/skills.example/self-docs/references/docs-admin-web-admin.md +++ b/skills.example/self-docs/references/docs-admin-web-admin.md @@ -115,6 +115,8 @@ proxy_set_header X-Forwarded-For $remote_addr; `X-Forwarded-For` 使用 `$remote_addr` 覆盖语义:后端从直连对端为内网地址的请求中取 XFF 最右一个非内网跳作为真实客户端 IP,登录限流按该 IP 计数。`$proxy_add_x_forwarded_for` 会把客户端自带的 XFF 保留在链上,覆盖语义可以让链上只有 nginx 实际看到的对端地址。 +该算法假定可信代理全是内网地址。如果 nginx 前面还有公网前置代理(CDN、云负载均衡),取到的将是前置代理的出口地址而非终端客户端:限流与封禁按出口地址共享计数,误伤同出口的其他管理员;这种拓扑下应在最外层代理做限流。反过来,不要把后台直接绑定到不信任的二层网络(源码安装时显式设置 `WEB_ADMIN_HOST=0.0.0.0` 且无代理)——此时对端是内网地址而路径上没有代理追加真实来源,XFF 完全由客户端控制,限流键可被任意伪造。 + 外层 `auth_basic` 本身没有速率限制,建议在 nginx 站点中为 `/ops/` 位置补充请求限流,例如: ```nginx diff --git a/skills.example/self-docs/references/index.md b/skills.example/self-docs/references/index.md index 32856174..39600901 100644 --- a/skills.example/self-docs/references/index.md +++ b/skills.example/self-docs/references/index.md @@ -59,7 +59,7 @@ - `docs/dev/branching.md` → `references/docs-dev-branching.md` — QuickQuip 开发工作流与发布流程 | 关键词:dev、main、style.md、testing.md、documenting.md、architecture.md、versioning.md、feat、fix、refactor - `docs/dev/documenting.md` → `references/docs-dev-documenting.md` — QuickQuip 公开文档规范 | 关键词:docs/、README.md、docs/user/、docs/admin/、docs/dev/、CHANGELOG.md、ROADMAP.md、../index.md、prod.example/、.env - `docs/dev/game-framework.md` → `references/docs-dev-game-framework.md` — 游戏框架开发者指南 | 关键词:BaseGame、aliases、src/quickquip/app/message_pipeline.py、GameResult、finished、group_id、OrderedDict[str, Session]、src/quickquip/adapters/nonebot/commands.py、command_parts/、_ensure_schema() -- `docs/dev/llm-configuration-api.md` → `references/docs-dev-llm-configuration-api.md` — LLM 配置后端接口 | 关键词:data/llm_config.db、/ops/api/llm-configuration、quickquip_admin_session、Origin、Referer、/openapi.json、format_version、。顶层字段为、style_profiles、pricing +- `docs/dev/llm-configuration-api.md` → `references/docs-dev-llm-configuration-api.md` — LLM 配置后端接口 | 关键词:data/llm_config.db、/ops/api/llm-configuration、quickquip_admin_session、Origin、Referer、在进程内构造应用导出,随约束或路由变更同步提交;运行中的管理 API 不提供、/redoc、/openapi.json、format_version、。顶层字段为 - `docs/dev/llm-configuration-frontend.md` → `references/docs-dev-llm-configuration-frontend.md` — LLM 配置管理前端交付契约 | 关键词:/ops/#/llm-config、draft_revision、published_revision、loaded_revision、draft_version、published_version、loaded_version、{saved_at, note}、PUT /note、202 - `docs/dev/llm-module.md` → `references/docs-dev-llm-module.md` — QuickQuip LLM 模块说明 | 关键词:/search、LLM_TRACE_FLAG_FILE、data/llm_trace.db、run_tool_call_loop、trigger_kind、group_direct、private_direct、group_passive、interrupted、request_cancelled - `docs/dev/mcp-integration.md` → `references/docs-dev-mcp-integration.md` — QuickQuip MCP 集成说明 | 关键词:ToolRegistry、data/llm_config.db、settings.mcp、config.toml、search_web、tavily_search、tavily_crawl、tavily_research、docker、/var/run/docker.sock diff --git a/src/quickquip/app/web/app.py b/src/quickquip/app/web/app.py index 62a0aef9..bfb3abeb 100644 --- a/src/quickquip/app/web/app.py +++ b/src/quickquip/app/web/app.py @@ -161,10 +161,6 @@ def create_app() -> FastAPI: llm_configuration.router, prefix="/ops/api", dependencies=auth.protected_dependencies ) - from quickquip.app.web.llm_configuration_schema import enrich_openapi - original_openapi = app.openapi - app.openapi = lambda: enrich_openapi(original_openapi()) - _register_root_redirect(app) if _DIST.exists(): diff --git a/src/quickquip/app/web/auth.py b/src/quickquip/app/web/auth.py index 82a631f0..492feb49 100644 --- a/src/quickquip/app/web/auth.py +++ b/src/quickquip/app/web/auth.py @@ -25,8 +25,9 @@ # --- 登录速率限制(H2)--- # 每 IP 最多 5 次失败 / 60 秒;超过后封禁 300 秒。 -# 两个字典有容量上限并周期性清扫过期键,伪造来源无法让它们无界增长; -# 容量占满时放弃追踪新键(口令校验与既有封禁不受影响)。 +# 两个字典均有容量上限并周期性清扫过期键,伪造来源无法让它们无界增长; +# 容量占满时逐出最旧的键为新键腾位——限流跟踪不会因满载出现真空, +# 被逐出的代价只是对应来源的失败计数或封禁提前结束。 _LOGIN_WINDOW = 60 # 秒 _LOGIN_MAX_FAILS = 5 _LOGIN_BAN_SECONDS = 300 @@ -79,11 +80,15 @@ def _record_login_failure(ip: str) -> None: fails = _login_fails.get(ip) if fails is None: if len(_login_fails) >= _LOGIN_TRACK_LIMIT: - return + # 逐出最旧键而非拒收新来源:拒收会让满载后的新来源完全不受限流 + # (dict 保持插入序,首键即最旧) + _login_fails.pop(next(iter(_login_fails))) fails = [] _login_fails[ip] = fails fails.append(now) if len(fails) >= _LOGIN_MAX_FAILS: + if len(_login_bans) >= _LOGIN_TRACK_LIMIT: + _login_bans.pop(next(iter(_login_bans))) _login_bans[ip] = now + _LOGIN_BAN_SECONDS _login_fails.pop(ip, None) logger.warning("web admin login: ip=%s banned for %ds", ip, _LOGIN_BAN_SECONDS) @@ -219,7 +224,9 @@ def _load_fresh_session(request: Request, response: Response) -> tuple[dict[str, try: created_at = datetime.fromisoformat(session["created_at"]) except (KeyError, ValueError): - created_at = now + # 存储行损坏时按失效处理,不为来源不明的会话重置绝对寿命 + store.delete_session(session_id) + raise HTTPException(status_code=401, detail="session invalid, please login again") from None absolute_deadline = created_at + _session_max_age() if absolute_deadline <= now: store.delete_session(session_id) diff --git a/src/quickquip/app/web/client_ip.py b/src/quickquip/app/web/client_ip.py index ef472b45..fc7a37ec 100644 --- a/src/quickquip/app/web/client_ip.py +++ b/src/quickquip/app/web/client_ip.py @@ -7,6 +7,11 @@ the first non-private entry from the right is the real client even when the client itself prepended a spoofed XFF value. A public direct peer means no trusted proxy is in the path, and XFF is ignored entirely. + +The heuristic assumes every trusted proxy is a private/loopback hop. With a +public front proxy (CDN, cloud LB) between nginx and the client, the result +is that proxy's egress address, and rate limiting is shared per egress; see +docs/admin/web-admin.md for the deployment guidance. """ import ipaddress diff --git a/src/quickquip/app/web/routes/tieba.py b/src/quickquip/app/web/routes/tieba.py index 41cd512e..42cd0800 100644 --- a/src/quickquip/app/web/routes/tieba.py +++ b/src/quickquip/app/web/routes/tieba.py @@ -193,7 +193,7 @@ async def peek_tieba(forum: str = Query(..., max_length=32)): # 登录态失效的文案由领域异常自带,可直接回显给管理员 raise HTTPException(status_code=409, detail=str(exc)) from exc except TiebaServiceError as exc: - logger.warning("tieba peek failed for forum %s: %s", forum, exc) + logger.exception("tieba peek failed for forum %s", forum) raise HTTPException(status_code=502, detail="贴吧访问失败,请查看服务日志") from exc if thread is None: raise HTTPException(status_code=404, detail="thread not found") diff --git a/tests/unit/web/test_auth_security.py b/tests/unit/web/test_auth_security.py index 6c0865ac..d00f88f4 100644 --- a/tests/unit/web/test_auth_security.py +++ b/tests/unit/web/test_auth_security.py @@ -28,12 +28,24 @@ def test_ban_still_triggers_below_cap(self): auth._check_login_rate_limit("1.2.3.4") assert getattr(exc_info.value, "status_code", None) == 429 - def test_new_key_not_tracked_when_fails_dict_at_cap(self, monkeypatch): + def test_oldest_key_evicted_when_fails_dict_at_cap(self, monkeypatch): monkeypatch.setattr(auth, "_LOGIN_TRACK_LIMIT", 2) auth._record_login_failure("1.1.1.1") auth._record_login_failure("2.2.2.2") auth._record_login_failure("3.3.3.3") - assert set(auth._login_fails) == {"1.1.1.1", "2.2.2.2"} + # 表满时逐出最旧的键:新来源始终被追踪,不存在满载后的限流真空 + assert set(auth._login_fails) == {"2.2.2.2", "3.3.3.3"} + + def test_oldest_ban_evicted_when_bans_dict_at_cap(self, monkeypatch): + monkeypatch.setattr(auth, "_LOGIN_TRACK_LIMIT", 1) + for _ in range(auth._LOGIN_MAX_FAILS): + auth._record_login_failure("1.1.1.1") + assert "1.1.1.1" in auth._login_bans + for _ in range(auth._LOGIN_MAX_FAILS): + auth._record_login_failure("2.2.2.2") + assert "2.2.2.2" in auth._login_bans + assert "1.1.1.1" not in auth._login_bans + assert len(auth._login_bans) == 1 def test_sweep_removes_stale_fails_and_expired_bans(self, monkeypatch): monkeypatch.setattr(auth, "_LOGIN_SWEEP_INTERVAL", 0) @@ -111,6 +123,20 @@ def test_sliding_renewal_is_capped_by_absolute_deadline(self, client, monkeypatc remaining = (expires_at - datetime.now(timezone.utc)).total_seconds() assert 0 < remaining < 3600 + def test_corrupted_created_at_is_treated_as_expired(self, client): + session_id = _login(client) + key = hashlib.sha256(session_id.encode("utf-8")).hexdigest() + with sqlite3.connect(client.sessions.path) as conn: + conn.execute( + "UPDATE admin_sessions SET created_at = ? WHERE session_id = ?", + ("not-a-date", key), + ) + + response = client.get("/ops/api/auth/me") + + assert response.status_code == 401 + assert client.sessions.get_session(session_id) is None + class TestSessionIdHashing: def test_db_stores_only_sha256_of_session_id(self, client):