Spaces:
Running
Running
| """扫描器拦截中间件 | |
| 针对 Hugging Face Spaces 公网部署场景:每天都会被自动化扫描器盯上, | |
| 日志里会出现大量 `.env`、`.git/config`、`/actuator/env`、`/proc/self/environ`、 | |
| `/wp-config.php` 等敏感文件探测请求。 | |
| 策略: | |
| 1. 路径命中扫描特征(敏感文件/调试接口/框架特定路径)→ 立即拉黑该 IP 5 分钟 | |
| 2. 已被拉黑的 IP 的所有请求 → 直接返回 403(不再走下游路由,节省资源) | |
| 设计要点: | |
| - 内存存储黑名单(HF Space 重启会清空,但短期拦截有效) | |
| - 仅按 IP 拉黑,不涉及 token/access_key(合法用户带 token 不受影响) | |
| - 白名单路径永远不拉黑:/、/health、/v1/*、/admin/*、/u/* 等业务路径 | |
| - 不拦截 OPTIONS 预检(CORS 需要) | |
| - 不拦截 HF Spaces 的健康检查(10.x 内网 IP) | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import time | |
| from typing import Awaitable, Callable | |
| from starlette.middleware.base import BaseHTTPMiddleware | |
| from starlette.requests import Request | |
| from starlette.responses import JSONResponse, Response | |
| logger = logging.getLogger(__name__) | |
| # 黑名单 TTL(秒):被拉黑后 5 分钟内所有请求被拒绝 | |
| _BLACKLIST_TTL = 300 | |
| # 命中即拉黑的扫描特征路径前缀/子串 | |
| # 这些路径在合法业务中绝不会出现,命中即确定是扫描器 | |
| # NM3 修复: | |
| # - 删除与合法路径冲突的条目(/admin/config 可能误判 /admin/configs/*) | |
| # - .env 类改为以 /.env 开头匹配,避免误命中 /v1/xtc/environment 这类合法路径 | |
| # - 整体匹配仍用子串,但 dispatch 中先 check 白名单优先放行 | |
| _SCANNER_PATH_PATTERNS = [ | |
| # 敏感配置文件(必须以 / 开头,避免误命中合法路径片段) | |
| "/.env", "/.env.local", "/.env.production", "/.env.development", | |
| "/.env.backup", "/.env.bak", "/.env.old", "/.env.example", "/.env.swp", | |
| "config.php.bak", "wp-config.php", "wp-config.php.bak", | |
| "settings.php.bak", "configuration.php", | |
| ".streamlit/secrets.toml", ".streamlit/config.toml", | |
| "_stcore/host-config", "_stcore/health", | |
| # Git/SVN 泄露 | |
| ".git/config", ".git/HEAD", ".git/index", | |
| ".svn/entries", ".hg/store", | |
| # 路径遍历 | |
| "../proc/self/environ", "..%252f", "..%2f", "/proc/self/environ", | |
| "/proc/self/cmdline", "/proc/self/status", | |
| # 框架调试/监控接口 | |
| "/actuator/env", "/actuator/configprops", "/actuator/health", | |
| "/actuator/mappings", "/actuator/beans", | |
| "/__debug__", "/_debug", "/debug/vars", "/debug/pprof", | |
| "/metrics", "/prometheus", | |
| "/api-docs", "/swagger-ui.html", "/swagger.json", | |
| "/redoc", "/swagger", | |
| # GraphQL(除非业务用,否则视为扫描) | |
| "/graphql", "/api/graphql", | |
| # PHP/Java/ASP 调试接口 | |
| "/phpinfo.php", "/__phpinfo", | |
| "/elmah.axd", "/trace.axd", | |
| "/telescope", "/horizon", "/_profiler", | |
| # Java/Mongo/Redis 等端口探测 | |
| "/server-status", "/server-info", | |
| "/internal/config", "/internal/debug", | |
| # 各种配置探测(注意:避免与 /admin/api/config 等合法路径冲突) | |
| "/config.json", "/config.yaml", "/config.yml", "/config.py", | |
| "/configuration", "/settings.json", | |
| "/api/keys", "/api/v1/keys", "/api/config", "/api/v1/config", | |
| "/api/settings", "/api/env", "/api/v1/env", | |
| "/api/credentials", "/api/secrets", | |
| "/api/v1/models", # 扫描器常用的 OpenAI 兼容接口探测(我们的在 /v1/models) | |
| # 其他常见扫描路径 | |
| "/backup/.env", "/backup/config.json", | |
| "/.aws/credentials", "/.ssh/id_rsa", | |
| # 注:原 "/admin/config" 已移除——会与 /admin/configs/* 等合法路径冲突。 | |
| # /admin/api/* 走 _require_admin 鉴权,无需 scanner_block 拦截。 | |
| ] | |
| # 白名单路径前缀:即使路径命中扫描特征也永远放行 | |
| # 这些是合法业务路径,扫描器偶尔也会请求但绝不能误判 | |
| # NM3:dispatch 中必须先 check 白名单,否则死代码会导致误拦合法路径 | |
| _WHITELIST_PREFIXES = ( | |
| "/", # 根路径 | |
| "/health", # 健康检查 | |
| "/v1/", # OpenAI 兼容 API | |
| "/admin", # 后台(带鉴权) | |
| "/u/", # 用户文件 API | |
| "/uapi/", # 备用 | |
| "/favicon.ico", | |
| "/robots.txt", | |
| ) | |
| # 内网 IP 前缀:HF Spaces 的健康检查来自这些 IP,永远不拉黑 | |
| _INTERNAL_IP_PREFIXES = ("10.", "172.", "192.168.", "127.") | |
| # 内存黑名单:{ip: expire_ts} | |
| _ip_blacklist: dict[str, float] = {} | |
| def _get_client_ip(request: Request) -> str: | |
| """提取真实客户端 IP(尊重 X-Forwarded-For / X-Real-IP)。 | |
| NL1:行为与 rate_limit_store.get_client_ip 保持一致, | |
| 避免不同中间件记录的 IP 不一致导致日志关联困难。 | |
| 保留独立实现避免 scanner_block_middleware 反向依赖 services 包。 | |
| """ | |
| xff = request.headers.get("x-forwarded-for") | |
| if xff: | |
| # 取第一个(最外层客户端) | |
| return xff.split(",")[0].strip() | |
| x_real_ip = request.headers.get("x-real-ip") | |
| if x_real_ip: | |
| return x_real_ip.strip() | |
| if request.client: | |
| return request.client.host | |
| return "" | |
| def _is_internal_ip(ip: str) -> bool: | |
| if not ip: | |
| return False | |
| return any(ip.startswith(p) for p in _INTERNAL_IP_PREFIXES) | |
| def _is_scanner_path(path: str) -> bool: | |
| """路径是否命中扫描特征""" | |
| if not path: | |
| return False | |
| # 路径小写化便于匹配 | |
| lower = path.lower() | |
| for pat in _SCANNER_PATH_PATTERNS: | |
| if pat in lower: | |
| return True | |
| return False | |
| def _is_whitelisted_path(path: str) -> bool: | |
| if not path: | |
| return True | |
| # 精确匹配 / 和白名单前缀 | |
| if path == "/": | |
| return True | |
| for p in _WHITELIST_PREFIXES: | |
| if path.startswith(p): | |
| return True | |
| return False | |
| def _blacklist_ip(ip: str, reason: str = "") -> None: | |
| if not ip or _is_internal_ip(ip): | |
| return | |
| expire = time.time() + _BLACKLIST_TTL | |
| _ip_blacklist[ip] = expire | |
| logger.warning("[scanner_block] IP %s blacklisted for %ds (%s)", ip, _BLACKLIST_TTL, reason) | |
| def _is_blacklisted(ip: str) -> bool: | |
| if not ip: | |
| return False | |
| expire = _ip_blacklist.get(ip) | |
| if expire is None: | |
| return False | |
| if time.time() > expire: | |
| # 过期,自动移除 | |
| _ip_blacklist.pop(ip, None) | |
| return False | |
| return True | |
| def _cleanup_blacklist() -> None: | |
| """清理过期黑名单条目(每次检查时调用,避免内存泄漏)""" | |
| now = time.time() | |
| expired = [ip for ip, exp in _ip_blacklist.items() if now > exp] | |
| for ip in expired: | |
| _ip_blacklist.pop(ip, None) | |
| class ScannerBlockMiddleware(BaseHTTPMiddleware): | |
| """扫描器拦截中间件 | |
| 部署在最外层(CORS 之前),优先拦截明显扫描行为。 | |
| """ | |
| async def dispatch( | |
| self, | |
| request: Request, | |
| call_next: Callable[[Request], Awaitable[Response]], | |
| ) -> Response: | |
| # OPTIONS 预检直接放行(CORS 需要) | |
| if request.method == "OPTIONS": | |
| return await call_next(request) | |
| client_ip = _get_client_ip(request) | |
| path = request.url.path | |
| # 内网 IP 永远不拦(HF Spaces 健康检查来自 10.x) | |
| if _is_internal_ip(client_ip): | |
| return await call_next(request) | |
| # NM3:白名单路径永远放行,且必须在扫描特征检测之前 | |
| # 原实现 _is_whitelisted_path 是死代码从未被调用,导致 /admin/api/config | |
| # 等合法路径若被加入 _SCANNER_PATH_PATTERNS 会误拦 IP 5 分钟 | |
| if _is_whitelisted_path(path): | |
| return await call_next(request) | |
| # 1. 已被拉黑的 IP:所有请求直接 403 | |
| if _is_blacklisted(client_ip): | |
| _cleanup_blacklist() | |
| return JSONResponse( | |
| status_code=403, | |
| content={ | |
| "ok": False, | |
| "error": { | |
| "code": "forbidden", | |
| "message": "Access denied", | |
| "status": 403, | |
| } | |
| }, | |
| ) | |
| # 2. 路径命中扫描特征 → 立即拉黑该 IP | |
| if _is_scanner_path(path): | |
| _blacklist_ip(client_ip, reason=f"path={path}") | |
| logger.warning( | |
| "[scanner_block] blocked scan attempt ip=%s path=%s method=%s", | |
| client_ip, path[:200], request.method, | |
| ) | |
| return JSONResponse( | |
| status_code=403, | |
| content={ | |
| "ok": False, | |
| "error": { | |
| "code": "forbidden", | |
| "message": "Access denied", | |
| "status": 403, | |
| } | |
| }, | |
| ) | |
| # 3. 正常请求继续 | |
| return await call_next(request) | |