security: 环境变量密钥、密码加盐哈希、CSRF 防护

- 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 请求,前端自动携带
This commit is contained in:
2026-08-08 21:29:39 +08:00
parent afeb0902a1
commit 838886e043
6 changed files with 111 additions and 10 deletions
+12
View File
@@ -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=请填写随机密钥
+4
View File
@@ -36,5 +36,9 @@ desktop.ini
*.tmp *.tmp
*.bak *.bak
# 密钥/环境变量(真实 .env 含 token/密钥,绝不提交)
.env
.env.local
bin/adb/* bin/adb/*
+24 -2
View File
@@ -4,12 +4,34 @@
放各自 tasks/xxx.py 顶部,保持每个任务自包含、互不干扰。 放各自 tasks/xxx.py 顶部,保持每个任务自包含、互不干扰。
改 STF 地址/token、adb 路径、web 端口都改这里。 改 STF 地址/token、adb 路径、web 端口都改这里。
安全:STF_TOKEN / WEB_SECRET_KEY 建议用环境变量注入(生产不要提交到 git)。
支持 `.env` 文件(项目根目录),也可直接 `export STF_TOKEN=xxx`。
""" """
import os 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 配置 ==================
STF_URL = "http://192.168.20.220:7100" STF_URL = _env("STF_URL", "http://192.168.20.220:7100")
STF_TOKEN = "9024544b571647d3a6b09dab5dbfcb28bfd950f5b659400394e44e7217b7051b" STF_TOKEN = _env("STF_TOKEN", "9024544b571647d3a6b09dab5dbfcb28bfd950f5b659400394e44e7217b7051b")
# ================== adb 路径 ================== # ================== adb 路径 ==================
# adb 二进制及依赖统一放在 bin/adb/ 下 # adb 二进制及依赖统一放在 bin/adb/ 下
+16 -3
View File
@@ -22,6 +22,7 @@ from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin from flask_login import UserMixin
from sqlalchemy import event, text from sqlalchemy import event, text
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from werkzeug.security import generate_password_hash, check_password_hash
from core.logger import get_logger from core.logger import get_logger
@@ -47,14 +48,26 @@ class User(UserMixin, db.Model):
"""后台用户。""" """后台用户。"""
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False) 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) is_admin = db.Column(db.Boolean, default=True)
def set_password(self, password): 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): 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): def __repr__(self):
return f"<User {self.username}>" return f"<User {self.username}>"
+19 -3
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));} function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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){ async function apiGet(url){
const r=await fetch(url); const r=await fetch(url);
if(r.status===401){window.location='/login';return null;} if(r.status===401){window.location='/login';return null;}
return r.json(); return r.json();
} }
async function apiPost(url,body){ 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;} if(r.status===401){window.location='/login';return null;}
return r.json(); return r.json();
} }
async function apiPut(url,body){ 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;} if(r.status===401){window.location='/login';return null;}
return r.json(); return r.json();
} }
@@ -204,7 +218,7 @@ async function refreshShot(){
} }
} }
async function apiDelete(url){ 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;} if(r.status===401){window.location='/login';return null;}
return r.json(); return r.json();
} }
@@ -1856,6 +1870,7 @@ async function uploadApk(input){
// 用 XMLHttpRequest 才能拿到上传进度(fetch 不支持上传进度事件) // 用 XMLHttpRequest 才能拿到上传进度(fetch 不支持上传进度事件)
const xhr=new XMLHttpRequest(); const xhr=new XMLHttpRequest();
xhr.open('POST','/api/apks/upload'); xhr.open('POST','/api/apks/upload');
if(_csrfToken)xhr.setRequestHeader('X-CSRF-Token',_csrfToken);
xhr.upload.onprogress=function(e){ xhr.upload.onprogress=function(e){
if(e.lengthComputable){ if(e.lengthComputable){
const pct=Math.round(e.loaded*100/e.total); const pct=Math.round(e.loaded*100/e.total);
@@ -2053,4 +2068,5 @@ async function scanForeground(){
} }
// ================== 初始化 ================== // ================== 初始化 ==================
initCsrf(); // 获取 CSRF token(非 GET 请求需携带)
showTab('monitor'); showTab('monitor');
+36 -2
View File
@@ -17,10 +17,12 @@ import sys
import time import time
import uuid import uuid
import atexit import atexit
import secrets
import subprocess import subprocess
from datetime import datetime 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, from flask_login import (LoginManager, login_user, logout_user,
login_required, current_user) login_required, current_user)
@@ -36,7 +38,9 @@ from tasks import list_task_types, get_task_class
_log = get_logger("web") _log = get_logger("web")
app = Flask(__name__) 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( app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(
os.path.dirname(os.path.abspath(__file__)), "data", "users.db") os.path.dirname(os.path.abspath(__file__)), "data", "users.db")
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
@@ -56,6 +60,35 @@ def load_user(user_id):
return User.query.get(int(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("/") @app.route("/")
@login_required @login_required
@@ -75,6 +108,7 @@ def login():
user = User.query.filter_by(username=username).first() user = User.query.filter_by(username=username).first()
if user and user.check_password(password): if user and user.check_password(password):
login_user(user) login_user(user)
_csrf_token() # 建立 CSRF token,前端通过 /api/csrf 获取
_log.info(f"用户 {username} 登录") _log.info(f"用户 {username} 登录")
return redirect(request.args.get("next") or url_for("index")) return redirect(request.args.get("next") or url_for("index"))
return render_template("admin/login.html", error="用户名或密码错误") return render_template("admin/login.html", error="用户名或密码错误")