From 838886e043cb5d94039c856eecad34161f972930 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Sat, 8 Aug 2026 21:29:39 +0800 Subject: [PATCH] =?UTF-8?q?security:=20=E7=8E=AF=E5=A2=83=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E5=AF=86=E9=92=A5=E3=80=81=E5=AF=86=E7=A0=81=E5=8A=A0?= =?UTF-8?q?=E7=9B=90=E5=93=88=E5=B8=8C=E3=80=81CSRF=20=E9=98=B2=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.py/web_server.py 支持环境变量注入 STF_TOKEN/WEB_SECRET_KEY,新增 .env 加载和 .env.example;.env 加入 gitignore - 用户密码从裸 SHA-256 改为 werkzeug 加盐哈希(scrypt),旧哈希登录时自动升级 - CSRF:session token + X-CSRF-Token 请求头校验非 GET 请求,前端自动携带 --- .env.example | 12 ++++++++++++ .gitignore | 4 ++++ config.py | 26 ++++++++++++++++++++++++-- core/models.py | 19 ++++++++++++++++--- static/admin/monitor.js | 22 +++++++++++++++++++--- web_server.py | 38 ++++++++++++++++++++++++++++++++++++-- 6 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d758ac3 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# 环境变量示例:复制为 .env 并按需修改(.env 不进 git) +# 生产环境建议用环境变量/密钥管理注入,不要把 token 写进 git。 + +# STF 服务地址 +STF_URL=http://192.168.20.220:7100 + +# STF API Token(安全敏感,生产必须设置) +STF_TOKEN=请填写你的STF_token + +# Web 会话密钥(安全敏感,生产必须设置为随机长字符串) +# 生成:python -c "import secrets; print(secrets.token_hex(32))" +WEB_SECRET_KEY=请填写随机密钥 diff --git a/.gitignore b/.gitignore index efce3cd..df26f57 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,9 @@ desktop.ini *.tmp *.bak +# 密钥/环境变量(真实 .env 含 token/密钥,绝不提交) +.env +.env.local + bin/adb/* \ No newline at end of file diff --git a/config.py b/config.py index 513b5cd..b3be224 100644 --- a/config.py +++ b/config.py @@ -4,12 +4,34 @@ 放各自 tasks/xxx.py 顶部,保持每个任务自包含、互不干扰。 改 STF 地址/token、adb 路径、web 端口都改这里。 + +安全:STF_TOKEN / WEB_SECRET_KEY 建议用环境变量注入(生产不要提交到 git)。 +支持 `.env` 文件(项目根目录),也可直接 `export STF_TOKEN=xxx`。 """ import os +# 可选加载 .env 文件(不存在则忽略) +_env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") +if os.path.exists(_env_path): + try: + with open(_env_path, encoding="utf-8") as _f: + for _line in _f: + _line = _line.strip() + if _line and not _line.startswith("#") and "=" in _line: + _k, _v = _line.split("=", 1) + os.environ.setdefault(_k.strip(), _v.strip()) + except OSError: + pass + + +def _env(key, default): + """读环境变量,未设置则返回默认值。""" + return os.environ.get(key, default) + + # ================== STF 配置 ================== -STF_URL = "http://192.168.20.220:7100" -STF_TOKEN = "9024544b571647d3a6b09dab5dbfcb28bfd950f5b659400394e44e7217b7051b" +STF_URL = _env("STF_URL", "http://192.168.20.220:7100") +STF_TOKEN = _env("STF_TOKEN", "9024544b571647d3a6b09dab5dbfcb28bfd950f5b659400394e44e7217b7051b") # ================== adb 路径 ================== # adb 二进制及依赖统一放在 bin/adb/ 下 diff --git a/core/models.py b/core/models.py index 4ef2947..d3d793d 100644 --- a/core/models.py +++ b/core/models.py @@ -22,6 +22,7 @@ from flask_sqlalchemy import SQLAlchemy from flask_login import UserMixin from sqlalchemy import event, text from sqlalchemy.engine import Engine +from werkzeug.security import generate_password_hash, check_password_hash from core.logger import get_logger @@ -47,14 +48,26 @@ class User(UserMixin, db.Model): """后台用户。""" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) - password_hash = db.Column(db.String(120), nullable=False) + password_hash = db.Column(db.String(255), nullable=False) is_admin = db.Column(db.Boolean, default=True) def set_password(self, password): - self.password_hash = hashlib.sha256(password.encode()).hexdigest() + self.password_hash = generate_password_hash(password) def check_password(self, password): - return self.password_hash == hashlib.sha256(password.encode()).hexdigest() + """校验密码。兼容旧 SHA-256 哈希(匹配则自动升级为新哈希)。""" + ph = self.password_hash or "" + if ph and not ph.startswith(("pbkdf2:", "scrypt:")): + # 旧版裸 SHA-256:比对通过则升级为加盐哈希 + if hashlib.sha256(password.encode()).hexdigest() == ph: + self.set_password(password) + try: + db.session.commit() + except Exception: + pass + return True + return False + return check_password_hash(ph, password) def __repr__(self): return f"" diff --git a/static/admin/monitor.js b/static/admin/monitor.js index 738b6a1..9a09846 100644 --- a/static/admin/monitor.js +++ b/static/admin/monitor.js @@ -6,18 +6,32 @@ const APP_NAMES={'com.ss.android.ugc.aweme':'抖音','com.ss.android.ugc.aweme.l function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));} +// ===== CSRF ===== +let _csrfToken=''; +// 页面加载后调用:获取 CSRF token,非 GET 请求需携带 +async function initCsrf(){ + try{ + const r=await fetch('/api/csrf'); + const d=await r.json(); + _csrfToken=d.token||''; + }catch(e){} +} +function _csrfHeaders(){ + return _csrfToken?{'X-CSRF-Token':_csrfToken}:{}; +} + async function apiGet(url){ const r=await fetch(url); if(r.status===401){window.location='/login';return null;} return r.json(); } async function apiPost(url,body){ - const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):'{}'}); + const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json',..._csrfHeaders()},body:body?JSON.stringify(body):'{}'}); if(r.status===401){window.location='/login';return null;} return r.json(); } async function apiPut(url,body){ - const r=await fetch(url,{method:'PUT',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):'{}'}); + const r=await fetch(url,{method:'PUT',headers:{'Content-Type':'application/json',..._csrfHeaders()},body:body?JSON.stringify(body):'{}'}); if(r.status===401){window.location='/login';return null;} return r.json(); } @@ -204,7 +218,7 @@ async function refreshShot(){ } } async function apiDelete(url){ - const r=await fetch(url,{method:'DELETE'}); + const r=await fetch(url,{method:'DELETE',headers:_csrfHeaders()}); if(r.status===401){window.location='/login';return null;} return r.json(); } @@ -1856,6 +1870,7 @@ async function uploadApk(input){ // 用 XMLHttpRequest 才能拿到上传进度(fetch 不支持上传进度事件) const xhr=new XMLHttpRequest(); xhr.open('POST','/api/apks/upload'); + if(_csrfToken)xhr.setRequestHeader('X-CSRF-Token',_csrfToken); xhr.upload.onprogress=function(e){ if(e.lengthComputable){ const pct=Math.round(e.loaded*100/e.total); @@ -2053,4 +2068,5 @@ async function scanForeground(){ } // ================== 初始化 ================== +initCsrf(); // 获取 CSRF token(非 GET 请求需携带) showTab('monitor'); diff --git a/web_server.py b/web_server.py index 5cba703..045e40a 100644 --- a/web_server.py +++ b/web_server.py @@ -17,10 +17,12 @@ import sys import time import uuid import atexit +import secrets import subprocess from datetime import datetime -from flask import Flask, jsonify, request, redirect, url_for, render_template, Response, make_response +from flask import (Flask, jsonify, request, redirect, url_for, render_template, + Response, make_response, session) from flask_login import (LoginManager, login_user, logout_user, login_required, current_user) @@ -36,7 +38,9 @@ from tasks import list_task_types, get_task_class _log = get_logger("web") app = Flask(__name__) -app.config["SECRET_KEY"] = "dev-secret-key-change-in-production" +# 生产必须通过环境变量 WEB_SECRET_KEY 设置强密钥;缺省用开发密钥(不安全) +app.config["SECRET_KEY"] = os.environ.get( + "WEB_SECRET_KEY", "dev-secret-key-change-in-production") app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join( os.path.dirname(os.path.abspath(__file__)), "data", "users.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False @@ -56,6 +60,35 @@ def load_user(user_id): return User.query.get(int(user_id)) +# ================== CSRF 防护 ================== +def _csrf_token(): + """获取或生成当前会话的 CSRF token(非 GET 请求需在 X-CSRF-Token 头携带)。""" + if "csrf_token" not in session: + session["csrf_token"] = secrets.token_hex(16) + return session["csrf_token"] + + +@app.before_request +def _csrf_protect(): + """非安全方法(POST/PUT/DELETE/PATCH)校验 X-CSRF-Token 请求头。""" + if request.method not in ("POST", "PUT", "DELETE", "PATCH"): + return + # 登录表单(未登录,尚无 token)和静态文件跳过 + if request.endpoint in ("login", "static"): + return + sess = session.get("csrf_token", "") + header = request.headers.get("X-CSRF-Token", "") + if not sess or header != sess: + return jsonify({"ok": False, "error": "CSRF 校验失败"}), 403 + + +@app.route("/api/csrf") +@login_required +def api_csrf(): + """获取 CSRF token(前端非 GET 请求需携带 X-CSRF-Token 头)。""" + return jsonify({"ok": True, "token": _csrf_token()}) + + # ================== 页面路由 ================== @app.route("/") @login_required @@ -75,6 +108,7 @@ def login(): user = User.query.filter_by(username=username).first() if user and user.check_password(password): login_user(user) + _csrf_token() # 建立 CSRF token,前端通过 /api/csrf 获取 _log.info(f"用户 {username} 登录") return redirect(request.args.get("next") or url_for("index")) return render_template("admin/login.html", error="用户名或密码错误")