// +---------------------------------------------------------------------- declare(strict_types=1); namespace ywxapp\middleware; use Closure; use think\App; use think\exception\HttpException; use think\Request; use think\Response; /** * CSRF 防护中间件(会话型应用:backend / member / frontend)。 * * 两层防护(config/csrf.php): * 1. 同源校验(默认开启):POST/PUT/PATCH/DELETE 请求若携带 Origin 或 Referer, * 且与当前站点 Host 不一致 → 403。正常同源表单 / 同源 AJAX 完全不受影响, * 未携带来源头的老客户端放行(宽松模式,不破坏现有功能)。 * 2. Token 校验(strict 模式,CSRF_STRICT=true 开启):校验 __token__ 参数 * 或 X-CSRF-TOKEN 请求头与 Session 中令牌一致。启用后模板需注入 * `{:token_field()}` 或 `{$Request.token}`,JS 需携带 X-CSRF-TOKEN 头。 * * 注册位置:app/backend/middleware.php、app/member/middleware.php、 * app/frontend/middleware.php。api 应用走 JWT 鉴权,不注册本中间件。 */ class VerifyCsrfToken { /** 安全方法(无副作用,跳过校验) */ protected array $safeMethods = ['GET', 'HEAD', 'OPTIONS']; public function __construct(protected App $app) { } public function handle(Request $request, Closure $next): Response { if (in_array(strtoupper($request->method()), $this->safeMethods, true)) { return $next($request); } $appName = $this->app->http->getName() ?: 'index'; $apps = (array) $this->app->config->get('csrf.session_apps', ['backend', 'member', 'frontend', 'index']); if (!in_array($appName, $apps, true)) { return $next($request); } // 豁免路径(支付异步回调、第三方 webhook 等,匹配当前应用内 pathinfo) $path = $request->pathinfo(); foreach ((array) $this->app->config->get('csrf.exempt', []) as $pattern) { if (is_string($pattern) && $pattern !== '' && preg_match($pattern, $path)) { return $next($request); } } // 第一层:同源校验 if (!$this->sameOrigin($request)) { throw new HttpException(403, 'Cross-site request detected (CSRF protection)'); } // 第二层:strict 模式强校验令牌 if ($this->app->config->get('csrf.strict', false)) { $token = (string) ($request->param('__token__', '') ?: $request->header('x-csrf-token', '')); $sessionToken = (string) $request->session()->get('__token__', ''); if ($token === '' || $sessionToken === '' || !hash_equals($sessionToken, $token)) { throw new HttpException(403, 'CSRF token mismatch'); } } return $next($request); } /** * 同源校验:Origin / Referer 任一存在且 Host 不匹配即拒绝。 */ protected function sameOrigin(Request $request): bool { foreach (['origin', 'referer'] as $key) { $url = trim((string) $request->header($key, '')); if ($url === '' || stripos($url, '://') === false) { continue; } $host = (string) (parse_url($url, PHP_URL_HOST) ?: ''); if ($host === '') { continue; } $port = parse_url($url, PHP_URL_PORT); $origin = strtolower($host . ($port ? ':' . $port : '')); if ($origin !== strtolower((string) $request->host(true))) { return false; } } return true; } }