captcha.php
1 <? 2 3 require_once "lib/rpc.php"; 4 require_once 'lib/ini.php'; 5 load_ini_file('captcha_config.ini'); 6 7 $recaptcha_public_key = RECAPTCHA_API_KEY_PUBLIC; 8 $recaptcha_private_key = RECAPTCHA_API_KEY_PRIVATE; 9 10 $hcaptcha_public_key = HCAPTCHA_API_KEY_PUBLIC; 11 $hcaptcha_private_key = HCAPTCHA_API_KEY_PRIVATE; 12 13 // Parameter formats and other checks must much the formatting in /captcha 14 function is_twister_captcha_valid($memcached, $ip, $userpwd, $board = '!', $thread_id = 0, &$unsolved_count = null) { 15 if (!$memcached) { 16 return false; 17 } 18 19 if (defined('TEST_BOARD') && TEST_BOARD) { 20 require_once 'lib/twister_captcha-test.php'; 21 } 22 else { 23 require_once 'lib/twister_captcha.php'; 24 } 25 26 if (!isset($_POST['t-challenge']) || !$_POST['t-challenge']) { 27 return false; 28 } 29 30 if (!isset($_POST['t-response']) || !$_POST['t-response']) { 31 return false; 32 } 33 34 if (strlen($_POST['t-response']) > 24) { 35 return false; 36 } 37 38 if (strlen($_POST['t-challenge']) > 255) { 39 return false; 40 } 41 42 // User agent 43 if (!isset($_SERVER['HTTP_USER_AGENT'])) { 44 $user_agent = '!'; 45 } 46 else { 47 $user_agent = md5($_SERVER['HTTP_USER_AGENT']); 48 } 49 50 // Password 51 $password = '!'; 52 53 if ($userpwd && !$userpwd->isNew()) { 54 $password = $userpwd->getPwd(); 55 } 56 57 // --- 58 59 list($uniq_id, $challenge_hash) = explode('.', $_POST['t-challenge']); 60 61 $long_ip = ip2long($ip); 62 63 if (!$uniq_id || !$challenge_hash || !$long_ip) { 64 return false; 65 } 66 67 $challenge_key = "ch$long_ip"; 68 69 $params = [$ip, $password, $user_agent, $board, $thread_id]; 70 71 $response = TwisterCaptcha::normalizeReponseStr($_POST['t-response']); 72 73 $is_valid = TwisterCaptcha::verifyChallengeHash($challenge_hash, $uniq_id, $response, $params); 74 75 if ($is_valid) { 76 $active_uniq_id = $memcached->get($challenge_key); 77 78 // Delete challenge 79 $memcached->delete($challenge_key); 80 81 if (!$active_uniq_id || $uniq_id !== $active_uniq_id) { 82 return false; 83 } 84 85 // Return and decrement the unsolved session count 86 $us = decrement_twister_captcha_session($memcached, $ip, $unsolved_count !== null); 87 88 if ($unsolved_count !== null) { 89 $unsolved_count = $us; 90 } 91 92 return true; 93 } 94 95 // Delete challenge 96 $memcached->delete($challenge_key); 97 98 return false; 99 } 100 101 // Decrements the unsolved count by 2 and returns the old count 102 function decrement_twister_captcha_session($memcached, $ip, $return_old = true) { 103 if (!$memcached) { 104 return false; 105 } 106 107 $long_ip = ip2long($ip); 108 109 if (!$long_ip) { 110 return false; 111 } 112 113 $key = "us$long_ip"; 114 115 if ($return_old) { 116 $val = $memcached->get($key); 117 118 if ($val === false) { 119 $val = 0; 120 } 121 } 122 else { 123 $val = 0; 124 } 125 126 $memcached->decrement($key, 2); 127 128 return $val; 129 } 130 131 // FIXME: The IP arg isn't used for now 132 function set_twister_captcha_credits($memcached, $ip, $userpwd, $current_time) { 133 if (!$memcached || !$userpwd) { 134 return false; 135 } 136 137 $current_time = (int)$current_time; 138 139 if ($current_time <= 0) { 140 return false; 141 } 142 143 //$long_ip = ip2long($ip); 144 145 //if (!$long_ip) { 146 //return false; 147 //} 148 149 $credits = 0; 150 151 // Config 152 // Stage 1 should match the check in use_twister_captcha_credit() 153 // and captcha.php for optimisation purposes 154 155 // Stage 1 156 $noop_known_ttl_1 = 4320; // required user lifetime (3 days, in minutes) 157 $noop_post_count_1 = 5; // required post count 158 $noop_credits_1 = 1; // credits given 159 $noop_duration_1 = 3600; // duration of the credits (1 hour, in seconds) 160 161 // Stage 2 162 $noop_known_ttl_2 = 21600; // 15 days 163 $noop_post_count_2 = 20; 164 $noop_credits_2 = 2; 165 $noop_duration_2 = 7200; // 2 hours 166 167 // Stage 3 168 $noop_known_ttl_3 = 129600; // 90 days 169 $noop_post_count_3 = 100; 170 $noop_credits_3 = 3; 171 $noop_duration_3 = 10800; // 3 hours 172 173 // --- 174 175 // The IP changed too recently 176 if ($userpwd->ipLifetime() < 60) { 177 return false; 178 } 179 180 // Stage 3 181 if ($userpwd->isUserKnown($noop_known_ttl_3) && $userpwd->postCount() >= $noop_post_count_3) { 182 $credits = $noop_credits_3; 183 $duration = $noop_duration_3; 184 } 185 // Stage 2 186 else if ($userpwd->isUserKnown($noop_known_ttl_2) && $userpwd->postCount() >= $noop_post_count_2) { 187 $credits = $noop_credits_2; 188 $duration = $noop_duration_2; 189 } 190 // Stage 1 191 else if ($userpwd->isUserKnown($noop_known_ttl_1) && $userpwd->postCount() >= $noop_post_count_1) { 192 $credits = $noop_credits_1; 193 $duration = $noop_duration_1; 194 } 195 else { 196 return false; 197 } 198 199 if (!$credits || $credits > 3) { 200 return false; 201 } 202 203 $expiration_ts = $current_time + $duration; 204 205 // Require no more than 5 actions in the past 8 minutes 206 /* 207 $query = <<<SQL 208 SELECT SQL_NO_CACHE COUNT(*) FROM user_actions 209 WHERE ip = $long_ip 210 AND time >= DATE_SUB(NOW(), INTERVAL 8 MINUTE) 211 SQL; 212 213 $res = mysql_global_call($query); 214 215 if (!$res) { 216 return false; 217 } 218 219 $count = (int)mysql_fetch_row($res)[0]; 220 221 if ($count > 5) { 222 return false; 223 } 224 */ 225 // Set credits 226 227 $pwd = $userpwd->getPwd(); 228 229 if (!$pwd) { 230 return false; 231 } 232 233 $key = "cr-$pwd"; 234 $val = "$credits.$expiration_ts"; 235 236 $res = $memcached->replace($key, $val, $expiration_ts); 237 238 if ($res === false) { 239 if ($memcached->getResultCode() === Memcached::RES_NOTSTORED) { 240 return $memcached->set($key, $val, $expiration_ts); 241 } 242 else { 243 return false; 244 } 245 } 246 247 return true; 248 } 249 250 // FIXME: The IP arg isn't used for now 251 function use_twister_captcha_credit($memcached, $ip, $userpwd) { 252 if (!$memcached || !$userpwd) { 253 return false; 254 } 255 256 //$long_ip = ip2long($ip); 257 258 //if (!$long_ip) { 259 //return false; 260 //} 261 262 // Must match the check in set_twister_captcha_credits() 263 $noop_known_ttl_1 = 4320; // required user lifetime (3 days, in minutes) 264 $noop_post_count_1 = 5; // required post count 265 266 if (!$userpwd->isUserKnown($noop_known_ttl_1) || $userpwd->postCount() < $noop_post_count_1) { 267 return false; 268 } 269 270 $pwd = $userpwd->getPwd(); 271 272 if (!$pwd) { 273 return false; 274 } 275 276 $key = "cr-$pwd"; 277 $credits = $memcached->get($key); 278 279 if ($credits === false) { 280 return false; 281 } 282 283 list($count, $ts) = explode('.', $credits); 284 285 $count = (int)$count; 286 $ts = (int)$ts; 287 288 // No credits left 289 if ($count <= 0 || $ts <= 0) { 290 $memcached->delete($key); 291 return false; 292 } 293 294 $count -= 1; 295 296 $res = $memcached->replace($key, "$count.$ts", $ts); 297 298 if ($res === false && $memcached->getResultCode() !== Memcached::RES_NOTSTORED) { 299 return false; 300 } 301 302 return true; 303 } 304 305 function twister_captcha_form() { 306 return '<div id="t-root"></div>'; 307 } 308 309 function log_failed_captcha($ip, $userpwd, $board, $thread_id, $is_quiet, $meta = null) { 310 $data = [ 311 'board' => $board, 312 'thread_id' => $thread_id, 313 ]; 314 315 if ($userpwd) { 316 $data['arg_num'] = $userpwd->pwdLifetime(); 317 $data['pwd'] = $userpwd->getPwd(); 318 } 319 320 if ($meta) { 321 $data['meta'] = $meta; 322 } 323 324 if ($is_quiet) { 325 $type = 'failed_captcha_quiet'; 326 } 327 else { 328 $type = 'failed_captcha'; 329 } 330 331 write_to_event_log($type, $ip, $data); 332 } 333 334 function h_captcha_form($autoload = false, $cb = 'onRecaptchaLoaded', $dark = false) { 335 global $hcaptcha_public_key; 336 337 $js_tag = '<script src="https://hcaptcha.com/1/api.js' 338 . (!$autoload ? "?onload=$cb&render=explicit" : '') . '" async defer></script>'; 339 340 if ($autoload) { 341 $attrs = ' class="h-captcha" data-sitekey="' . $hcaptcha_public_key . '"'; 342 343 if ($dark) { 344 $attrs .= ' data-theme="dark"'; 345 } 346 } 347 else { 348 $attrs = ''; 349 } 350 351 $container_tag = '<script>window.hcaptchaKey = "' . $hcaptcha_public_key 352 . '";</script><div id="g-recaptcha"' 353 . $attrs . '></div>'; 354 355 return $js_tag.$container_tag; 356 } 357 358 // Moves css out of the form for html validation 359 function captcha_form($autoload = false, $cb = 'onRecaptchaLoaded', $dark = false) { 360 global $recaptcha_public_key; 361 362 $js_tag = '<script src="https://www.google.com/recaptcha/api.js' 363 . (!$autoload ? "?onload=$cb&render=explicit" : '') . '" async defer></script>'; 364 365 if ($autoload) { 366 $attrs = ' class="g-recaptcha" data-sitekey="' . $recaptcha_public_key . '"'; 367 368 if ($dark) { 369 $attrs .= ' data-theme="dark"'; 370 } 371 } 372 else { 373 $attrs = ''; 374 } 375 376 $container_tag = '<script>window.recaptchaKey = "' . $recaptcha_public_key 377 . '";</script><div id="g-recaptcha"' 378 . $attrs . '></div>'; 379 380 $noscript_tag =<<<HTML 381 <noscript> 382 <div style="width: 302px;"> 383 <div style="width: 302px; position: relative;"> 384 <div style="width: 302px; height: 422px;"> 385 <iframe src="https://www.google.com/recaptcha/api/fallback?k=$recaptcha_public_key" frameborder="0" scrolling="no" style="width: 302px; height:422px; border-style: none;"></iframe> 386 </div> 387 <div style="width: 300px; height: 60px; border-style: none;bottom: 12px; left: 25px; margin: 0px; padding: 0px; right: 25px;background: #f9f9f9; border: 1px solid #c1c1c1; border-radius: 3px;"> 388 <textarea id="g-recaptcha-response" name="g-recaptcha-response" class="g-recaptcha-response" style="width: 250px; height: 40px; border: 1px solid #c1c1c1;margin: 10px 25px; padding: 0px; resize: none;"></textarea> 389 </div> 390 </div> 391 </div> 392 </noscript> 393 HTML; 394 395 if (defined('NOSCRIPT_CAPTCHA_ONLY') && NOSCRIPT_CAPTCHA_ONLY == 1) { 396 return $container_tag.$noscript_tag; 397 } 398 399 return $js_tag.$container_tag.$noscript_tag; 400 } 401 402 // Legacy captcha 403 // Uses recaptcha v2 for noscript captcha as the v1 seems to be broken currently. 404 function captcha_form_alt() { 405 global $recaptcha_public_key; 406 407 $html = <<<HTML 408 <div id="captchaContainerAlt"></div> 409 <script> 410 function onAltCaptchaClick() { 411 Recaptcha.reload('t'); 412 } 413 function onAltCaptchaReady() { 414 var el; 415 416 if (el = document.getElementById('recaptcha_image')) { 417 el.title = 'Reload'; 418 el.addEventListener('click', onAltCaptchaClick, false); 419 } 420 } 421 if (!window.passEnabled) { 422 var el = document.createElement('script'); 423 el.type = 'text/javascript'; 424 el.src = '//www.google.com/recaptcha/api/js/recaptcha_ajax.js'; 425 el.onload = function() { 426 Recaptcha.create('$recaptcha_public_key', 427 'captchaContainerAlt', 428 { 429 theme: 'clean', 430 tabindex: 3, 431 callback: onAltCaptchaReady 432 } 433 ); 434 } 435 document.head.appendChild(el); 436 }</script> 437 <noscript> 438 <div style="width: 302px;"> 439 <div style="width: 302px; position: relative;"> 440 <div style="width: 302px; height: 422px;"> 441 <iframe src="https://www.google.com/recaptcha/api/fallback?k=$recaptcha_public_key" frameborder="0" scrolling="no" style="width: 302px; height:422px; border-style: none;"></iframe> 442 </div> 443 <div style="width: 300px; height: 60px; border-style: none;bottom: 12px; left: 25px; margin: 0px; padding: 0px; right: 25px;background: #f9f9f9; border: 1px solid #c1c1c1; border-radius: 3px;"> 444 <textarea id="g-recaptcha-response" name="g-recaptcha-response" class="g-recaptcha-response" style="width: 250px; height: 40px; border: 1px solid #c1c1c1;margin: 10px 25px; padding: 0px; resize: none;"></textarea> 445 </div> 446 </div> 447 </div> 448 </noscript> 449 HTML; 450 451 return $html; 452 } 453 454 function recaptcha_ban($n, $time, $return_error = 0, $length = 1) 455 { 456 auto_ban_poster($name, $length, 1, "failed verification $n times per $time", "Possible spambot; repeatedly sent incorrect CAPTCHA verification."); 457 if( $return_error == 1 ) { 458 return S_GENERICERROR; 459 } 460 error(S_GENERICERROR); 461 } 462 463 /** 464 * Works for both recaptcha and hcaptcha 465 */ 466 function recaptcha_bad_captcha($return_error = false, $codes = null) { 467 $error = S_BADCAPTCHA; 468 469 if (is_array($codes)) { 470 if (in_array('missing-input-response', $codes)) { 471 $error = S_NOCAPTCHA; 472 } 473 474 if ($return_error) { 475 return $error; 476 } 477 else { 478 error($error); 479 } 480 } 481 else { 482 if ($return_error) { 483 return $error; 484 } 485 else { 486 error($error); 487 } 488 } 489 } 490 491 // ----------- 492 // hCaptcha 493 // ----------- 494 function start_hcaptcha_verify($return_error = false) { 495 global $hcaptcha_private_key, $hcaptcha_ch; 496 497 $response = $_POST["h-captcha-response"]; 498 499 if (!$response) { 500 if ($return_error == false) { 501 error(S_NOCAPTCHA); 502 } 503 return S_NOCAPTCHA; 504 } 505 506 $response = urlencode($response); 507 508 $rlen = strlen($response); 509 510 if ($rlen > 32768) { 511 return recaptcha_bad_captcha($return_error); 512 } 513 514 $api_url = 'https://hcaptcha.com/siteverify'; 515 516 $post = array( 517 'secret' => $hcaptcha_private_key, 518 'response' => $response 519 ); 520 521 $hcaptcha_ch = rpc_start_captcha_request($api_url, $post, null, false); 522 } 523 524 function end_hcaptcha_verify($return_error = false) { 525 global $hcaptcha_ch; 526 527 if (!$hcaptcha_ch) { 528 return; 529 } 530 531 $ret = rpc_finish_request($hcaptcha_ch, $error, $httperror); 532 533 // BAD 534 // 413 Request Too Large is bad; it was caused intentionally by the user. 535 if ($httperror == 413) { 536 return recaptcha_bad_captcha($return_error); 537 } 538 539 // BAD 540 if ($ret == null) { 541 return recaptcha_bad_captcha($return_error); 542 } 543 544 $resp = json_decode($ret, true); 545 546 // BAD 547 // Malformed JSON response from Google 548 if (json_last_error() !== JSON_ERROR_NONE) { 549 return recaptcha_bad_captcha($return_error); 550 } 551 552 // GOOD 553 if ($resp['success']) { 554 return $resp; 555 } 556 557 // BAD 558 return recaptcha_bad_captcha($return_error, $resp['error-codes']); 559 } 560 561 // ----------- 562 // reCaptcha V2 563 // ----------- 564 // FIXME $challenge_field is no longer used 565 function start_recaptcha_verify($return_error = false, $challenge_field = '') { 566 global $recaptcha_private_key, $recaptcha_ch; 567 568 $response = $_POST["g-recaptcha-response"]; 569 570 if (!$response) { 571 if ($return_error == false) { 572 error(S_NOCAPTCHA); 573 } 574 return S_NOCAPTCHA; 575 } 576 577 $response = urlencode($response); 578 579 $rlen = strlen($response); 580 581 if ($rlen > 4096) { 582 return recaptcha_bad_captcha($return_error); 583 } 584 585 $api_url = 'https://www.google.com/recaptcha/api/siteverify'; 586 587 $post = array( 588 'secret' => $recaptcha_private_key, 589 'response' => $response 590 ); 591 592 $recaptcha_ch = rpc_start_captcha_request($api_url, $post, null, false); 593 } 594 595 function end_recaptcha_verify($return_error = false) { 596 global $recaptcha_ch; 597 598 if (!$recaptcha_ch) { 599 return; 600 } 601 602 $ret = rpc_finish_request($recaptcha_ch, $error, $httperror); 603 604 // BAD 605 // 413 Request Too Large is bad; it was caused intentionally by the user. 606 if ($httperror == 413) { 607 return recaptcha_bad_captcha($return_error); 608 } 609 610 // BAD 611 if ($ret == null) { 612 return recaptcha_bad_captcha($return_error); 613 } 614 615 $resp = json_decode($ret, true); 616 617 // BAD 618 // Malformed JSON response from Google 619 if (json_last_error() !== JSON_ERROR_NONE) { 620 return recaptcha_bad_captcha($return_error); 621 } 622 623 // GOOD 624 if ($resp['success']) { 625 return $resp; 626 } 627 628 // BAD 629 return recaptcha_bad_captcha($return_error, $resp['error-codes']); 630 } 631 632 // ----------- 633 // reCaptcha V1 634 // ----------- 635 function start_recaptcha_verify_alt($return_error = false, $challenge_field = '') { 636 global $recaptcha_private_key, $recaptcha_ch; 637 638 $challenge = ( $challenge_field == '' ) ? $_POST["recaptcha_challenge_field"] : $challenge_field; 639 $response = $_POST["recaptcha_response_field"]; 640 if (!$challenge || !$response) { 641 if( $return_error == false ) { 642 error(S_NOCAPTCHA); 643 } 644 return S_NOCAPTCHA; 645 } 646 647 $num_words = 1 + preg_match_all('/\\s/', $response); 648 $rlen = strlen($response); 649 if ($num_words > 3 || $rlen > 128) { 650 return recaptcha_bad_captcha($return_error); 651 } 652 653 $post = array( 654 "privatekey" => $recaptcha_private_key, 655 "challenge" => $challenge, 656 "remoteip" => $_SERVER["REMOTE_ADDR"], 657 "response" => $response 658 ); 659 660 $recaptcha_ch = rpc_start_request("https://www.google.com/recaptcha/api/verify", $post, null, false); 661 } 662 663 function end_recaptcha_verify_alt($return_error = false) { 664 global $recaptcha_ch; 665 666 if (!$recaptcha_ch) return; 667 668 $ret = rpc_finish_request($recaptcha_ch, $error, $httperror); 669 670 if ($httperror == 413) { 671 return recaptcha_bad_captcha($return_error); 672 } 673 674 if ($ret) { 675 $lines = explode("\n", $ret); 676 if ($lines[0] === "true") { 677 // GOOD 678 return; 679 } 680 } 681 682 // BAD 683 return recaptcha_bad_captcha($return_error); 684 } 685 686 ?>