P0-C: 安全修复
- 会话文件移出 web 根(data/ 目录),auth_secret 启动自动生成并持久化(0600) - cookie 签名改 HMAC-SHA256 + compare_digest;会话表加锁 + 原子写 - serve_path 增加敏感文件黑名单(纵深防御) - PyPI: 移除 ?url= 任意 URL 回退(SSRF),限制 scheme,缓存键防路径穿越 - v2 文件元数据/版本/缩略图端点全部加 is_safe_path;缩略图尺寸与像素上限 - api-docs/generate 限写 docs 目录;user/password 强制旧密码;GET /api/v2/config 脱敏 - 目录列表/错误页 HTML 转义(防存储型 XSS);Content-Disposition 文件名清洗 - 信号处理改优雅退出(移除 os._exit);启动时默认凭据安全警告
This commit is contained in:
+2
-12
@@ -90,18 +90,8 @@ class AdminAPI:
|
|||||||
def _handle_sessions(self, handler, method, parts):
|
def _handle_sessions(self, handler, method, parts):
|
||||||
"""处理会话管理"""
|
"""处理会话管理"""
|
||||||
if method == 'GET':
|
if method == 'GET':
|
||||||
# 列出活跃会话
|
# 列出活跃会话(auth_manager 内部加锁遍历)
|
||||||
sessions = []
|
sessions = self.auth_manager.list_sessions()
|
||||||
for session_id, session in self.auth_manager.sessions.items():
|
|
||||||
if time.time() < session.expires_at:
|
|
||||||
sessions.append({
|
|
||||||
"session_id": session.session_id,
|
|
||||||
"user_id": session.user_id,
|
|
||||||
"level": session.level,
|
|
||||||
"created_at": session.created_at,
|
|
||||||
"expires_at": session.expires_at,
|
|
||||||
"last_activity": session.last_activity
|
|
||||||
})
|
|
||||||
|
|
||||||
handler.send_json_response({
|
handler.send_json_response({
|
||||||
"sessions": sessions,
|
"sessions": sessions,
|
||||||
|
|||||||
+123
-61
@@ -38,6 +38,7 @@ class APIAuthManager:
|
|||||||
def __init__(self, config: dict = None):
|
def __init__(self, config: dict = None):
|
||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
self.sessions: Dict[str, AuthSession] = {}
|
self.sessions: Dict[str, AuthSession] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
# 确定基础目录(用于保存会话文件)
|
# 确定基础目录(用于保存会话文件)
|
||||||
base_dir = config.get('base_dir', '.') if config else '.'
|
base_dir = config.get('base_dir', '.') if config else '.'
|
||||||
@@ -48,9 +49,20 @@ class APIAuthManager:
|
|||||||
elif base_dir == '.':
|
elif base_dir == '.':
|
||||||
base_dir = os.getcwd()
|
base_dir = os.getcwd()
|
||||||
|
|
||||||
|
# 数据目录:默认放到 base_dir 同级 data/ 下,
|
||||||
|
# 避免 auth_sessions.json 落入 web 静态根目录被公开下载
|
||||||
|
data_dir = config.get('data_dir') if config else None
|
||||||
|
if not data_dir:
|
||||||
|
data_dir = os.path.join(os.path.dirname(os.path.abspath(base_dir)), 'data')
|
||||||
|
try:
|
||||||
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
data_dir = base_dir
|
||||||
|
self.data_dir = data_dir
|
||||||
|
|
||||||
# 会话文件路径
|
# 会话文件路径
|
||||||
sessions_filename = config.get('auth_sessions_file', 'auth_sessions.json') if config else 'auth_sessions.json'
|
sessions_filename = config.get('auth_sessions_file', 'auth_sessions.json') if config else 'auth_sessions.json'
|
||||||
self.sessions_file = os.path.join(base_dir, sessions_filename)
|
self.sessions_file = os.path.join(data_dir, sessions_filename)
|
||||||
|
|
||||||
# 会话超时时间
|
# 会话超时时间
|
||||||
self.session_timeout = config.get('auth_session_timeout', 3600) if config else 3600
|
self.session_timeout = config.get('auth_session_timeout', 3600) if config else 3600
|
||||||
@@ -63,9 +75,33 @@ class APIAuthManager:
|
|||||||
self.ip_whitelist = config.get('ip_whitelist', []) if config else []
|
self.ip_whitelist = config.get('ip_whitelist', []) if config else []
|
||||||
self.ip_whitelist_enabled = config.get('ip_whitelist_enabled', False) if config else False
|
self.ip_whitelist_enabled = config.get('ip_whitelist_enabled', False) if config else False
|
||||||
|
|
||||||
|
# Cookie 签名密钥:优先用配置;缺失则从文件读取或生成并持久化
|
||||||
|
self.auth_secret = self._load_or_create_secret(config)
|
||||||
|
|
||||||
# 加载已保存的会话
|
# 加载已保存的会话
|
||||||
self._load_sessions()
|
self._load_sessions()
|
||||||
|
|
||||||
|
def _load_or_create_secret(self, config) -> str:
|
||||||
|
"""获取或生成 auth_secret(持久化到数据目录,避免默认密钥公开可伪造)"""
|
||||||
|
secret = config.get('auth_secret') if config else None
|
||||||
|
if secret:
|
||||||
|
return secret
|
||||||
|
|
||||||
|
secret_file = os.path.join(self.data_dir, 'auth_secret.key')
|
||||||
|
try:
|
||||||
|
if os.path.exists(secret_file):
|
||||||
|
with open(secret_file, 'r', encoding='utf-8') as f:
|
||||||
|
secret = f.read().strip()
|
||||||
|
if secret:
|
||||||
|
return secret
|
||||||
|
secret = secrets.token_hex(32)
|
||||||
|
with open(secret_file, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(secret)
|
||||||
|
os.chmod(secret_file, 0o600)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"警告: 持久化 auth_secret 失败: {e}")
|
||||||
|
return secret
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def db(self):
|
def db(self):
|
||||||
"""动态获取数据库实例"""
|
"""动态获取数据库实例"""
|
||||||
@@ -182,6 +218,7 @@ class APIAuthManager:
|
|||||||
|
|
||||||
def validate_cookie(self, cookie_value: str, client_ip: str = None) -> Optional[dict]:
|
def validate_cookie(self, cookie_value: str, client_ip: str = None) -> Optional[dict]:
|
||||||
"""验证认证Cookie"""
|
"""验证认证Cookie"""
|
||||||
|
import hmac
|
||||||
if not cookie_value:
|
if not cookie_value:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -191,50 +228,52 @@ class APIAuthManager:
|
|||||||
|
|
||||||
session_id, timestamp, signature = parts
|
session_id, timestamp, signature = parts
|
||||||
|
|
||||||
session = self.sessions.get(session_id)
|
with self._lock:
|
||||||
if not session:
|
session = self.sessions.get(session_id)
|
||||||
return None
|
if not session:
|
||||||
|
return None
|
||||||
|
|
||||||
if time.time() > session.expires_at:
|
if time.time() > session.expires_at:
|
||||||
del self.sessions[session_id]
|
del self.sessions[session_id]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
expected_sig = self._generate_signature(session_id, timestamp, session.user_id)
|
expected_sig = self._generate_signature(session_id, timestamp, session.user_id)
|
||||||
if signature != expected_sig:
|
if not hmac.compare_digest(signature, expected_sig):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
session.last_activity = time.time()
|
session.last_activity = time.time()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"valid": True,
|
"valid": True,
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"user_id": session.user_id,
|
"user_id": session.user_id,
|
||||||
"level": session.level,
|
"level": session.level,
|
||||||
"permissions": session.permissions
|
"permissions": session.permissions
|
||||||
}
|
}
|
||||||
|
|
||||||
def validate_session_id(self, session_id: str) -> Optional[dict]:
|
def validate_session_id(self, session_id: str) -> Optional[dict]:
|
||||||
"""验证会话ID"""
|
"""验证会话ID"""
|
||||||
if not session_id:
|
if not session_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
session = self.sessions.get(session_id)
|
with self._lock:
|
||||||
if not session:
|
session = self.sessions.get(session_id)
|
||||||
return None
|
if not session:
|
||||||
|
return None
|
||||||
|
|
||||||
if time.time() > session.expires_at:
|
if time.time() > session.expires_at:
|
||||||
del self.sessions[session_id]
|
del self.sessions[session_id]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
session.last_activity = time.time()
|
session.last_activity = time.time()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"valid": True,
|
"valid": True,
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"user_id": session.user_id,
|
"user_id": session.user_id,
|
||||||
"level": session.level,
|
"level": session.level,
|
||||||
"permissions": session.permissions
|
"permissions": session.permissions
|
||||||
}
|
}
|
||||||
|
|
||||||
# === 请求验证 ===
|
# === 请求验证 ===
|
||||||
|
|
||||||
@@ -355,8 +394,9 @@ class APIAuthManager:
|
|||||||
permissions=permissions or ['*']
|
permissions=permissions or ['*']
|
||||||
)
|
)
|
||||||
|
|
||||||
self.sessions[session_id] = session
|
with self._lock:
|
||||||
self._save_sessions()
|
self.sessions[session_id] = session
|
||||||
|
self._save_sessions()
|
||||||
|
|
||||||
signature = self._generate_signature(session_id, timestamp, user_id)
|
signature = self._generate_signature(session_id, timestamp, user_id)
|
||||||
cookie_value = f"{session_id}.{timestamp}.{signature}"
|
cookie_value = f"{session_id}.{timestamp}.{signature}"
|
||||||
@@ -371,19 +411,37 @@ class APIAuthManager:
|
|||||||
|
|
||||||
def destroy_session(self, session_id: str) -> bool:
|
def destroy_session(self, session_id: str) -> bool:
|
||||||
"""销毁会话"""
|
"""销毁会话"""
|
||||||
if session_id in self.sessions:
|
with self._lock:
|
||||||
del self.sessions[session_id]
|
if session_id in self.sessions:
|
||||||
self._save_sessions()
|
del self.sessions[session_id]
|
||||||
return True
|
self._save_sessions()
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def list_sessions(self) -> List[dict]:
|
||||||
|
"""列出活跃会话(加锁遍历,供管理接口使用)"""
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
sessions = []
|
||||||
|
for session_id, session in self.sessions.items():
|
||||||
|
if now < session.expires_at:
|
||||||
|
sessions.append({
|
||||||
|
"session_id": session.session_id,
|
||||||
|
"user_id": session.user_id,
|
||||||
|
"level": session.level,
|
||||||
|
"created_at": session.created_at,
|
||||||
|
"expires_at": session.expires_at,
|
||||||
|
"last_activity": session.last_activity
|
||||||
|
})
|
||||||
|
return sessions
|
||||||
|
|
||||||
# === 内部方法 ===
|
# === 内部方法 ===
|
||||||
|
|
||||||
def _generate_signature(self, session_id: str, timestamp: str, user_id: str) -> str:
|
def _generate_signature(self, session_id: str, timestamp: str, user_id: str) -> str:
|
||||||
"""生成签名"""
|
"""生成签名(HMAC-SHA256,使用持久化的 auth_secret)"""
|
||||||
secret = self.config.get('auth_secret', 'default_secret_change_me')
|
import hmac
|
||||||
data = f"{session_id}.{timestamp}.{user_id}.{secret}"
|
data = f"{session_id}.{timestamp}.{user_id}".encode('utf-8')
|
||||||
return hashlib.sha256(data.encode()).hexdigest()[:32]
|
return hmac.new(self.auth_secret.encode('utf-8'), data, hashlib.sha256).hexdigest()[:32]
|
||||||
|
|
||||||
def _load_sessions(self):
|
def _load_sessions(self):
|
||||||
"""加载会话"""
|
"""加载会话"""
|
||||||
@@ -409,31 +467,35 @@ class APIAuthManager:
|
|||||||
print(f"加载会话失败: {e}")
|
print(f"加载会话失败: {e}")
|
||||||
|
|
||||||
def _save_sessions(self):
|
def _save_sessions(self):
|
||||||
"""保存会话"""
|
"""保存会话(原子写: 临时文件 + os.replace)"""
|
||||||
data = []
|
with self._lock:
|
||||||
for session in self.sessions.values():
|
data = []
|
||||||
data.append({
|
for session in self.sessions.values():
|
||||||
"session_id": session.session_id,
|
data.append({
|
||||||
"user_id": session.user_id,
|
"session_id": session.session_id,
|
||||||
"level": session.level,
|
"user_id": session.user_id,
|
||||||
"created_at": session.created_at,
|
"level": session.level,
|
||||||
"expires_at": session.expires_at,
|
"created_at": session.created_at,
|
||||||
"last_activity": session.last_activity,
|
"expires_at": session.expires_at,
|
||||||
"permissions": session.permissions
|
"last_activity": session.last_activity,
|
||||||
})
|
"permissions": session.permissions
|
||||||
|
})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self.sessions_file, 'w', encoding='utf-8') as f:
|
tmp_file = self.sessions_file + '.tmp'
|
||||||
|
with open(tmp_file, 'w', encoding='utf-8') as f:
|
||||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
except Exception:
|
os.replace(tmp_file, self.sessions_file)
|
||||||
pass
|
except Exception as e:
|
||||||
|
print(f"警告: 保存会话失败: {e}")
|
||||||
|
|
||||||
def get_stats(self) -> dict:
|
def get_stats(self) -> dict:
|
||||||
"""获取认证统计"""
|
"""获取认证统计"""
|
||||||
active_sessions = sum(
|
with self._lock:
|
||||||
1 for s in self.sessions.values()
|
active_sessions = sum(
|
||||||
if time.time() < s.expires_at
|
1 for s in self.sessions.values()
|
||||||
)
|
if time.time() < s.expires_at
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"active_sessions": active_sessions,
|
"active_sessions": active_sessions,
|
||||||
|
|||||||
+1511
-1487
File diff suppressed because it is too large
Load Diff
@@ -36,10 +36,9 @@ from core.optimization import (
|
|||||||
|
|
||||||
|
|
||||||
def signal_handler(signum, _frame):
|
def signal_handler(signum, _frame):
|
||||||
"""处理退出信号"""
|
"""处理退出信号(优雅退出:触发 KeyboardInterrupt 走正常关闭流程)"""
|
||||||
print(f"\n收到信号 {signum},正在关闭服务器...")
|
print(f"\n收到信号 {signum},正在优雅关闭服务器...")
|
||||||
import os
|
raise KeyboardInterrupt
|
||||||
os._exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_arguments():
|
def parse_arguments():
|
||||||
@@ -587,6 +586,21 @@ def main():
|
|||||||
# 设置服务器启动时间(用于计算运行时间)
|
# 设置服务器启动时间(用于计算运行时间)
|
||||||
config['start_time'] = time.time()
|
config['start_time'] = time.time()
|
||||||
|
|
||||||
|
# 默认凭据警告
|
||||||
|
try:
|
||||||
|
if config.get('auth_type') == 'basic' and config.get('auth_pass') in (None, '', 'admin123'):
|
||||||
|
print("\n" + "!" * 60)
|
||||||
|
print("! 安全警告: 正在使用默认/空认证密码 (admin/admin123)")
|
||||||
|
print("! 请立即修改 settings.json 中的 auth_pass 或使用 --auth-pass 指定")
|
||||||
|
print("!" * 60)
|
||||||
|
if config.get('auth_type') == 'token' and config.get('auth_token') in (None, '', 'admin_token_123456'):
|
||||||
|
print("\n" + "!" * 60)
|
||||||
|
print("! 安全警告: 正在使用默认/空认证 token")
|
||||||
|
print("! 请立即修改 settings.json 中的 auth_token 或使用 --auth-token 指定")
|
||||||
|
print("!" * 60)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# 创建并启动服务器
|
# 创建并启动服务器
|
||||||
try:
|
try:
|
||||||
server = MirrorServer(config)
|
server = MirrorServer(config)
|
||||||
@@ -595,6 +609,10 @@ def main():
|
|||||||
else:
|
else:
|
||||||
print("服务器启动失败")
|
print("服务器启动失败")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
# 信号处理已触发优雅关闭(server.stop + atexit cleanup 已执行)
|
||||||
|
print("\n服务器已正常退出")
|
||||||
|
sys.exit(0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"错误: {e}")
|
print(f"错误: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|||||||
+808
-788
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user