ratelimit.php 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. <?php
  2. declare(strict_types=1);
  3. // File-based per-IP rate limiting for hosting without Redis. Ported from the
  4. // PSA order system (includes/functions.php rateLimitEvaluate). State lives
  5. // under storage/ratelimit/ and is therefore not web-readable.
  6. function manageRateLimitClientIp(): string
  7. {
  8. $ip = trim((string) ($_SERVER["REMOTE_ADDR"] ?? ""));
  9. return $ip !== "" ? $ip : "unknown";
  10. }
  11. function manageRateLimitStatePath(string $name): string
  12. {
  13. $name = preg_replace("/[^a-z0-9_-]/", "", strtolower($name)) ?? "";
  14. if ($name === "") {
  15. $name = "default";
  16. }
  17. return manageStorageDir() . "ratelimit" . DIRECTORY_SEPARATOR . $name . ".json";
  18. }
  19. /**
  20. * @return bool true while under the limit; increments the counter when $consume
  21. */
  22. function manageRateLimitEvaluate(
  23. string $name,
  24. int $maxAttempts,
  25. int $windowSeconds,
  26. bool $consume,
  27. ): bool {
  28. if ($maxAttempts < 1 || $windowSeconds < 1) {
  29. return true;
  30. }
  31. $path = manageRateLimitStatePath($name);
  32. $dir = dirname($path);
  33. if (!is_dir($dir) && !@mkdir($dir, 02775, true) && !is_dir($dir)) {
  34. // Without usable state the limiter must not lock anybody out.
  35. return true;
  36. }
  37. @chmod($dir, 02775);
  38. $handle = fopen($path, "c+");
  39. if ($handle === false) {
  40. return true;
  41. }
  42. if (!flock($handle, LOCK_EX)) {
  43. fclose($handle);
  44. return true;
  45. }
  46. rewind($handle);
  47. $raw = stream_get_contents($handle);
  48. $data = [];
  49. if (is_string($raw) && trim($raw) !== "") {
  50. $decoded = json_decode($raw, true);
  51. if (is_array($decoded)) {
  52. $data = $decoded;
  53. }
  54. }
  55. $now = time();
  56. $ip = manageRateLimitClientIp();
  57. foreach ($data as $key => $entry) {
  58. if (!is_array($entry)) {
  59. unset($data[$key]);
  60. continue;
  61. }
  62. if ($now - (int) ($entry["w"] ?? 0) > $windowSeconds * 2) {
  63. unset($data[$key]);
  64. }
  65. }
  66. $entry = isset($data[$ip]) && is_array($data[$ip]) ? $data[$ip] : null;
  67. $windowStart = (int) ($entry["w"] ?? $now);
  68. $count = (int) ($entry["c"] ?? 0);
  69. if ($entry === null || $now - $windowStart > $windowSeconds) {
  70. $windowStart = $now;
  71. $count = 0;
  72. }
  73. if ($count >= $maxAttempts) {
  74. flock($handle, LOCK_UN);
  75. fclose($handle);
  76. return false;
  77. }
  78. if ($consume) {
  79. $data[$ip] = ["w" => $windowStart, "c" => $count + 1];
  80. $payload = json_encode($data, JSON_UNESCAPED_UNICODE);
  81. if ($payload !== false) {
  82. ftruncate($handle, 0);
  83. rewind($handle);
  84. fwrite($handle, $payload);
  85. fflush($handle);
  86. @chmod($path, 0660);
  87. }
  88. }
  89. flock($handle, LOCK_UN);
  90. fclose($handle);
  91. return true;
  92. }
  93. function manageRateLimitTryConsume(string $name, int $maxAttempts, int $windowSeconds): bool
  94. {
  95. return manageRateLimitEvaluate($name, $maxAttempts, $windowSeconds, true);
  96. }
  97. function manageRateLimitClearIp(string $name): void
  98. {
  99. $path = manageRateLimitStatePath($name);
  100. if (!is_file($path)) {
  101. return;
  102. }
  103. $handle = fopen($path, "c+");
  104. if ($handle === false) {
  105. return;
  106. }
  107. if (!flock($handle, LOCK_EX)) {
  108. fclose($handle);
  109. return;
  110. }
  111. rewind($handle);
  112. $raw = stream_get_contents($handle);
  113. $data = is_string($raw) && trim($raw) !== "" ? json_decode($raw, true) : [];
  114. if (is_array($data)) {
  115. unset($data[manageRateLimitClientIp()]);
  116. $payload = json_encode($data, JSON_UNESCAPED_UNICODE);
  117. if ($payload !== false) {
  118. ftruncate($handle, 0);
  119. rewind($handle);
  120. fwrite($handle, $payload);
  121. fflush($handle);
  122. }
  123. }
  124. flock($handle, LOCK_UN);
  125. fclose($handle);
  126. }
  127. function manageLoginIsRateLimited(): bool
  128. {
  129. return !manageRateLimitEvaluate(
  130. "admin-login",
  131. (int) MANAGE_LOGIN_RATE_LIMIT_MAX,
  132. (int) MANAGE_LOGIN_RATE_LIMIT_WINDOW,
  133. false,
  134. );
  135. }
  136. function manageLoginRecordFailure(): void
  137. {
  138. manageRateLimitTryConsume(
  139. "admin-login",
  140. (int) MANAGE_LOGIN_RATE_LIMIT_MAX,
  141. (int) MANAGE_LOGIN_RATE_LIMIT_WINDOW,
  142. );
  143. }
  144. function manageLoginClearRateLimit(): void
  145. {
  146. manageRateLimitClearIp("admin-login");
  147. }