P0-A: 修复鉴权体系
- check_endpoint_auth 路径归一化(无前导斜杠),v1/v2 端点级鉴权真正生效 - ADMIN_API_ENDPOINTS 重建:修复 files 单复数、补 mirrors/alerts/prewarm/api-docs/user-password - handler check_auth 方法感知:公开端点仅 GET/HEAD,写操作默认要求认证(fail-closed) - 补全受保护端点(monitor/activity/login-logs/mirrors/alerts/prewarm/file 元数据) - _check_basic_auth/_check_token_auth 改用 hmac.compare_digest - do_POST/do_HEAD 解析路径时剥离查询串;do_HEAD 增加 is_safe_path 检查 - is_safe_path 改用 realpath 防符号链接绕过
This commit is contained in:
+72
-32
@@ -254,7 +254,7 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
return handler
|
||||
|
||||
def check_auth(self, path=None):
|
||||
"""检查认证"""
|
||||
"""检查认证(方法感知: 读操作与写操作区分对待)"""
|
||||
# 获取检查路径
|
||||
if path is not None:
|
||||
check_path = path
|
||||
@@ -263,6 +263,10 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
check_path = ''
|
||||
|
||||
# 去掉查询串(do_POST/do_HEAD 传入的路径可能带 ?query)
|
||||
if '?' in check_path:
|
||||
check_path = check_path.split('?', 1)[0]
|
||||
|
||||
# 根路径直接放行
|
||||
if not check_path or check_path == '/':
|
||||
return True
|
||||
@@ -270,10 +274,20 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
if not self.config:
|
||||
return True
|
||||
|
||||
# 公开端点(不需要认证)- 文件只读操作
|
||||
public_endpoints = [
|
||||
# 登录
|
||||
auth_type = self.config.get('auth_type', 'none')
|
||||
if auth_type == 'none':
|
||||
return True
|
||||
|
||||
method = (getattr(self, 'command', '') or 'GET').upper()
|
||||
|
||||
# 任何方法都公开的端点(登录、认证状态查询等)
|
||||
public_any = [
|
||||
'api/v2/user/login',
|
||||
'api/v2/admin/auth/verify',
|
||||
]
|
||||
|
||||
# 只读公开端点(仅 GET/HEAD 放行)
|
||||
public_get = [
|
||||
# 文件只读:列表/搜索/下载/mirror/mc
|
||||
'api/v1/files',
|
||||
'api/v1/file/',
|
||||
@@ -288,17 +302,20 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
'api/v2/health',
|
||||
'api/v2/stats/',
|
||||
'api/v2/cache/stats',
|
||||
'api/v2/cache/popular',
|
||||
'api/v2/api-docs.yaml',
|
||||
# 镜像加速源代理下载(只读)
|
||||
'api/v2/mirrors/pypi',
|
||||
'api/v2/mirrors/npm',
|
||||
'api/v2/mirrors/go',
|
||||
'api/v2/mirrors/docker',
|
||||
]
|
||||
|
||||
# 检查是否是公开端点
|
||||
for endpoint in public_endpoints:
|
||||
if check_path == endpoint or check_path.startswith(endpoint + '/'):
|
||||
return True
|
||||
|
||||
# 需要认证的端点 - 所有修改操作
|
||||
# 受保护前缀(GET/HEAD 命中也需要认证;写操作另有兜底)
|
||||
protected_endpoints = [
|
||||
# 用户操作
|
||||
'api/v2/user/password', # 改密码
|
||||
'api/v2/user/login-logs', # 登录日志(含IP)
|
||||
'api/v2/users',
|
||||
# 文件修改操作
|
||||
'api/v1/upload',
|
||||
@@ -314,27 +331,41 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
'api/v2/config',
|
||||
'api/v2/server/',
|
||||
'api/v2/cache/clean',
|
||||
'api/v2/cache/prewarm',
|
||||
'api/v2/webhooks',
|
||||
'api/v2/sync/',
|
||||
'api/v2/file/', # 文件删除/重命名
|
||||
'api/v2/file/', # 文件删除/重命名/元数据/版本/缩略图
|
||||
'api/v2/mirrors', # 镜像管理(写 settings.json)
|
||||
'api/v2/alerts',
|
||||
'api/v2/activity',
|
||||
'api/v2/monitor',
|
||||
]
|
||||
|
||||
# 检查是否是需要认证的端点
|
||||
is_protected = False
|
||||
for endpoint in protected_endpoints:
|
||||
if check_path.startswith(endpoint):
|
||||
is_protected = True
|
||||
break
|
||||
# 任何方法都公开
|
||||
for endpoint in public_any:
|
||||
if check_path == endpoint or check_path.startswith(endpoint + '/'):
|
||||
return True
|
||||
|
||||
# 如果不是受保护端点,直接放行
|
||||
if not is_protected:
|
||||
if method in ('GET', 'HEAD'):
|
||||
# 只读公开端点放行
|
||||
for endpoint in public_get:
|
||||
if check_path == endpoint or check_path.startswith(endpoint + '/'):
|
||||
return True
|
||||
# 其余 GET: 命中受保护前缀才需要认证
|
||||
for endpoint in protected_endpoints:
|
||||
if check_path.startswith(endpoint):
|
||||
return self._do_auth(auth_type)
|
||||
return True
|
||||
|
||||
# 只有受保护端点才需要认证检查
|
||||
auth_type = self.config.get('auth_type', 'none')
|
||||
if auth_type == 'none':
|
||||
return True
|
||||
elif auth_type == 'basic':
|
||||
# 写方法(POST/PUT/DELETE): API 路径一律要求认证,防止新增端点漏配
|
||||
if check_path.startswith('api/'):
|
||||
return self._do_auth(auth_type)
|
||||
|
||||
return True
|
||||
|
||||
def _do_auth(self, auth_type):
|
||||
"""执行指定类型的认证检查"""
|
||||
if auth_type == 'basic':
|
||||
return self._check_basic_auth()
|
||||
elif auth_type == 'token':
|
||||
return self._check_token_auth()
|
||||
@@ -342,6 +373,7 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def _check_basic_auth(self):
|
||||
"""检查基本认证"""
|
||||
import hmac
|
||||
auth_header = self.headers.get('Authorization')
|
||||
if not auth_header or not auth_header.startswith('Basic '):
|
||||
self.send_auth_required()
|
||||
@@ -352,8 +384,9 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
username, password = auth_decoded.split(':', 1)
|
||||
expected_user = self.config.get('auth_user', 'admin') if self.config else 'admin'
|
||||
expected_pass = self.config.get('auth_pass', 'admin123') if self.config else 'admin123'
|
||||
|
||||
if username == expected_user and password == expected_pass:
|
||||
|
||||
# 恒定时间比较,防时序攻击
|
||||
if hmac.compare_digest(username, expected_user) and hmac.compare_digest(password, expected_pass):
|
||||
return True
|
||||
else:
|
||||
self.send_auth_required()
|
||||
@@ -390,7 +423,8 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
|
||||
# 检查是否是静态 token
|
||||
expected_token = self.config.get('auth_token') if self.config else None
|
||||
if token and token == expected_token:
|
||||
import hmac
|
||||
if token and expected_token and hmac.compare_digest(token, expected_token):
|
||||
return True
|
||||
|
||||
# 验证失败
|
||||
@@ -504,7 +538,10 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
if self.api_router is None and self.config is not None:
|
||||
MirrorServerHandler.api_router = APIRouter(self.config)
|
||||
|
||||
path = unquote(self.path).lstrip('/')
|
||||
# 解析路径(去掉查询串,避免把 ?query 拼进 API 路径)
|
||||
parsed_path = urlparse(self.path)
|
||||
path = unquote(parsed_path.path).lstrip('/')
|
||||
query = parsed_path.query
|
||||
|
||||
# 调试模式输出完整路径 (debug-http)
|
||||
if self._is_debug_enabled('http'):
|
||||
@@ -516,8 +553,6 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
if path.startswith("api/"):
|
||||
parsed_path = urlparse(self.path)
|
||||
query = parsed_path.query
|
||||
self.api_router.handle_request(self, 'POST', path, query)
|
||||
else:
|
||||
self.send_error(405)
|
||||
@@ -601,11 +636,16 @@ class MirrorServerHandler(BaseHTTPRequestHandler):
|
||||
"""处理HEAD请求"""
|
||||
if not self.check_auth():
|
||||
return
|
||||
path = unquote(self.path).lstrip('/')
|
||||
# 解析路径(去掉查询串),并复用 serve_path 的安全检查防路径穿越
|
||||
parsed_path = urlparse(self.path)
|
||||
rel_path = unquote(parsed_path.path).lstrip('/')
|
||||
if self.config is None:
|
||||
self.send_error(500)
|
||||
return
|
||||
file_path = os.path.join(self.config['base_dir'], path)
|
||||
file_path = os.path.join(self.config['base_dir'], rel_path)
|
||||
if not is_safe_path(self.config['base_dir'], file_path):
|
||||
self.send_error(403, "Access denied")
|
||||
return
|
||||
if os.path.isfile(file_path):
|
||||
self.send_file_headers(file_path)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user