From 83fc6750ba2109fce85faef520cb1e8366077c07 Mon Sep 17 00:00:00 2001 From: protitude Date: Fri, 16 Jan 2026 16:59:52 -0700 Subject: [PATCH 1/2] misc: d8-2639 add turnstile middlware --- modules/access_misc/access_misc.services.yml | 8 + .../src/Services/TurnstileService.php | 425 ++++++++++++++++++ .../StackMiddleware/TurnstileMiddleware.php | 59 +++ 3 files changed, 492 insertions(+) create mode 100644 modules/access_misc/src/Services/TurnstileService.php create mode 100644 modules/access_misc/src/StackMiddleware/TurnstileMiddleware.php diff --git a/modules/access_misc/access_misc.services.yml b/modules/access_misc/access_misc.services.yml index b6859ebb..101d57b4 100644 --- a/modules/access_misc/access_misc.services.yml +++ b/modules/access_misc/access_misc.services.yml @@ -1,4 +1,12 @@ services: + access_misc.turnstile_service: + class: Drupal\access_misc\Services\TurnstileService + + access_misc.turnstile_middleware: + class: Drupal\access_misc\StackMiddleware\TurnstileMiddleware + decorates: http_kernel + arguments: ['@access_misc.turnstile_middleware.inner', '@access_misc.turnstile_service'] + access_misc.subscriber: class: '\Drupal\access_misc\EventSubscriber\Subscriber' tags: diff --git a/modules/access_misc/src/Services/TurnstileService.php b/modules/access_misc/src/Services/TurnstileService.php new file mode 100644 index 00000000..f50254a8 --- /dev/null +++ b/modules/access_misc/src/Services/TurnstileService.php @@ -0,0 +1,425 @@ +getRequestUri(); + + // Handle Turnstile verification endpoint. + if (strpos($uri, '/turnstile-verify') === 0) { + return $this->handleVerifyEndpoint($request); + } + + // Handle Turnstile challenge page. + if (strpos($uri, '/turnstile-challenge') === 0) { + return $this->serveChallengeForm($request); + } + + // Check faceted search requests. + $query = $request->query->all(); + if (isset($query['f']) && is_array($query['f']) && count($query['f']) > 0) { + return $this->checkFacetedRequest($request); + } + + return NULL; + } + + /** + * Handle the Turnstile verification endpoint. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response. + */ + protected function handleVerifyEndpoint(Request $request) { + $token = $request->query->get('token', ''); + $return_url = $request->query->get('return', '/'); + $secret_key = $this->getTurnstileSecret('TURNSTILE_SECRET_KEY'); + + // Sanitize return URL. + if (!preg_match('/^\/[a-zA-Z0-9\-\_\/\?\&\=\[\]\%\.\+\:\#\~\@\!\'\(\)\,\;\* ]*$/', $return_url)) { + $return_url = '/'; + } + + if (!empty($token) && !empty($secret_key)) { + $result = $this->verifyTurnstileToken($token, $secret_key, $request->getClientIp()); + + if ($result['success']) { + $response = new Response('', 302); + $response->headers->set('Location', $return_url); + + $cookie_value = hash('sha256', $secret_key . $request->getClientIp()); + $secure = $request->isSecure(); + $cookie = Cookie::create( + self::COOKIE_NAME, + $cookie_value, + time() + self::COOKIE_DURATION, + '/', + NULL, + $secure, + TRUE, + FALSE, + Cookie::SAMESITE_LAX + ); + $response->headers->setCookie($cookie); + + return $response; + } + } + + // Verification failed. + $challenge_url = '/turnstile-challenge?return=' . urlencode($return_url) . '&error=1'; + $response = new Response('', 302); + $response->headers->set('Location', $challenge_url); + return $response; + } + + /** + * Serve the Turnstile challenge form. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response. + */ + protected function serveChallengeForm(Request $request) { + $return_url = $request->query->get('return', '/'); + $site_key = $this->getTurnstileSecret('TURNSTILE_SITE_KEY'); + $error = $request->query->has('error') ? 'Verification failed. Please try again.' : ''; + + // Sanitize return URL. + if (!preg_match('/^\/[a-zA-Z0-9\-\_\/\?\&\=\[\]\%\.\+\:\#\~\@\!\'\(\)\,\;\* ]*$/', $return_url)) { + $return_url = '/'; + } + + // Calculate base path for "skip" link. + $base_path = strtok($return_url, '?'); + $show_skip_link = ($base_path !== $return_url); + + $html = $this->getChallengePageHtml($site_key, $return_url, $error, $show_skip_link, $base_path); + + return new Response($html, 200, ['Content-Type' => 'text/html; charset=UTF-8']); + } + + /** + * Check a faceted search request for bot protection. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return \Symfony\Component\HttpFoundation\Response|null + * A response if the request should be blocked, NULL otherwise. + */ + protected function checkFacetedRequest(Request $request) { + $query = $request->query->all(); + $facet_count = isset($query['f']) && is_array($query['f']) ? count($query['f']) : 0; + + if ($facet_count === 0) { + return NULL; + } + + $user_agent = $request->headers->get('User-Agent', ''); + + // Skip verification for logged-in users. + $is_logged_in = FALSE; + foreach ($request->cookies->all() as $cookie_name => $cookie_value) { + if (strpos($cookie_name, 'SESS') === 0 || strpos($cookie_name, 'SSESS') === 0) { + $is_logged_in = TRUE; + break; + } + } + + // Skip verification for AJAX requests. + $is_ajax = $request->query->has('_drupal_ajax') || $request->headers->has('X-Requested-With'); + + if ($is_ajax || $is_logged_in) { + return NULL; + } + + // First line of defense: block known bots. + if ($this->isKnownBot($user_agent)) { + error_log('Blocked known bot faceted request: ' . $request->getRequestUri() . ' | UA: ' . $user_agent); + return new Response('Access denied.', 403); + } + + // Second line of defense: Turnstile verification. + $turnstile_secret = $this->getTurnstileSecret('TURNSTILE_SECRET_KEY'); + + $cookie_valid = FALSE; + if ($request->cookies->has(self::COOKIE_NAME) && !empty($turnstile_secret)) { + $expected_hash = hash('sha256', $turnstile_secret . $request->getClientIp()); + $cookie_valid = hash_equals($expected_hash, $request->cookies->get(self::COOKIE_NAME)); + } + + if (!$cookie_valid && !empty($turnstile_secret)) { + $challenge_url = '/turnstile-challenge?return=' . urlencode($request->getRequestUri()); + $response = new Response('', 302); + $response->headers->set('Location', $challenge_url); + return $response; + } + + // Fallback: block multiple facets if Turnstile not configured. + if (empty($turnstile_secret) && $facet_count >= 2) { + $html = ' + + + Service Temporarily Unavailable + + +

Service Temporarily Unavailable

+

Please use fewer filters or try again later.

+

Return to the homepage

+ +'; + $response = new Response($html, 503); + $response->headers->set('Retry-After', '60'); + return $response; + } + + return NULL; + } + + /** + * Get Turnstile secrets from file or environment. + * + * @param string $name + * The secret name. + * + * @return string + * The secret value. + */ + protected function getTurnstileSecret($name) { + static $secrets = NULL; + + if ($secrets === NULL) { + $possible_paths = [ + 'sites/default/files/private/.keys/secrets.json', + __DIR__ . '/../../../../../sites/default/files/private/.keys/secrets.json', + '/files/private/.keys/secrets.json', + ]; + + $secrets = []; + foreach ($possible_paths as $path) { + if (file_exists($path)) { + $raw = file_get_contents($path); + $secrets = json_decode($raw, TRUE) ?: []; + break; + } + } + } + + if (isset($secrets[$name])) { + return $secrets[$name]; + } + + return getenv($name) ?: ''; + } + + /** + * Check if the user agent is a known bot. + * + * @param string $user_agent + * The user agent string. + * + * @return bool + * TRUE if this is a known bot. + */ + protected function isKnownBot($user_agent) { + $known_bots = [ + 'bot', 'Bot', 'BOT', 'crawler', 'Crawler', 'spider', 'Spider', + 'AhrefsBot', 'SemrushBot', 'MJ12bot', 'DotBot', 'PetalBot', 'BLEXBot', + 'YandexBot', 'Googlebot', 'bingbot', 'Baiduspider', 'Sogou', 'Exabot', + 'facebot', 'ia_archiver', 'Screaming Frog', 'python', 'Python', + 'Go-http-client', 'Java/', 'wget', 'curl', 'libwww', 'lwp-trivial', + 'httrack', 'nutch', 'msnbot', 'Discordbot', 'WhatsApp', 'Twitterbot', + 'facebookexternalhit', 'LinkedInBot', 'Slackbot', 'Telegram', 'Signal', + 'DataForSeoBot', 'SeznamBot', 'BingPreview', 'PageSpeed', 'Lighthouse', + 'Chrome-Lighthouse', 'HeadlessChrome', 'PhantomJS', 'SlimerJS', + 'CensysInspect', 'NetcraftSurveyAgent', 'masscan', 'nmap', + ]; + + foreach ($known_bots as $bot) { + if (stripos($user_agent, $bot) !== FALSE) { + return TRUE; + } + } + + return FALSE; + } + + /** + * Verify a Turnstile token with Cloudflare. + * + * @param string $token + * The Turnstile token. + * @param string $secret_key + * The secret key. + * @param string $remote_ip + * The remote IP address. + * + * @return array + * The verification result with 'success' key. + */ + protected function verifyTurnstileToken($token, $secret_key, $remote_ip) { + $ch = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify'); + curl_setopt_array($ch, [ + CURLOPT_POST => TRUE, + CURLOPT_POSTFIELDS => http_build_query([ + 'secret' => $secret_key, + 'response' => $token, + 'remoteip' => $remote_ip, + ]), + CURLOPT_RETURNTRANSFER => TRUE, + CURLOPT_TIMEOUT => 10, + ]); + + $response = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($http_code === 200) { + $result = json_decode($response, TRUE); + return $result ?: ['success' => FALSE]; + } + + return ['success' => FALSE]; + } + + /** + * Get the challenge page HTML. + * + * @param string $site_key + * The Turnstile site key. + * @param string $return_url + * The return URL. + * @param string $error + * The error message. + * @param bool $show_skip_link + * Whether to show the skip link. + * @param string $base_path + * The base path for the skip link. + * + * @return string + * The HTML content. + */ + protected function getChallengePageHtml($site_key, $return_url, $error, $show_skip_link, $base_path) { + $site_key_html = htmlspecialchars($site_key); + $return_url_json = json_encode($return_url); + $error_html = $error ? '
' . htmlspecialchars($error) . '
' : ''; + $skip_link_html = $show_skip_link ? '' : ''; + + return << + + + + + Verify You're Human - ACCESS + + + + +
+

Quick Verification

+

To help protect our site from automated traffic, please complete this quick verification.

+ $error_html + +
+ + + $skip_link_html +
+ + +HTML; + } + +} diff --git a/modules/access_misc/src/StackMiddleware/TurnstileMiddleware.php b/modules/access_misc/src/StackMiddleware/TurnstileMiddleware.php new file mode 100644 index 00000000..d7d6753d --- /dev/null +++ b/modules/access_misc/src/StackMiddleware/TurnstileMiddleware.php @@ -0,0 +1,59 @@ +httpKernel = $http_kernel; + $this->turnstileService = $turnstile_service; + } + + /** + * {@inheritdoc} + */ + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = TRUE): Response { + // Check if Turnstile protection should run. + $response = $this->turnstileService->checkRequest($request); + + if ($response !== NULL) { + return $response; + } + + // Continue with normal request handling. + return $this->httpKernel->handle($request, $type, $catch); + } + +} From 8d5468b1d6bbfd9e426555712f6156acb2d26882 Mon Sep 17 00:00:00 2001 From: protitude Date: Fri, 16 Jan 2026 17:35:02 -0700 Subject: [PATCH 2/2] misc: d8-2639 add turnstile controller --- modules/access_misc/access_misc.routing.yml | 18 +++ .../src/Controller/TurnstileController.php | 122 ++++++++++++++++++ .../src/Services/TurnstileService.php | 99 +------------- .../StackMiddleware/TurnstileMiddleware.php | 8 +- 4 files changed, 150 insertions(+), 97 deletions(-) create mode 100644 modules/access_misc/src/Controller/TurnstileController.php diff --git a/modules/access_misc/access_misc.routing.yml b/modules/access_misc/access_misc.routing.yml index 356e5332..d411053d 100644 --- a/modules/access_misc/access_misc.routing.yml +++ b/modules/access_misc/access_misc.routing.yml @@ -21,3 +21,21 @@ wvuf_api_integration.unmask: _title: 'Unmask' requirements: _user_is_masquerading: 'TRUE' +access_misc.turnstile_challenge: + path: '/turnstile-challenge' + defaults: + _controller: '\Drupal\access_misc\Controller\TurnstileController::challenge' + _title: 'Verify You''re Human' + requirements: + _access: 'TRUE' + options: + no_cache: 'TRUE' +access_misc.turnstile_verify: + path: '/turnstile-verify' + defaults: + _controller: '\Drupal\access_misc\Controller\TurnstileController::verify' + _title: '' + requirements: + _access: 'TRUE' + options: + no_cache: 'TRUE' diff --git a/modules/access_misc/src/Controller/TurnstileController.php b/modules/access_misc/src/Controller/TurnstileController.php new file mode 100644 index 00000000..64a1ba71 --- /dev/null +++ b/modules/access_misc/src/Controller/TurnstileController.php @@ -0,0 +1,122 @@ +turnstileService = $turnstile_service; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('access_misc.turnstile_service') + ); + } + + /** + * Display the Turnstile challenge form. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response. + */ + public function challenge(Request $request) { + $return_url = $request->query->get('return', '/'); + $site_key = $this->turnstileService->getTurnstileSecret('TURNSTILE_SITE_KEY'); + $error = $request->query->has('error') ? 'Verification failed. Please try again.' : ''; + + // Sanitize return URL. + if (!preg_match('/^\/[a-zA-Z0-9\-\_\/\?\&\=\[\]\%\.\+\:\#\~\@\!\'\(\)\,\;\* ]*$/', $return_url)) { + $return_url = '/'; + } + + // Calculate base path for "skip" link. + $base_path = strtok($return_url, '?'); + $show_skip_link = ($base_path !== $return_url); + + $html = $this->turnstileService->getChallengePageHtml($site_key, $return_url, $error, $show_skip_link, $base_path); + + return new Response($html, 200, ['Content-Type' => 'text/html; charset=UTF-8']); + } + + /** + * Verify a Turnstile token. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response. + */ + public function verify(Request $request) { + $token = $request->query->get('token', ''); + $return_url = $request->query->get('return', '/'); + $secret_key = $this->turnstileService->getTurnstileSecret('TURNSTILE_SECRET_KEY'); + + // Sanitize return URL. + if (!preg_match('/^\/[a-zA-Z0-9\-\_\/\?\&\=\[\]\%\.\+\:\#\~\@\!\'\(\)\,\;\* ]*$/', $return_url)) { + $return_url = '/'; + } + + if (!empty($token) && !empty($secret_key)) { + $result = $this->turnstileService->verifyTurnstileToken($token, $secret_key, $request->getClientIp()); + + if ($result['success']) { + $response = new Response('', 302); + $response->headers->set('Location', $return_url); + + $cookie_value = hash('sha256', $secret_key . $request->getClientIp()); + $secure = $request->isSecure(); + + setcookie( + TurnstileService::COOKIE_NAME, + $cookie_value, + [ + 'expires' => time() + TurnstileService::COOKIE_DURATION, + 'path' => '/', + 'secure' => $secure, + 'httponly' => TRUE, + 'samesite' => 'Lax', + ] + ); + + return $response; + } + } + + // Verification failed. + $challenge_url = '/turnstile-challenge?return=' . urlencode($return_url) . '&error=1'; + $response = new Response('', 302); + $response->headers->set('Location', $challenge_url); + return $response; + } + +} diff --git a/modules/access_misc/src/Services/TurnstileService.php b/modules/access_misc/src/Services/TurnstileService.php index f50254a8..8282787d 100644 --- a/modules/access_misc/src/Services/TurnstileService.php +++ b/modules/access_misc/src/Services/TurnstileService.php @@ -4,7 +4,6 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\Cookie; /** * Service for Turnstile bot protection. @@ -31,18 +30,6 @@ public function checkRequest(Request $request) { return NULL; } - $uri = $request->getRequestUri(); - - // Handle Turnstile verification endpoint. - if (strpos($uri, '/turnstile-verify') === 0) { - return $this->handleVerifyEndpoint($request); - } - - // Handle Turnstile challenge page. - if (strpos($uri, '/turnstile-challenge') === 0) { - return $this->serveChallengeForm($request); - } - // Check faceted search requests. $query = $request->query->all(); if (isset($query['f']) && is_array($query['f']) && count($query['f']) > 0) { @@ -52,86 +39,6 @@ public function checkRequest(Request $request) { return NULL; } - /** - * Handle the Turnstile verification endpoint. - * - * @param \Symfony\Component\HttpFoundation\Request $request - * The request object. - * - * @return \Symfony\Component\HttpFoundation\Response - * The response. - */ - protected function handleVerifyEndpoint(Request $request) { - $token = $request->query->get('token', ''); - $return_url = $request->query->get('return', '/'); - $secret_key = $this->getTurnstileSecret('TURNSTILE_SECRET_KEY'); - - // Sanitize return URL. - if (!preg_match('/^\/[a-zA-Z0-9\-\_\/\?\&\=\[\]\%\.\+\:\#\~\@\!\'\(\)\,\;\* ]*$/', $return_url)) { - $return_url = '/'; - } - - if (!empty($token) && !empty($secret_key)) { - $result = $this->verifyTurnstileToken($token, $secret_key, $request->getClientIp()); - - if ($result['success']) { - $response = new Response('', 302); - $response->headers->set('Location', $return_url); - - $cookie_value = hash('sha256', $secret_key . $request->getClientIp()); - $secure = $request->isSecure(); - $cookie = Cookie::create( - self::COOKIE_NAME, - $cookie_value, - time() + self::COOKIE_DURATION, - '/', - NULL, - $secure, - TRUE, - FALSE, - Cookie::SAMESITE_LAX - ); - $response->headers->setCookie($cookie); - - return $response; - } - } - - // Verification failed. - $challenge_url = '/turnstile-challenge?return=' . urlencode($return_url) . '&error=1'; - $response = new Response('', 302); - $response->headers->set('Location', $challenge_url); - return $response; - } - - /** - * Serve the Turnstile challenge form. - * - * @param \Symfony\Component\HttpFoundation\Request $request - * The request object. - * - * @return \Symfony\Component\HttpFoundation\Response - * The response. - */ - protected function serveChallengeForm(Request $request) { - $return_url = $request->query->get('return', '/'); - $site_key = $this->getTurnstileSecret('TURNSTILE_SITE_KEY'); - $error = $request->query->has('error') ? 'Verification failed. Please try again.' : ''; - - // Sanitize return URL. - if (!preg_match('/^\/[a-zA-Z0-9\-\_\/\?\&\=\[\]\%\.\+\:\#\~\@\!\'\(\)\,\;\* ]*$/', $return_url)) { - $return_url = '/'; - } - - // Calculate base path for "skip" link. - $base_path = strtok($return_url, '?'); - $show_skip_link = ($base_path !== $return_url); - - $html = $this->getChallengePageHtml($site_key, $return_url, $error, $show_skip_link, $base_path); - - return new Response($html, 200, ['Content-Type' => 'text/html; charset=UTF-8']); - } - /** * Check a faceted search request for bot protection. * @@ -219,7 +126,7 @@ protected function checkFacetedRequest(Request $request) { * @return string * The secret value. */ - protected function getTurnstileSecret($name) { + public function getTurnstileSecret($name) { static $secrets = NULL; if ($secrets === NULL) { @@ -291,7 +198,7 @@ protected function isKnownBot($user_agent) { * @return array * The verification result with 'success' key. */ - protected function verifyTurnstileToken($token, $secret_key, $remote_ip) { + public function verifyTurnstileToken($token, $secret_key, $remote_ip) { $ch = curl_init('https://challenges.cloudflare.com/turnstile/v0/siteverify'); curl_setopt_array($ch, [ CURLOPT_POST => TRUE, @@ -333,7 +240,7 @@ protected function verifyTurnstileToken($token, $secret_key, $remote_ip) { * @return string * The HTML content. */ - protected function getChallengePageHtml($site_key, $return_url, $error, $show_skip_link, $base_path) { + public function getChallengePageHtml($site_key, $return_url, $error, $show_skip_link, $base_path) { $site_key_html = htmlspecialchars($site_key); $return_url_json = json_encode($return_url); $error_html = $error ? '
' . htmlspecialchars($error) . '
' : ''; diff --git a/modules/access_misc/src/StackMiddleware/TurnstileMiddleware.php b/modules/access_misc/src/StackMiddleware/TurnstileMiddleware.php index d7d6753d..cd3c0be2 100644 --- a/modules/access_misc/src/StackMiddleware/TurnstileMiddleware.php +++ b/modules/access_misc/src/StackMiddleware/TurnstileMiddleware.php @@ -45,7 +45,13 @@ public function __construct(HttpKernelInterface $http_kernel, TurnstileService $ * {@inheritdoc} */ public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = TRUE): Response { - // Check if Turnstile protection should run. + // Let /turnstile-challenge and /turnstile-verify go through to controllers. + $uri = $request->getRequestUri(); + if (strpos($uri, '/turnstile-challenge') === 0 || strpos($uri, '/turnstile-verify') === 0) { + return $this->httpKernel->handle($request, $type, $catch); + } + + // Check if Turnstile protection should run for other requests. $response = $this->turnstileService->checkRequest($request); if ($response !== NULL) {