Changeset View
Changeset View
Standalone View
Standalone View
support/PhabricatorStartup.php
| <?php | <?php | ||||
| /** | /** | ||||
| * Handle request startup, before loading the environment or libraries. This | * Handle request startup, before loading the environment or libraries. This | ||||
| * class bootstraps the request state up to the point where we can enter | * class bootstraps the request state up to the point where we can enter | ||||
| * Phabricator code. | * Phabricator code. | ||||
| * | * | ||||
| * NOTE: This class MUST NOT have any dependencies. It runs before libraries | * NOTE: This class MUST NOT have any dependencies. It runs before libraries | ||||
| * load. | * load. | ||||
| * | * | ||||
| * Rate Limiting | |||||
| * ============= | |||||
| * | |||||
| * Phabricator limits the rate at which clients can request pages, and issues | |||||
| * HTTP 429 "Too Many Requests" responses if clients request too many pages too | |||||
| * quickly. Although this is not a complete defense against high-volume attacks, | |||||
| * it can protect an install against aggressive crawlers, security scanners, | |||||
| * and some types of malicious activity. | |||||
| * | |||||
| * To perform rate limiting, each page increments a score counter for the | |||||
| * requesting user's IP. The page can give the IP more points for an expensive | |||||
| * request, or fewer for an authetnicated request. | |||||
| * | |||||
| * Score counters are kept in buckets, and writes move to a new bucket every | |||||
| * minute. After a few minutes (defined by @{method:getRateLimitBucketCount}), | |||||
| * the oldest bucket is discarded. This provides a simple mechanism for keeping | |||||
| * track of scores without needing to store, access, or read very much data. | |||||
| * | |||||
| * Users are allowed to accumulate up to 1000 points per minute, averaged across | |||||
| * all of the tracked buckets. | |||||
| * | |||||
| * @task info Accessing Request Information | * @task info Accessing Request Information | ||||
| * @task hook Startup Hooks | * @task hook Startup Hooks | ||||
| * @task apocalypse In Case Of Apocalypse | * @task apocalypse In Case Of Apocalypse | ||||
| * @task validation Validation | * @task validation Validation | ||||
| * @task ratelimit Rate Limiting | |||||
| */ | */ | ||||
| final class PhabricatorStartup { | final class PhabricatorStartup { | ||||
| private static $startTime; | private static $startTime; | ||||
| private static $globals = array(); | private static $globals = array(); | ||||
| private static $capturingOutput; | private static $capturingOutput; | ||||
| private static $rawInput; | private static $rawInput; | ||||
| private static $maximumRate = 1000; | |||||
| /* -( Accessing Request Information )-------------------------------------- */ | /* -( Accessing Request Information )-------------------------------------- */ | ||||
| /** | /** | ||||
| * @task info | * @task info | ||||
| */ | */ | ||||
| ▲ Show 20 Lines • Show All 58 Lines • ▼ Show 20 Lines | if (!$registered) { | ||||
| // interpreter state, which we may implement in the future. | // interpreter state, which we may implement in the future. | ||||
| register_shutdown_function(array(__CLASS__, 'didShutdown')); | register_shutdown_function(array(__CLASS__, 'didShutdown')); | ||||
| $registered = true; | $registered = true; | ||||
| } | } | ||||
| self::setupPHP(); | self::setupPHP(); | ||||
| self::verifyPHP(); | self::verifyPHP(); | ||||
| if (isset($_SERVER['REMOTE_ADDR'])) { | |||||
| self::rateLimitRequest($_SERVER['REMOTE_ADDR']); | |||||
| } | |||||
| self::normalizeInput(); | self::normalizeInput(); | ||||
| self::verifyRewriteRules(); | self::verifyRewriteRules(); | ||||
| self::detectPostMaxSizeTriggered(); | self::detectPostMaxSizeTriggered(); | ||||
| self::beginOutputCapture(); | self::beginOutputCapture(); | ||||
| ▲ Show 20 Lines • Show All 412 Lines • ▼ Show 20 Lines | PhabricatorStartup::didFatal( | ||||
| "but no POST data.\n\n". | "but no POST data.\n\n". | ||||
| "Normally, this indicates that it exceeds the 'post_max_size' setting ". | "Normally, this indicates that it exceeds the 'post_max_size' setting ". | ||||
| "in the PHP configuration on the server. Increase the 'post_max_size' ". | "in the PHP configuration on the server. Increase the 'post_max_size' ". | ||||
| "setting or reduce the size of the request.\n\n". | "setting or reduce the size of the request.\n\n". | ||||
| "Request size according to 'Content-Length' was '{$length}', ". | "Request size according to 'Content-Length' was '{$length}', ". | ||||
| "'post_max_size' is set to '{$config}'."); | "'post_max_size' is set to '{$config}'."); | ||||
| } | } | ||||
| /* -( Rate Limiting )------------------------------------------------------ */ | |||||
| /** | |||||
| * Adjust the permissible rate limit score. | |||||
| * | |||||
| * By default, the limit is `1000`. You can use this method to set it to | |||||
| * a larger or smaller value. If you set it to `2000`, users may make twice | |||||
| * as many requests before rate limiting. | |||||
| * | |||||
| * @param int Maximum score before rate limiting. | |||||
| * @return void | |||||
| * @task ratelimit | |||||
| */ | |||||
| public static function setMaximumRate($rate) { | |||||
| self::$maximumRate = $rate; | |||||
| } | |||||
| /** | |||||
| * Check if the user (identified by `$user_identity`) has issued too many | |||||
| * requests recently. If they have, end the request with a 429 error code. | |||||
| * | |||||
| * The key just needs to identify the user. Phabricator uses both user PHIDs | |||||
| * and user IPs as keys, tracking logged-in and logged-out users separately | |||||
| * and enforcing different limits. | |||||
| * | |||||
| * @param string Some key which identifies the user making the request. | |||||
| * @return void If the user has exceeded the rate limit, this method | |||||
| * does not return. | |||||
| * @task ratelimit | |||||
| */ | |||||
| public static function rateLimitRequest($user_identity) { | |||||
| if (!self::canRateLimit()) { | |||||
| return; | |||||
| } | |||||
| $score = self::getRateLimitScore($user_identity); | |||||
| if ($score > (self::$maximumRate * self::getRateLimitBucketCount())) { | |||||
| // Give the user some bonus points for getting rate limited. This keeps | |||||
| // bad actors who keep slamming the 429 page locked out completely, | |||||
| // instead of letting them get a burst of requests through every minute | |||||
| // after a bucket expires. | |||||
| self::addRateLimitScore($user_identity, 50); | |||||
| self::didRateLimit($user_identity); | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Add points to the rate limit score for some user. | |||||
| * | |||||
| * If users have earned more than 1000 points per minute across all the | |||||
| * buckets they'll be locked out of the application, so awarding 1 point per | |||||
| * request roughly corresponds to allowing 1000 requests per second, while | |||||
| * awarding 50 points roughly corresponds to allowing 20 requests per second. | |||||
| * | |||||
| * @param string Some key which identifies the user making the request. | |||||
| * @param float The cost for this request; more points pushes them toward | |||||
| * the limit faster. | |||||
| * @return void | |||||
| * @task ratelimit | |||||
| */ | |||||
| public static function addRateLimitScore($user_identity, $score) { | |||||
| if (!self::canRateLimit()) { | |||||
| return; | |||||
| } | |||||
| $current = self::getRateLimitBucket(); | |||||
| // There's a bit of a race here, if a second process reads the bucket before | |||||
| // this one writes it, but it's fine if we occasionally fail to record a | |||||
| // user's score. If they're making requests fast enough to hit rate | |||||
| // limiting, we'll get them soon. | |||||
| $bucket_key = self::getRateLimitBucketKey($current); | |||||
| $bucket = apc_fetch($bucket_key); | |||||
| if (!is_array($bucket)) { | |||||
| $bucket = array(); | |||||
| } | |||||
| if (empty($bucket[$user_identity])) { | |||||
| $bucket[$user_identity] = 0; | |||||
| } | |||||
| $bucket[$user_identity] += $score; | |||||
| apc_store($bucket_key, $bucket); | |||||
| } | |||||
| /** | |||||
| * Determine if rate limiting is available. | |||||
| * | |||||
| * Rate limiting depends on APC, and isn't available unless the APC user | |||||
| * cache is available. | |||||
| * | |||||
| * @return bool True if rate limiting is available. | |||||
| * @task ratelimit | |||||
| */ | |||||
| private static function canRateLimit() { | |||||
| if (!self::$maximumRate) { | |||||
| return false; | |||||
| } | |||||
| if (!function_exists('apc_fetch')) { | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| /** | |||||
| * Get the current bucket for storing rate limit scores. | |||||
| * | |||||
| * @return int The current bucket. | |||||
| * @task ratelimit | |||||
| */ | |||||
| private static function getRateLimitBucket() { | |||||
| return (int)(time() / 60); | |||||
| } | |||||
| /** | |||||
| * Get the total number of rate limit buckets to retain. | |||||
| * | |||||
| * @return int Total number of rate limit buckets to retain. | |||||
| * @task ratelimit | |||||
| */ | |||||
| private static function getRateLimitBucketCount() { | |||||
| return 5; | |||||
| } | |||||
| /** | |||||
| * Get the APC key for a given bucket. | |||||
| * | |||||
| * @param int Bucket to get the key for. | |||||
| * @return string APC key for the bucket. | |||||
| * @task ratelimit | |||||
| */ | |||||
| private static function getRateLimitBucketKey($bucket) { | |||||
| return 'rate:bucket:'.$bucket; | |||||
| } | |||||
| /** | |||||
| * Get the APC key for the smallest stored bucket. | |||||
| * | |||||
| * @return string APC key for the smallest stored bucket. | |||||
| * @task ratelimit | |||||
| */ | |||||
| private static function getRateLimitMinKey() { | |||||
| return 'rate:min'; | |||||
| } | |||||
| /** | |||||
| * Get the current rate limit score for a given user. | |||||
| * | |||||
| * @param string Unique key identifying the user. | |||||
| * @return float The user's current score. | |||||
| * @task ratelimit | |||||
| */ | |||||
| private static function getRateLimitScore($user_identity) { | |||||
| $min_key = self::getRateLimitMinKey(); | |||||
| // Identify the oldest bucket stored in APC. | |||||
| $cur = self::getRateLimitBucket(); | |||||
| $min = apc_fetch($min_key); | |||||
| // If we don't have any buckets stored yet, store the current bucket as | |||||
| // the oldest bucket. | |||||
| if (!$min) { | |||||
| apc_store($min_key, $cur); | |||||
| $min = $cur; | |||||
| } | |||||
| // Destroy any buckets that are older than the minimum bucket we're keeping | |||||
| // track of. Under load this normally shouldn't do anything, but will clean | |||||
| // up an old bucket once per minute. | |||||
| $count = self::getRateLimitBucketCount(); | |||||
| for ($cursor = $min; $cursor < ($cur - $count); $cursor++) { | |||||
| apc_delete(self::getRateLimitBucketKey($cursor)); | |||||
| apc_store($min_key, $cursor + 1); | |||||
| } | |||||
| // Now, sum up the user's scores in all of the active buckets. | |||||
| $score = 0; | |||||
| for (; $cursor <= $cur; $cursor++) { | |||||
| $bucket = apc_fetch(self::getRateLimitBucketKey($cursor)); | |||||
| if (isset($bucket[$user_identity])) { | |||||
| $score += $bucket[$user_identity]; | |||||
| } | |||||
| } | |||||
| return $score; | |||||
| } | |||||
| /** | |||||
| * Emit an HTTP 429 "Too Many Requests" response (indicating that the user | |||||
| * has exceeded application rate limits) and exit. | |||||
| * | |||||
| * @return exit This method **does not return**. | |||||
| * @task ratelimit | |||||
| */ | |||||
| private static function didRateLimit() { | |||||
| $message = | |||||
| "TOO MANY REQUESTS\n". | |||||
| "You are issuing too many requests too quickly.\n". | |||||
| "To adjust limits, see \"Configuring a Preamble Script\" in the ". | |||||
| "documentation."; | |||||
btrahan: documentation* | |||||
| header( | |||||
| 'Content-Type: text/plain; charset=utf-8', | |||||
| $replace = true, | |||||
| $http_error = 429); | |||||
| echo $message; | |||||
| exit(1); | |||||
| } | |||||
| } | } | ||||
documentation*