feat: 新增备份恢复与公网加固

- 新增备份管理页与 API:在线快照、自动周期备份、按份数清理、下载、一键恢复(恢复前自动兜底)
- 新增 /profile/export,普通用户可导出本人全部数据(不含 Cookie 明文)
- 修复 X-Forwarded-For 可伪造导致三道 IP 防线失效,统一走 client_ip() 取客户端地址
- 取消 admin123 硬编码默认口令,留空则生成随机初始口令并仅打印一次
- .dockerignore 排除 backups/ 并加构建期断言,防止密钥随镜像分发
- 新增会话版本号,改密/停用/删除及恢复备份后其他会话立即失效
- 新增容器资源上限、采集跨度硬顶 31 天、重操作最小间隔与并发 409
- 新增访问日志、HSTS 条件下发、口令黑名单、验证码抗模板匹配、instance.json 0600
- 版本号升至 1.4.0,同步更新 README、SECURITY、.env.example 与 compose 配置
这个提交包含在:
2026-09-18 08:46:34 +08:00
父节点 1bf961f6b3
当前提交 3751dffef9
共修改 30 个文件,包含 3150 行新增和 241 行删除
+216 -9
查看文件
@@ -10,14 +10,26 @@
`db.get_secret(conn, "cookie", uid)`;`db.get_settings()` 会把凭证置空,
所以「顺手把配置回传给前端」这类代码不可能把它带出去。
2. **禁用/删除账号立刻失效**:`current_user()` 每个请求回查一次
users.status,不靠会话过期来兜底(默认会话 12 小时,太久了)。
3. **失败限速按「来源 IP」和「用户名」双维度计数**:只按 IP 挡不住
「一批肉鸡轮流撞同一个账号」,只按用户名又会让一个 IP 无限注册。
users.status 与 users.session_ver,不靠会话过期来兜底。
3. **限速按「来源 IP」和「用户名」双维度计数**,但两者的**强度刻意不同**:
IP 维度是真锁,用户名维度只是秒级退避。原因见 `user_soft_left` 的注释。
来源 IP 的取法(对外提供服务时最容易出错的一处)
------------------------------------------------
`client_ip()` 是**全站唯一**的取客户端地址入口。默认只信 `remote_addr`:
反向代理若用 `$proxy_add_x_forwarded_for`(追加语义),请求头里第 0 段就是
攻击者自己填的字符串,采信它等于把验证码限速、注册配额、登录锁定三道
IP 防线一起交出去。只有显式设置 `WB_TRUST_PROXY=1`(且你的代理会重写该头)
时才读 X-Forwarded-For,而且**取最右侧**那一段 —— 最右边是离我们最近的
一跳,由我们自己的代理写入,客户端伪造不了。
"""
import functools
import hmac
import ipaddress
import logging
import re
import secrets
import sqlite3
import time
from flask import (current_app, flash, g, jsonify, redirect, render_template,
@@ -25,10 +37,50 @@ from flask import (current_app, flash, g, jsonify, redirect, render_template,
from . import captcha, config, db
log = logging.getLogger("wb.security")
# ---------------- 来源 IP(全站唯一入口) ----------------
def _valid_ip(s):
try:
ipaddress.ip_address(s)
return True
except ValueError:
return False
def client_ip():
"""当前请求的客户端地址。
* `WB_TRUST_PROXY` 未开启(默认):直接用 `remote_addr`。
直接暴露公网、或前面挂了「追加型」代理时,XFF 的第一段是攻击者可控的。
* 已开启:读 X-Forwarded-For 并**取最右侧**合法 IP。
最右侧是最近一跳(我们自己的代理)写入的,客户端加不进去。
多级代理(CDN -> nginx)需要按跳数取值,本项目不支持 —— 那样只能靠
代理侧传 `X-Real-IP` 之类的可信头,不要在这里猜。
"""
remote = (request.remote_addr or "").strip()
if not config.TRUST_PROXY:
return remote
raw = request.headers.get("X-Forwarded-For", "")
if not raw:
return remote
for part in reversed([p.strip() for p in raw.split(",")]):
# 去掉 IPv6 的 [..]:port 写法
cand = part.strip("[]").split("%")[0]
if cand.count(":") == 1 and cand.rsplit(":", 1)[1].isdigit():
cand = cand.rsplit(":", 1)[0] # IPv4:port
if _valid_ip(cand):
return cand
# 头里全是垃圾 -> 退回 remote_addr,而不是把一个伪造值当 IP 用
log.warning("X-Forwarded-For 里没有合法 IP,已回退 remote_addr:%r", raw[:120])
return remote
# ---------------- 失败计数(内存即可) ----------------
# 单进程部署(见 README 的部署约束),重启清零可接受;
# 真正的防爆破靠「验证码 + 双维度限速」两道,而不是靠计数持久化。
_fails = {} # key -> [count, last_ts]
_tries = {} # ip -> [count, window_started_at](含成功,只看总量)
_FAILS_MAX_KEYS = 8192 # 上限,防止海量来源把字典撑爆
_FAILS_TTL = 3600 # 超过 1 小时无更新即清理
@@ -88,9 +140,76 @@ def fail_count(key):
return c[0] if c else 0
# ---- IP 维度:真锁(来源地址现在已经不可伪造,锁得住真正的攻击者)----
def ip_lock_left(ip):
c = _fails.get(_ip_key(ip))
if not c or c[0] < config.MAX_LOGIN_FAILS:
return 0
return max(0, int(config.LOGIN_LOCK_MINUTES * 60 - (time.time() - c[1])))
# ---- 用户名维度:只做秒级退避,**不做长锁** ----
def user_soft_left(username):
"""知道一个用户名就能把它锁死 10 分钟 —— 那本身就是攻击。
对外提供服务后,管理员用户名是公开信息(导航里就写着),
如果按用户名施加长锁,任何人只要连打 5 次错误口令,就能让真正的管理员
十分钟进不去。所以这里改成「递增且有封顶」的秒级等待:
超过阈值后第 1 次 1s、第 2 次 2s …… 封顶 60s。
真正的重锁只按来源 IP 施加(`ip_lock_left`)—— 那才是攻击者无法伪造、
也无法甩锅给别人的东西。命中阈值的同时,攻击者自己的 IP 也在计数,
所以这种「软」不会让爆破变得可行。
"""
c = _fails.get(_user_key(username))
if not c or c[0] < config.USER_SOFT_THRESHOLD:
return 0
delay = min(config.USER_SOFT_CAP_SECONDS,
1 << min(10, c[0] - config.USER_SOFT_THRESHOLD))
return max(0, int(delay - (time.time() - c[1])))
# ---- 单 IP 登录尝试总量(含成功):挡住「慢慢撞、不触发失败阈值」----
def note_try(ip):
now = time.time()
if len(_tries) > _FAILS_MAX_KEYS:
_tries.clear()
cur = _tries.get(ip)
if cur is None or now - cur[1] > config.LOGIN_ATTEMPTS_WINDOW:
_tries[ip] = [1, now]
return 1
cur[0] += 1
return cur[0]
def try_window_left(ip):
cur = _tries.get(ip)
if not cur or cur[0] < config.LOGIN_ATTEMPTS_PER_IP:
return 0
return max(0, int(config.LOGIN_ATTEMPTS_WINDOW - (time.time() - cur[1])))
def auth_locked(ip, username=""):
"""返回还需锁定的秒数(0 = 未锁)。IP 与用户名任一超限即锁。"""
return max(lock_left(_ip_key(ip)), lock_left(_user_key(username)))
"""还需等待的秒数(0 = 放行)。"""
return max(ip_lock_left(ip), try_window_left(ip), user_soft_left(username))
def auth_block_reason(ip, username=""):
"""被挡的原因码:ip / rate / user / 空。用于给出**准确**的提示语。"""
if ip_lock_left(ip):
return "ip"
if try_window_left(ip):
return "rate"
if user_soft_left(username):
return "user"
return ""
def auth_block_message(reason, seconds):
if reason == "ip":
return "该来源登录失败次数过多,请 %d 秒后再试" % seconds
if reason == "rate":
return "登录请求过于频繁,请 %d 秒后再试" % seconds
return "尝试过于频繁,请 %d 秒后再试" % seconds
def note_auth_fail(ip, username=""):
@@ -100,11 +219,40 @@ def note_auth_fail(ip, username=""):
def clear_auth_fail(ip, username=""):
"""登录成功后清掉两个维度的计数。
用户名维度必须在成功时清零:否则「攻击者打了几次 + 主人自己登一次」
之后,主人仍会被自己之前那几次的退避拖住。
"""
clear_fail(_ip_key(ip))
if username:
clear_fail(_user_key(username))
# ---------------- 通用动作限速(重操作保护) ----------------
# 采集 / 导出 / 整库整理这类动作的代价远高于普通页面请求:
# 一次 /api/collect 会让服务端对云端发起成百上千次请求,并独占一个线程;
# 一次导出会把整张表扫一遍。对外提供服务时必须有刹车,
# 否则**一个注册账号**就能把实例的线程与带宽吃干净。
_actions = {} # "name:uid" -> next_allowed_ts
def action_allowed(key, min_interval):
"""返回 (是否允许, 还需等待秒数)。允许时会把下次可执行时间推后。"""
now = time.time()
if len(_actions) > _FAILS_MAX_KEYS:
_actions.clear()
nxt = _actions.get(key) or 0
if now < nxt:
return False, int(nxt - now) + 1
_actions[key] = now + max(0, int(min_interval))
return True, 0
def action_wait_left(key):
return max(0, int((_actions.get(key) or 0) - time.time()))
# ---------------- 口令 / 用户名策略 ----------------
_USERNAME_RE = re.compile(config.USERNAME_RE)
@@ -149,6 +297,10 @@ def password_problem(new, new2=None, username=None):
(r"[a-z]", r"[A-Z]", r"[0-9]", r"[^A-Za-z0-9]"))
if classes < 2:
return "密码需包含大写字母、小写字母、数字、符号中的至少两类"
# 黑名单只挡「字典头几页」,命中即拒。只在设置/修改口令时校验,
# 登录路径不校验 —— 所以不会把用老口令的人挡在门外。
if new.lower() in config.WEAK_PASSWORDS:
return "这个密码在常见弱口令字典里,请换一个"
if new2 is not None and new2 != new:
return "两次输入的新密码不一致"
if username and new.lower() == str(username).lower():
@@ -182,8 +334,12 @@ def login_ok(conn, username, password):
def current_user():
"""当前登录用户(dict)或 None。
每个请求回查一次 users 表:账号被停用/删除后**立刻**失效,
而不是等 12 小时会话自然过期。结果缓存在 flask.g 里,一次请求只查一次。
每个请求回查一次 users 表,两道校验:
* `status` —— 账号被停用/删除后**立刻**失效,而不是等 12 小时会话过期
* `session_ver` —— 改密码 / 管理员重置 / 停用后,签发时的那一版会话
立即作废。少了它,「怀疑会话泄漏了所以改密码」就是个假的安心动作:
旧会话照样有效到 12 小时之后。
结果缓存在 flask.g 里,一次请求只查一次。
"""
if "wb_user" in g:
return g.wb_user
@@ -192,12 +348,24 @@ def current_user():
if uid:
try:
row = db.get_db().execute(
"SELECT id,username,display_name,is_admin,status FROM users WHERE id=?",
(uid,)).fetchone()
"SELECT id,username,display_name,is_admin,status,session_ver"
" FROM users WHERE id=?", (uid,)).fetchone()
except sqlite3.OperationalError as e:
# users 表结构与代码不一致(典型场景:升级到新版本后没跑 init_db
# 就先把 Web 起起来了,老库还没有 session_ver 这一列)。
# 这里必须**大声**报错。若和下面「无请求上下文」一起被静默吞掉,
# 症状会变成「全站所有人被踢下线、日志里什么都没有」——
# 界面上只看到「登录成功又立刻跳回登录页」,极难归因。
log.error("会话校验失败:users 表结构与代码不一致(%s);"
"请执行 `python manage.py init` 完成迁移", e)
row = None
except Exception: # noqa: BLE001 (无请求上下文等)
row = None
if row is None or (row["status"] or "active") != "active":
session.clear()
elif session.get("sv", 0) != db.session_ver_of(row):
# 老会话没有 sv 字段时按 0 处理,这样升级本身不会把所有人踢下线
session.clear()
else:
user = {"id": row["id"], "username": row["username"],
"display_name": row["display_name"] or row["username"],
@@ -219,12 +387,16 @@ def login_session(user):
`session.clear()` 是必须的:既清掉前一次的残留,
也顺带换掉 CSRF token 与验证码 id —— 这正是防「会话固定」的做法。
`sv` 记下签发时的 users.session_ver:之后一旦账号改密 / 被重置 / 被停用,
这一版会话会在下一个请求就被判为过期。
"""
session.clear()
session["uid"] = user["id"]
session["uname"] = user["username"]
session["dname"] = user["display_name"] or user["username"]
session["adm"] = 1 if user["is_admin"] else 0
session["sv"] = db.session_ver_of(user)
session["login_at"] = db.now_str()
session.permanent = True
@@ -401,19 +573,54 @@ def apply_security_headers(resp):
resp.headers.setdefault("Referrer-Policy", "same-origin")
resp.headers.setdefault("Content-Security-Policy", CSP)
resp.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
# HSTS 只在「确认这个部署跑在 HTTPS 上」时才发:在纯 HTTP 部署上发它,
# 浏览器会把该域名的 http 访问强行升级,表现成「打开就白屏」。
# 判据是管理员显式打开了 COOKIE_SECURE 或 FORCE_HTTPS。
if config.COOKIE_SECURE or config.FORCE_HTTPS:
resp.headers.setdefault("Strict-Transport-Security",
"max-age=31536000; includeSubDomains")
if request.path.startswith("/api/") or request.path.startswith("/captcha"):
resp.headers.setdefault("Cache-Control", "no-store")
return resp
def needs_https_redirect():
"""当前请求是否该被跳到 https(仅在显式开启 WB_FORCE_HTTPS 时才判断)。"""
if not config.FORCE_HTTPS or request.is_secure:
return False
# 反代终止 TLS 时,Flask 看到的是 http;靠 X-Forwarded-Proto 还原真实协议。
# 这个头只在「你已经决定信任代理」的前提下才有意义,所以与 TRUST_PROXY 绑定。
if config.TRUST_PROXY and (request.headers.get("X-Forwarded-Proto") or "").lower() == "https":
return False
if request.method not in ("GET", "HEAD"):
return False # 不重定向 POST:会丢请求体,行为难以预期
return True
def _access_log(resp, started):
if not config.ACCESS_LOG:
return resp
path = request.path
if path.startswith("/static/") or path == "/captcha.png":
return resp # 静态资源与验证码出图会把日志刷满
log.info("%s %s -> %s %dms ip=%s", request.method, path, resp.status_code,
int((time.time() - started) * 1000), client_ip())
return resp
def init_app(app):
app.jinja_env.globals["csrf_token"] = csrf_token
app.jinja_env.globals["current_user"] = current_user
@app.before_request
def _guard():
g.wb_t0 = time.time()
if needs_https_redirect():
url = request.url.replace("http://", "https://", 1)
return redirect(url, code=301)
return check_csrf()
@app.after_request
def _headers(resp):
_access_log(resp, getattr(g, "wb_t0", time.time()))
return apply_security_headers(resp)