diff --git a/src/aphront/configuration/AphrontApplicationConfiguration.php b/src/aphront/configuration/AphrontApplicationConfiguration.php index c0cd259992..60b12557c9 100644 --- a/src/aphront/configuration/AphrontApplicationConfiguration.php +++ b/src/aphront/configuration/AphrontApplicationConfiguration.php @@ -1,721 +1,711 @@ request = $request; return $this; } final public function getRequest() { return $this->request; } final public function getConsole() { return $this->console; } final public function setConsole($console) { $this->console = $console; return $this; } final public function setHost($host) { $this->host = $host; return $this; } final public function getHost() { return $this->host; } final public function setPath($path) { $this->path = $path; return $this; } final public function getPath() { return $this->path; } public function willBuildRequest() {} /** * @phutil-external-symbol class PhabricatorStartup */ public static function runHTTPRequest(AphrontHTTPSink $sink) { if (isset($_SERVER['HTTP_X_PHABRICATOR_SELFCHECK'])) { $response = self::newSelfCheckResponse(); return self::writeResponse($sink, $response); } PhabricatorStartup::beginStartupPhase('multimeter'); $multimeter = MultimeterControl::newInstance(); $multimeter->setEventContext(''); $multimeter->setEventViewer(''); // Build a no-op write guard for the setup phase. We'll replace this with a // real write guard later on, but we need to survive setup and build a // request object first. $write_guard = new AphrontWriteGuard('id'); PhabricatorStartup::beginStartupPhase('preflight'); $response = PhabricatorSetupCheck::willPreflightRequest(); if ($response) { return self::writeResponse($sink, $response); } PhabricatorStartup::beginStartupPhase('env.init'); try { PhabricatorEnv::initializeWebEnvironment(); $database_exception = null; } catch (PhabricatorClusterStrandedException $ex) { $database_exception = $ex; } if ($database_exception) { $issue = PhabricatorSetupIssue::newDatabaseConnectionIssue( $database_exception, true); $response = PhabricatorSetupCheck::newIssueResponse($issue); return self::writeResponse($sink, $response); } $multimeter->setSampleRate( PhabricatorEnv::getEnvConfig('debug.sample-rate')); $debug_time_limit = PhabricatorEnv::getEnvConfig('debug.time-limit'); if ($debug_time_limit) { PhabricatorStartup::setDebugTimeLimit($debug_time_limit); } // This is the earliest we can get away with this, we need env config first. PhabricatorStartup::beginStartupPhase('log.access'); PhabricatorAccessLog::init(); $access_log = PhabricatorAccessLog::getLog(); PhabricatorStartup::setAccessLog($access_log); $address = PhabricatorEnv::getRemoteAddress(); if ($address) { $address_string = $address->getAddress(); } else { $address_string = '-'; } $access_log->setData( array( 'R' => AphrontRequest::getHTTPHeader('Referer', '-'), 'r' => $address_string, 'M' => idx($_SERVER, 'REQUEST_METHOD', '-'), )); DarkConsoleXHProfPluginAPI::hookProfiler(); // We just activated the profiler, so we don't need to keep track of // startup phases anymore: it can take over from here. PhabricatorStartup::beginStartupPhase('startup.done'); DarkConsoleErrorLogPluginAPI::registerErrorHandler(); $response = PhabricatorSetupCheck::willProcessRequest(); if ($response) { return self::writeResponse($sink, $response); } $host = AphrontRequest::getHTTPHeader('Host'); $path = $_REQUEST['__path__']; switch ($host) { default: $config_key = 'aphront.default-application-configuration-class'; $application = PhabricatorEnv::newObjectFromConfig($config_key); break; } $application->setHost($host); $application->setPath($path); $application->willBuildRequest(); $request = $application->buildRequest(); // Now that we have a request, convert the write guard into one which // actually checks CSRF tokens. $write_guard->dispose(); $write_guard = new AphrontWriteGuard(array($request, 'validateCSRF')); // Build the server URI implied by the request headers. If an administrator // has not configured "phabricator.base-uri" yet, we'll use this to generate // links. $request_protocol = ($request->isHTTPS() ? 'https' : 'http'); $request_base_uri = "{$request_protocol}://{$host}/"; PhabricatorEnv::setRequestBaseURI($request_base_uri); $access_log->setData( array( 'U' => (string)$request->getRequestURI()->getPath(), )); $processing_exception = null; try { $response = $application->processRequest( $request, $access_log, $sink, $multimeter); $response_code = $response->getHTTPResponseCode(); } catch (Exception $ex) { $processing_exception = $ex; $response_code = 500; } $write_guard->dispose(); $access_log->setData( array( 'c' => $response_code, 'T' => PhabricatorStartup::getMicrosecondsSinceStart(), )); $multimeter->newEvent( MultimeterEvent::TYPE_REQUEST_TIME, $multimeter->getEventContext(), PhabricatorStartup::getMicrosecondsSinceStart()); $access_log->write(); $multimeter->saveEvents(); DarkConsoleXHProfPluginAPI::saveProfilerSample($access_log); - // Add points to the rate limits for this request. - $rate_token = PhabricatorStartup::getRateLimitToken(); - if ($rate_token !== null) { - // The base score for a request allows users to make 30 requests per - // minute. - $score = (1000 / 30); - - // If the user was logged in, let them make more requests. - if ($request->getUser() && $request->getUser()->getPHID()) { - $score = $score / 5; - } - - PhabricatorStartup::addRateLimitScore($rate_token, $score); - } + PhabricatorStartup::disconnectRateLimits( + array( + 'viewer' => $request->getUser(), + )); if ($processing_exception) { throw $processing_exception; } } public function processRequest( AphrontRequest $request, PhutilDeferredLog $access_log, AphrontHTTPSink $sink, MultimeterControl $multimeter) { $this->setRequest($request); list($controller, $uri_data) = $this->buildController(); $controller_class = get_class($controller); $access_log->setData( array( 'C' => $controller_class, )); $multimeter->setEventContext('web.'.$controller_class); $request->setController($controller); $request->setURIMap($uri_data); $controller->setRequest($request); // If execution throws an exception and then trying to render that // exception throws another exception, we want to show the original // exception, as it is likely the root cause of the rendering exception. $original_exception = null; try { $response = $controller->willBeginExecution(); if ($request->getUser() && $request->getUser()->getPHID()) { $access_log->setData( array( 'u' => $request->getUser()->getUserName(), 'P' => $request->getUser()->getPHID(), )); $multimeter->setEventViewer('user.'.$request->getUser()->getPHID()); } if (!$response) { $controller->willProcessRequest($uri_data); $response = $controller->handleRequest($request); $this->validateControllerResponse($controller, $response); } } catch (Exception $ex) { $original_exception = $ex; $response = $this->handleThrowable($ex); } catch (Throwable $ex) { $original_exception = $ex; $response = $this->handleThrowable($ex); } try { $response = $this->produceResponse($request, $response); $response = $controller->willSendResponse($response); $response->setRequest($request); self::writeResponse($sink, $response); } catch (Exception $ex) { if ($original_exception) { throw $original_exception; } throw $ex; } return $response; } private static function writeResponse( AphrontHTTPSink $sink, AphrontResponse $response) { $unexpected_output = PhabricatorStartup::endOutputCapture(); if ($unexpected_output) { $unexpected_output = pht( "Unexpected output:\n\n%s", $unexpected_output); phlog($unexpected_output); if ($response instanceof AphrontWebpageResponse) { $response->setUnexpectedOutput($unexpected_output); } } $sink->writeResponse($response); } /* -( URI Routing )-------------------------------------------------------- */ /** * Build a controller to respond to the request. * * @return pair Controller and dictionary of request * parameters. * @task routing */ final private function buildController() { $request = $this->getRequest(); // If we're configured to operate in cluster mode, reject requests which // were not received on a cluster interface. // // For example, a host may have an internal address like "170.0.0.1", and // also have a public address like "51.23.95.16". Assuming the cluster // is configured on a range like "170.0.0.0/16", we want to reject the // requests received on the public interface. // // Ideally, nodes in a cluster should only be listening on internal // interfaces, but they may be configured in such a way that they also // listen on external interfaces, since this is easy to forget about or // get wrong. As a broad security measure, reject requests received on any // interfaces which aren't on the whitelist. $cluster_addresses = PhabricatorEnv::getEnvConfig('cluster.addresses'); if ($cluster_addresses) { $server_addr = idx($_SERVER, 'SERVER_ADDR'); if (!$server_addr) { if (php_sapi_name() == 'cli') { // This is a command line script (probably something like a unit // test) so it's fine that we don't have SERVER_ADDR defined. } else { throw new AphrontMalformedRequestException( pht('No %s', 'SERVER_ADDR'), pht( 'Phabricator is configured to operate in cluster mode, but '. '%s is not defined in the request context. Your webserver '. 'configuration needs to forward %s to PHP so Phabricator can '. 'reject requests received on external interfaces.', 'SERVER_ADDR', 'SERVER_ADDR')); } } else { if (!PhabricatorEnv::isClusterAddress($server_addr)) { throw new AphrontMalformedRequestException( pht('External Interface'), pht( 'Phabricator is configured in cluster mode and the address '. 'this request was received on ("%s") is not whitelisted as '. 'a cluster address.', $server_addr)); } } } $site = $this->buildSiteForRequest($request); if ($site->shouldRequireHTTPS()) { if (!$request->isHTTPS()) { // Don't redirect intracluster requests: doing so drops headers and // parameters, imposes a performance penalty, and indicates a // misconfiguration. if ($request->isProxiedClusterRequest()) { throw new AphrontMalformedRequestException( pht('HTTPS Required'), pht( 'This request reached a site which requires HTTPS, but the '. 'request is not marked as HTTPS.')); } $https_uri = $request->getRequestURI(); $https_uri->setDomain($request->getHost()); $https_uri->setProtocol('https'); // In this scenario, we'll be redirecting to HTTPS using an absolute // URI, so we need to permit an external redirect. return $this->buildRedirectController($https_uri, true); } } $maps = $site->getRoutingMaps(); $path = $request->getPath(); $result = $this->routePath($maps, $path); if ($result) { return $result; } // If we failed to match anything but don't have a trailing slash, try // to add a trailing slash and issue a redirect if that resolves. // NOTE: We only do this for GET, since redirects switch to GET and drop // data like POST parameters. if (!preg_match('@/$@', $path) && $request->isHTTPGet()) { $result = $this->routePath($maps, $path.'/'); if ($result) { $target_uri = $request->getAbsoluteRequestURI(); // We need to restore URI encoding because the webserver has // interpreted it. For example, this allows us to redirect a path // like `/tag/aa%20bb` to `/tag/aa%20bb/`, which may eventually be // resolved meaningfully by an application. $target_path = phutil_escape_uri($path.'/'); $target_uri->setPath($target_path); $target_uri = (string)$target_uri; return $this->buildRedirectController($target_uri, true); } } $result = $site->new404Controller($request); if ($result) { return array($result, array()); } return $this->build404Controller(); } /** * Map a specific path to the corresponding controller. For a description * of routing, see @{method:buildController}. * * @param list List of routing maps. * @param string Path to route. * @return pair Controller and dictionary of request * parameters. * @task routing */ private function routePath(array $maps, $path) { foreach ($maps as $map) { $result = $map->routePath($path); if ($result) { return array($result->getController(), $result->getURIData()); } } } private function buildSiteForRequest(AphrontRequest $request) { $sites = PhabricatorSite::getAllSites(); $site = null; foreach ($sites as $candidate) { $site = $candidate->newSiteForRequest($request); if ($site) { break; } } if (!$site) { $path = $request->getPath(); $host = $request->getHost(); throw new AphrontMalformedRequestException( pht('Site Not Found'), pht( 'This request asked for "%s" on host "%s", but no site is '. 'configured which can serve this request.', $path, $host), true); } $request->setSite($site); return $site; } /* -( Response Handling )-------------------------------------------------- */ /** * Tests if a response is of a valid type. * * @param wild Supposedly valid response. * @return bool True if the object is of a valid type. * @task response */ private function isValidResponseObject($response) { if ($response instanceof AphrontResponse) { return true; } if ($response instanceof AphrontResponseProducerInterface) { return true; } return false; } /** * Verifies that the return value from an @{class:AphrontController} is * of an allowed type. * * @param AphrontController Controller which returned the response. * @param wild Supposedly valid response. * @return void * @task response */ private function validateControllerResponse( AphrontController $controller, $response) { if ($this->isValidResponseObject($response)) { return; } throw new Exception( pht( 'Controller "%s" returned an invalid response from call to "%s". '. 'This method must return an object of class "%s", or an object '. 'which implements the "%s" interface.', get_class($controller), 'handleRequest()', 'AphrontResponse', 'AphrontResponseProducerInterface')); } /** * Verifies that the return value from an * @{class:AphrontResponseProducerInterface} is of an allowed type. * * @param AphrontResponseProducerInterface Object which produced * this response. * @param wild Supposedly valid response. * @return void * @task response */ private function validateProducerResponse( AphrontResponseProducerInterface $producer, $response) { if ($this->isValidResponseObject($response)) { return; } throw new Exception( pht( 'Producer "%s" returned an invalid response from call to "%s". '. 'This method must return an object of class "%s", or an object '. 'which implements the "%s" interface.', get_class($producer), 'produceAphrontResponse()', 'AphrontResponse', 'AphrontResponseProducerInterface')); } /** * Verifies that the return value from an * @{class:AphrontRequestExceptionHandler} is of an allowed type. * * @param AphrontRequestExceptionHandler Object which produced this * response. * @param wild Supposedly valid response. * @return void * @task response */ private function validateErrorHandlerResponse( AphrontRequestExceptionHandler $handler, $response) { if ($this->isValidResponseObject($response)) { return; } throw new Exception( pht( 'Exception handler "%s" returned an invalid response from call to '. '"%s". This method must return an object of class "%s", or an object '. 'which implements the "%s" interface.', get_class($handler), 'handleRequestException()', 'AphrontResponse', 'AphrontResponseProducerInterface')); } /** * Resolves a response object into an @{class:AphrontResponse}. * * Controllers are permitted to return actual responses of class * @{class:AphrontResponse}, or other objects which implement * @{interface:AphrontResponseProducerInterface} and can produce a response. * * If a controller returns a response producer, invoke it now and produce * the real response. * * @param AphrontRequest Request being handled. * @param AphrontResponse|AphrontResponseProducerInterface Response, or * response producer. * @return AphrontResponse Response after any required production. * @task response */ private function produceResponse(AphrontRequest $request, $response) { $original = $response; // Detect cycles on the exact same objects. It's still possible to produce // infinite responses as long as they're all unique, but we can only // reasonably detect cycles, not guarantee that response production halts. $seen = array(); while (true) { // NOTE: It is permissible for an object to be both a response and a // response producer. If so, being a producer is "stronger". This is // used by AphrontProxyResponse. // If this response is a valid response, hand over the request first. if ($response instanceof AphrontResponse) { $response->setRequest($request); } // If this isn't a producer, we're all done. if (!($response instanceof AphrontResponseProducerInterface)) { break; } $hash = spl_object_hash($response); if (isset($seen[$hash])) { throw new Exception( pht( 'Failure while producing response for object of class "%s": '. 'encountered production cycle (identical object, of class "%s", '. 'was produced twice).', get_class($original), get_class($response))); } $seen[$hash] = true; $new_response = $response->produceAphrontResponse(); $this->validateProducerResponse($response, $new_response); $response = $new_response; } return $response; } /* -( Error Handling )----------------------------------------------------- */ /** * Convert an exception which has escaped the controller into a response. * * This method delegates exception handling to available subclasses of * @{class:AphrontRequestExceptionHandler}. * * @param Throwable Exception which needs to be handled. * @return wild Response or response producer, or null if no available * handler can produce a response. * @task exception */ private function handleThrowable($throwable) { $handlers = AphrontRequestExceptionHandler::getAllHandlers(); $request = $this->getRequest(); foreach ($handlers as $handler) { if ($handler->canHandleRequestThrowable($request, $throwable)) { $response = $handler->handleRequestThrowable($request, $throwable); $this->validateErrorHandlerResponse($handler, $response); return $response; } } throw $throwable; } private static function newSelfCheckResponse() { $path = idx($_REQUEST, '__path__', ''); $query = idx($_SERVER, 'QUERY_STRING', ''); $pairs = id(new PhutilQueryStringParser()) ->parseQueryStringToPairList($query); $params = array(); foreach ($pairs as $v) { $params[] = array( 'name' => $v[0], 'value' => $v[1], ); } $result = array( 'path' => $path, 'params' => $params, 'user' => idx($_SERVER, 'PHP_AUTH_USER'), 'pass' => idx($_SERVER, 'PHP_AUTH_PW'), // This just makes sure that the response compresses well, so reasonable // algorithms should want to gzip or deflate it. 'filler' => str_repeat('Q', 1024 * 16), ); return id(new AphrontJSONResponse()) ->setAddJSONShield(false) ->setContent($result); } } diff --git a/src/applications/base/controller/__tests__/PhabricatorAccessControlTestCase.php b/src/applications/base/controller/__tests__/PhabricatorAccessControlTestCase.php index 27e716556e..b6c268d88c 100644 --- a/src/applications/base/controller/__tests__/PhabricatorAccessControlTestCase.php +++ b/src/applications/base/controller/__tests__/PhabricatorAccessControlTestCase.php @@ -1,280 +1,280 @@ true, ); } public function testControllerAccessControls() { $root = dirname(phutil_get_library_root('phabricator')); - require_once $root.'/support/PhabricatorStartup.php'; + require_once $root.'/support/startup/PhabricatorStartup.php'; $application_configuration = new AphrontDefaultApplicationConfiguration(); $host = 'meow.example.com'; $_SERVER['REQUEST_METHOD'] = 'GET'; $request = id(new AphrontRequest($host, '/')) ->setApplicationConfiguration($application_configuration) ->setRequestData(array()); $controller = new PhabricatorTestController(); $controller->setRequest($request); $u_public = id(new PhabricatorUser()) ->setUsername('public'); $u_unverified = $this->generateNewTestUser() ->setUsername('unverified') ->save(); $u_unverified->setIsEmailVerified(0)->save(); $u_normal = $this->generateNewTestUser() ->setUsername('normal') ->save(); $u_disabled = $this->generateNewTestUser() ->setIsDisabled(true) ->setUsername('disabled') ->save(); $u_admin = $this->generateNewTestUser() ->setIsAdmin(true) ->setUsername('admin') ->save(); $u_notapproved = $this->generateNewTestUser() ->setIsApproved(0) ->setUsername('notapproved') ->save(); $env = PhabricatorEnv::beginScopedEnv(); $env->overrideEnvConfig('phabricator.base-uri', 'http://'.$host); $env->overrideEnvConfig('policy.allow-public', false); $env->overrideEnvConfig('auth.require-email-verification', false); $env->overrideEnvConfig('security.require-multi-factor-auth', false); // Test standard defaults. $this->checkAccess( pht('Default'), id(clone $controller), $request, array( $u_normal, $u_admin, $u_unverified, ), array( $u_public, $u_disabled, $u_notapproved, )); // Test email verification. $env->overrideEnvConfig('auth.require-email-verification', true); $this->checkAccess( pht('Email Verification Required'), id(clone $controller), $request, array( $u_normal, $u_admin, ), array( $u_unverified, $u_public, $u_disabled, $u_notapproved, )); $this->checkAccess( pht('Email Verification Required, With Exception'), id(clone $controller)->setConfig('email', false), $request, array( $u_normal, $u_admin, $u_unverified, ), array( $u_public, $u_disabled, $u_notapproved, )); $env->overrideEnvConfig('auth.require-email-verification', false); // Test admin access. $this->checkAccess( pht('Admin Required'), id(clone $controller)->setConfig('admin', true), $request, array( $u_admin, ), array( $u_normal, $u_unverified, $u_public, $u_disabled, $u_notapproved, )); // Test disabled access. $this->checkAccess( pht('Allow Disabled'), id(clone $controller)->setConfig('enabled', false), $request, array( $u_normal, $u_unverified, $u_admin, $u_disabled, $u_notapproved, ), array( $u_public, )); // Test no login required. $this->checkAccess( pht('No Login Required'), id(clone $controller)->setConfig('login', false), $request, array( $u_normal, $u_unverified, $u_admin, $u_public, ), array( $u_disabled, $u_notapproved, )); // Test public access. $this->checkAccess( pht('Public Access'), id(clone $controller)->setConfig('public', true), $request, array( $u_normal, $u_unverified, $u_admin, ), array( $u_disabled, $u_public, )); $env->overrideEnvConfig('policy.allow-public', true); $this->checkAccess( pht('Public + configured'), id(clone $controller)->setConfig('public', true), $request, array( $u_normal, $u_unverified, $u_admin, $u_public, ), array( $u_disabled, $u_notapproved, )); $env->overrideEnvConfig('policy.allow-public', false); $app = PhabricatorApplication::getByClass('PhabricatorTestApplication'); $app->reset(); $app->setPolicy( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicies::POLICY_NOONE); $app_controller = id(clone $controller)->setCurrentApplication($app); $this->checkAccess( pht('Application Controller'), $app_controller, $request, array( ), array( $u_normal, $u_unverified, $u_admin, $u_public, $u_disabled, $u_notapproved, )); $this->checkAccess( pht('Application Controller'), id(clone $app_controller)->setConfig('login', false), $request, array( $u_normal, $u_unverified, $u_admin, $u_public, ), array( $u_disabled, $u_notapproved, )); } private function checkAccess( $label, $controller, $request, array $yes, array $no) { foreach ($yes as $user) { $request->setUser($user); $uname = $user->getUsername(); try { $result = id(clone $controller)->willBeginExecution(); } catch (Exception $ex) { $result = $ex; } $this->assertTrue( ($result === null), pht("Expect user '%s' to be allowed access to '%s'.", $uname, $label)); } foreach ($no as $user) { $request->setUser($user); $uname = $user->getUsername(); try { $result = id(clone $controller)->willBeginExecution(); } catch (Exception $ex) { $result = $ex; } $this->assertFalse( ($result === null), pht("Expect user '%s' to be denied access to '%s'.", $uname, $label)); } } } diff --git a/support/startup/PhabricatorClientConnectionLimit.php b/support/startup/PhabricatorClientConnectionLimit.php new file mode 100644 index 0000000000..4a66d05739 --- /dev/null +++ b/support/startup/PhabricatorClientConnectionLimit.php @@ -0,0 +1,44 @@ + $this->getLimit()); + } + + protected function getConnectScore() { + return 1; + } + + protected function getPenaltyScore() { + return 0; + } + + protected function getDisconnectScore(array $request_state) { + return -1; + } + + protected function getRateLimitReason($score) { + $client_key = $this->getClientKey(); + + // NOTE: This happens before we load libraries, so we can not use pht() + // here. + + return + "TOO MANY CONCURRENT CONNECTIONS\n". + "You (\"{$client_key}\") have too many concurrent ". + "connections.\n"; + } + +} diff --git a/support/startup/PhabricatorClientLimit.php b/support/startup/PhabricatorClientLimit.php new file mode 100644 index 0000000000..bab90c6f15 --- /dev/null +++ b/support/startup/PhabricatorClientLimit.php @@ -0,0 +1,291 @@ +limitKey = $limit_key; + return $this; + } + + final public function getLimitKey() { + return $this->limitKey; + } + + final public function setClientKey($client_key) { + $this->clientKey = $client_key; + return $this; + } + + final public function getClientKey() { + return $this->clientKey; + } + + final public function setLimit($limit) { + $this->limit = $limit; + return $this; + } + + final public function getLimit() { + return $this->limit; + } + + final public function didConnect() { + // NOTE: We can not use pht() here because this runs before libraries + // load. + + if (!function_exists('apc_fetch') && !function_exists('apcu_fetch')) { + throw new Exception( + 'You can not configure connection rate limits unless APC/APCu are '. + 'available. Rate limits rely on APC/APCu to track clients and '. + 'connections.'); + } + + if ($this->getClientKey() === null) { + throw new Exception( + 'You must configure a client key when defining a rate limit.'); + } + + if ($this->getLimitKey() === null) { + throw new Exception( + 'You must configure a limit key when defining a rate limit.'); + } + + if ($this->getLimit() === null) { + throw new Exception( + 'You must configure a limit when defining a rate limit.'); + } + + $points = $this->getConnectScore(); + if ($points) { + $this->addScore($points); + } + + $score = $this->getScore(); + if (!$this->shouldRejectConnection($score)) { + // Client has not hit the limit, so continue processing the request. + return null; + } + + $penalty = $this->getPenaltyScore(); + if ($penalty) { + $this->addScore($penalty); + $score += $penalty; + } + + return $this->getRateLimitReason($score); + } + + final public function didDisconnect(array $request_state) { + $score = $this->getDisconnectScore($request_state); + if ($score) { + $this->addScore($score); + } + } + + + /** + * Get the number of seconds for each rate bucket. + * + * For example, a value of 60 will create one-minute buckets. + * + * @return int Number of seconds per bucket. + */ + abstract protected function getBucketDuration(); + + + /** + * Get the total number of rate limit buckets to retain. + * + * @return int Total number of rate limit buckets to retain. + */ + abstract protected function getBucketCount(); + + + /** + * Get the score to add when a client connects. + * + * @return double Connection score. + */ + abstract protected function getConnectScore(); + + + /** + * Get the number of penalty points to add when a client hits a rate limit. + * + * @return double Penalty score. + */ + abstract protected function getPenaltyScore(); + + + /** + * Get the score to add when a client disconnects. + * + * @return double Connection score. + */ + abstract protected function getDisconnectScore(array $request_state); + + + /** + * Get a human-readable explanation of why the client is being rejected. + * + * @return string Brief rejection message. + */ + abstract protected function getRateLimitReason($score); + + + /** + * Determine whether to reject a connection. + * + * @return bool True to reject the connection. + */ + abstract protected function shouldRejectConnection($score); + + + /** + * Get the APC key for the smallest stored bucket. + * + * @return string APC key for the smallest stored bucket. + * @task ratelimit + */ + private function getMinimumBucketCacheKey() { + $limit_key = $this->getLimitKey(); + return "limit:min:{$limit_key}"; + } + + + /** + * Get the current bucket ID for storing rate limit scores. + * + * @return int The current bucket ID. + */ + private function getCurrentBucketID() { + return (int)(time() / $this->getBucketDuration()); + } + + + /** + * Get the APC key for a given bucket. + * + * @param int Bucket to get the key for. + * @return string APC key for the bucket. + */ + private function getBucketCacheKey($bucket_id) { + $limit_key = $this->getLimitKey(); + return "limit:bucket:{$limit_key}:{$bucket_id}"; + } + + + /** + * Add points to the rate limit score for some client. + * + * @param string Some key which identifies the client making the request. + * @param float The cost for this request; more points pushes them toward + * the limit faster. + * @return this + */ + private function addScore($score) { + $is_apcu = (bool)function_exists('apcu_fetch'); + + $current = $this->getCurrentBucketID(); + $bucket_key = $this->getBucketCacheKey($current); + + // 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 client's score. If they're making requests fast enough to hit + // rate limiting, we'll get them soon enough. + + if ($is_apcu) { + $bucket = apcu_fetch($bucket_key); + } else { + $bucket = apc_fetch($bucket_key); + } + + if (!is_array($bucket)) { + $bucket = array(); + } + + $client_key = $this->getClientKey(); + if (empty($bucket[$client_key])) { + $bucket[$client_key] = 0; + } + + $bucket[$client_key] += $score; + + if ($is_apcu) { + apcu_store($bucket_key, $bucket); + } else { + apc_store($bucket_key, $bucket); + } + + return $this; + } + + + /** + * Get the current rate limit score for a given client. + * + * @return float The client's current score. + * @task ratelimit + */ + private function getScore() { + $is_apcu = (bool)function_exists('apcu_fetch'); + + // Identify the oldest bucket stored in APC. + $min_key = $this->getMinimumBucketCacheKey(); + if ($is_apcu) { + $min = apcu_fetch($min_key); + } else { + $min = apc_fetch($min_key); + } + + // If we don't have any buckets stored yet, store the current bucket as + // the oldest bucket. + $cur = $this->getCurrentBucketID(); + if (!$min) { + if ($is_apcu) { + apcu_store($min_key, $cur); + } else { + 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 = $this->getBucketCount(); + for ($cursor = $min; $cursor < ($cur - $count); $cursor++) { + $bucket_key = $this->getBucketCacheKey($cursor); + if ($is_apcu) { + apcu_delete($bucket_key); + apcu_store($min_key, $cursor + 1); + } else { + apc_delete($bucket_key); + apc_store($min_key, $cursor + 1); + } + } + + $client_key = $this->getClientKey(); + + // Now, sum up the client's scores in all of the active buckets. + $score = 0; + for (; $cursor <= $cur; $cursor++) { + $bucket_key = $this->getBucketCacheKey($cursor); + if ($is_apcu) { + $bucket = apcu_fetch($bucket_key); + } else { + $bucket = apc_fetch($bucket_key); + } + if (isset($bucket[$client_key])) { + $score += $bucket[$client_key]; + } + } + + return $score; + } + +} diff --git a/support/startup/PhabricatorClientRateLimit.php b/support/startup/PhabricatorClientRateLimit.php new file mode 100644 index 0000000000..85a6def878 --- /dev/null +++ b/support/startup/PhabricatorClientRateLimit.php @@ -0,0 +1,58 @@ +getLimit(); + + // Reject connections if the average score across all buckets exceeds the + // limit. + $average_score = $score / $this->getBucketCount(); + + return ($average_score > $limit); + } + + protected function getConnectScore() { + return 0; + } + + protected function getPenaltyScore() { + return 1; + } + + protected function getDisconnectScore(array $request_state) { + $score = 1; + + // If the user was logged in, let them make more requests. + if (isset($request_state['viewer'])) { + $viewer = $request_state['viewer']; + if ($viewer->isLoggedIn()) { + $score = 0.25; + } + } + + return $score; + } + + protected function getRateLimitReason($score) { + $client_key = $this->getClientKey(); + + // NOTE: This happens before we load libraries, so we can not use pht() + // here. + + return + "TOO MANY REQUESTS\n". + "You (\"{$client_key}\") are issuing too many requests ". + "too quickly.\n"; + } + +} diff --git a/support/PhabricatorStartup.php b/support/startup/PhabricatorStartup.php similarity index 73% rename from support/PhabricatorStartup.php rename to support/startup/PhabricatorStartup.php index 3d771a24f7..41164fbd84 100644 --- a/support/PhabricatorStartup.php +++ b/support/startup/PhabricatorStartup.php @@ -1,1027 +1,812 @@ setEncoding($encoding); } $input = ''; do { $bytes = $stream->readData(); if ($bytes === null) { break; } $input .= $bytes; } while (true); self::$rawInput = $input; } return self::$rawInput; } /* -( Startup Hooks )------------------------------------------------------ */ /** * @param float Request start time, from `microtime(true)`. * @task hook */ public static function didStartup($start_time) { self::$startTime = $start_time; self::$phases = array(); self::$accessLog = null; static $registered; if (!$registered) { // NOTE: This protects us against multiple calls to didStartup() in the // same request, but also against repeated requests to the same // interpreter state, which we may implement in the future. register_shutdown_function(array(__CLASS__, 'didShutdown')); $registered = true; } self::setupPHP(); self::verifyPHP(); // If we've made it this far, the environment isn't completely broken so // we can switch over to relying on our own exception recovery mechanisms. ini_set('display_errors', 0); - $rate_token = self::getRateLimitToken(); - if ($rate_token !== null) { - self::rateLimitRequest($rate_token); - } + self::connectRateLimits(); self::normalizeInput(); self::verifyRewriteRules(); self::detectPostMaxSizeTriggered(); self::beginOutputCapture(); } /** * @task hook */ public static function didShutdown() { $event = error_get_last(); if (!$event) { return; } switch ($event['type']) { case E_ERROR: case E_PARSE: case E_COMPILE_ERROR: break; default: return; } $msg = ">>> UNRECOVERABLE FATAL ERROR <<<\n\n"; if ($event) { // Even though we should be emitting this as text-plain, escape things // just to be sure since we can't really be sure what the program state // is when we get here. $msg .= htmlspecialchars( $event['message']."\n\n".$event['file'].':'.$event['line'], ENT_QUOTES, 'UTF-8'); } // flip dem tables $msg .= "\n\n\n"; $msg .= "\xe2\x94\xbb\xe2\x94\x81\xe2\x94\xbb\x20\xef\xb8\xb5\x20\xc2\xaf". "\x5c\x5f\x28\xe3\x83\x84\x29\x5f\x2f\xc2\xaf\x20\xef\xb8\xb5\x20". "\xe2\x94\xbb\xe2\x94\x81\xe2\x94\xbb"; self::didFatal($msg); } public static function loadCoreLibraries() { - $phabricator_root = dirname(dirname(__FILE__)); + $phabricator_root = dirname(dirname(dirname(__FILE__))); $libraries_root = dirname($phabricator_root); $root = null; if (!empty($_SERVER['PHUTIL_LIBRARY_ROOT'])) { $root = $_SERVER['PHUTIL_LIBRARY_ROOT']; } ini_set( 'include_path', $libraries_root.PATH_SEPARATOR.ini_get('include_path')); @include_once $root.'libphutil/src/__phutil_library_init__.php'; if (!@constant('__LIBPHUTIL__')) { self::didFatal( "Unable to load libphutil. Put libphutil/ next to phabricator/, or ". "update your PHP 'include_path' to include the parent directory of ". "libphutil/."); } phutil_load_library('arcanist/src'); // Load Phabricator itself using the absolute path, so we never end up doing // anything surprising (loading index.php and libraries from different // directories). phutil_load_library($phabricator_root.'/src'); } /* -( Output Capture )----------------------------------------------------- */ public static function beginOutputCapture() { if (self::$capturingOutput) { self::didFatal('Already capturing output!'); } self::$capturingOutput = true; ob_start(); } public static function endOutputCapture() { if (!self::$capturingOutput) { return null; } self::$capturingOutput = false; return ob_get_clean(); } /* -( Debug Time Limit )--------------------------------------------------- */ /** * Set a time limit (in seconds) for the current script. After time expires, * the script fatals. * * This works like `max_execution_time`, but prints out a useful stack trace * when the time limit expires. This is primarily intended to make it easier * to debug pages which hang by allowing extraction of a stack trace: set a * short debug limit, then use the trace to figure out what's happening. * * The limit is implemented with a tick function, so enabling it implies * some accounting overhead. * * @param int Time limit in seconds. * @return void */ public static function setDebugTimeLimit($limit) { self::$debugTimeLimit = $limit; static $initialized; if (!$initialized) { declare(ticks=1); register_tick_function(array(__CLASS__, 'onDebugTick')); } } /** * Callback tick function used by @{method:setDebugTimeLimit}. * * Fatals with a useful stack trace after the time limit expires. * * @return void */ public static function onDebugTick() { $limit = self::$debugTimeLimit; if (!$limit) { return; } $elapsed = (microtime(true) - self::getStartTime()); if ($elapsed > $limit) { $frames = array(); foreach (debug_backtrace() as $frame) { $file = isset($frame['file']) ? $frame['file'] : '-'; $file = basename($file); $line = isset($frame['line']) ? $frame['line'] : '-'; $class = isset($frame['class']) ? $frame['class'].'->' : null; $func = isset($frame['function']) ? $frame['function'].'()' : '?'; $frames[] = "{$file}:{$line} {$class}{$func}"; } self::didFatal( "Request aborted by debug time limit after {$limit} seconds.\n\n". "STACK TRACE\n". implode("\n", $frames)); } } /* -( In Case of Apocalypse )---------------------------------------------- */ /** * Fatal the request completely in response to an exception, sending a plain * text message to the client. Calls @{method:didFatal} internally. * * @param string Brief description of the exception context, like * `"Rendering Exception"`. * @param Exception The exception itself. * @param bool True if it's okay to show the exception's stack trace * to the user. The trace will always be logged. * @return exit This method **does not return**. * * @task apocalypse */ public static function didEncounterFatalException( $note, Exception $ex, $show_trace) { $message = '['.$note.'/'.get_class($ex).'] '.$ex->getMessage(); $full_message = $message; $full_message .= "\n\n"; $full_message .= $ex->getTraceAsString(); if ($show_trace) { $message = $full_message; } self::didFatal($message, $full_message); } /** * Fatal the request completely, sending a plain text message to the client. * * @param string Plain text message to send to the client. * @param string Plain text message to send to the error log. If not * provided, the client message is used. You can pass a more * detailed message here (e.g., with stack traces) to avoid * showing it to users. * @return exit This method **does not return**. * * @task apocalypse */ public static function didFatal($message, $log_message = null) { if ($log_message === null) { $log_message = $message; } self::endOutputCapture(); $access_log = self::$accessLog; if ($access_log) { // We may end up here before the access log is initialized, e.g. from // verifyPHP(). $access_log->setData( array( 'c' => 500, )); $access_log->write(); } header( 'Content-Type: text/plain; charset=utf-8', $replace = true, $http_error = 500); error_log($log_message); echo $message."\n"; exit(1); } /* -( Validation )--------------------------------------------------------- */ /** * @task validation */ private static function setupPHP() { error_reporting(E_ALL | E_STRICT); self::$oldMemoryLimit = ini_get('memory_limit'); ini_set('memory_limit', -1); // If we have libxml, disable the incredibly dangerous entity loader. if (function_exists('libxml_disable_entity_loader')) { libxml_disable_entity_loader(true); } } /** * @task validation */ public static function getOldMemoryLimit() { return self::$oldMemoryLimit; } /** * @task validation */ private static function normalizeInput() { // Replace superglobals with unfiltered versions, disrespect php.ini (we // filter ourselves). // NOTE: We don't filter INPUT_SERVER because we don't want to overwrite // changes made in "preamble.php". // NOTE: WE don't filter INPUT_POST because we may be constructing it // lazily if "enable_post_data_reading" is disabled. $filter = array( INPUT_GET, INPUT_ENV, INPUT_COOKIE, ); foreach ($filter as $type) { $filtered = filter_input_array($type, FILTER_UNSAFE_RAW); if (!is_array($filtered)) { continue; } switch ($type) { case INPUT_GET: $_GET = array_merge($_GET, $filtered); break; case INPUT_COOKIE: $_COOKIE = array_merge($_COOKIE, $filtered); break; case INPUT_ENV; $env = array_merge($_ENV, $filtered); $_ENV = self::filterEnvSuperglobal($env); break; } } self::rebuildRequest(); } /** * @task validation */ public static function rebuildRequest() { // Rebuild $_REQUEST, respecting order declared in ".ini" files. $order = ini_get('request_order'); if (!$order) { $order = ini_get('variables_order'); } if (!$order) { // $_REQUEST will be empty, so leave it alone. return; } $_REQUEST = array(); for ($ii = 0; $ii < strlen($order); $ii++) { switch ($order[$ii]) { case 'G': $_REQUEST = array_merge($_REQUEST, $_GET); break; case 'P': $_REQUEST = array_merge($_REQUEST, $_POST); break; case 'C': $_REQUEST = array_merge($_REQUEST, $_COOKIE); break; default: // $_ENV and $_SERVER never go into $_REQUEST. break; } } } /** * Adjust `$_ENV` before execution. * * Adjustments here primarily impact the environment as seen by subprocesses. * The environment is forwarded explicitly by @{class:ExecFuture}. * * @param map Input `$_ENV`. * @return map Suitable `$_ENV`. * @task validation */ private static function filterEnvSuperglobal(array $env) { // In some configurations, we may get "argc" and "argv" set in $_ENV. // These are not real environmental variables, and "argv" may have an array // value which can not be forwarded to subprocesses. Remove these from the // environment if they are present. unset($env['argc']); unset($env['argv']); return $env; } /** * @task validation */ private static function verifyPHP() { $required_version = '5.2.3'; if (version_compare(PHP_VERSION, $required_version) < 0) { self::didFatal( "You are running PHP version '".PHP_VERSION."', which is older than ". "the minimum version, '{$required_version}'. Update to at least ". "'{$required_version}'."); } if (get_magic_quotes_gpc()) { self::didFatal( "Your server is configured with PHP 'magic_quotes_gpc' enabled. This ". "feature is 'highly discouraged' by PHP's developers and you must ". "disable it to run Phabricator. Consult the PHP manual for ". "instructions."); } if (extension_loaded('apc')) { $apc_version = phpversion('apc'); $known_bad = array( '3.1.14' => true, '3.1.15' => true, '3.1.15-dev' => true, ); if (isset($known_bad[$apc_version])) { self::didFatal( "You have APC {$apc_version} installed. This version of APC is ". "known to be bad, and does not work with Phabricator (it will ". "cause Phabricator to fatal unrecoverably with nonsense errors). ". "Downgrade to version 3.1.13."); } } if (isset($_SERVER['HTTP_PROXY'])) { self::didFatal( 'This HTTP request included a "Proxy:" header, poisoning the '. 'environment (CVE-2016-5385 / httpoxy). Declining to process this '. 'request. For details, see: https://phurl.io/u/httpoxy'); } } /** * @task validation */ private static function verifyRewriteRules() { if (isset($_REQUEST['__path__']) && strlen($_REQUEST['__path__'])) { return; } if (php_sapi_name() == 'cli-server') { // Compatibility with PHP 5.4+ built-in web server. $url = parse_url($_SERVER['REQUEST_URI']); $_REQUEST['__path__'] = $url['path']; return; } if (!isset($_REQUEST['__path__'])) { self::didFatal( "Request parameter '__path__' is not set. Your rewrite rules ". "are not configured correctly."); } if (!strlen($_REQUEST['__path__'])) { self::didFatal( "Request parameter '__path__' is set, but empty. Your rewrite rules ". "are not configured correctly. The '__path__' should always ". "begin with a '/'."); } } /** * Detect if this request has had its POST data stripped by exceeding the * 'post_max_size' PHP configuration limit. * * PHP has a setting called 'post_max_size'. If a POST request arrives with * a body larger than the limit, PHP doesn't generate $_POST but processes * the request anyway, and provides no formal way to detect that this * happened. * * We can still read the entire body out of `php://input`. However according * to the documentation the stream isn't available for "multipart/form-data" * (on nginx + php-fpm it appears that it is available, though, at least) so * any attempt to generate $_POST would be fragile. * * @task validation */ private static function detectPostMaxSizeTriggered() { // If this wasn't a POST, we're fine. if ($_SERVER['REQUEST_METHOD'] != 'POST') { return; } // If "enable_post_data_reading" is off, we won't have $_POST and this // condition is effectively impossible. if (!ini_get('enable_post_data_reading')) { return; } // If there's POST data, clearly we're in good shape. if ($_POST) { return; } // For HTML5 drag-and-drop file uploads, Safari submits the data as // "application/x-www-form-urlencoded". For most files this generates // something in POST because most files decode to some nonempty (albeit // meaningless) value. However, some files (particularly small images) // don't decode to anything. If we know this is a drag-and-drop upload, // we can skip this check. if (isset($_REQUEST['__upload__'])) { return; } // PHP generates $_POST only for two content types. This routing happens // in `main/php_content_types.c` in PHP. Normally, all forms use one of // these content types, but some requests may not -- for example, Firefox // submits files sent over HTML5 XMLHTTPRequest APIs with the Content-Type // of the file itself. If we don't have a recognized content type, we // don't need $_POST. // // NOTE: We use strncmp() because the actual content type may be something // like "multipart/form-data; boundary=...". // // NOTE: Chrome sometimes omits this header, see some discussion in T1762 // and http://code.google.com/p/chromium/issues/detail?id=6800 $content_type = isset($_SERVER['CONTENT_TYPE']) ? $_SERVER['CONTENT_TYPE'] : ''; $parsed_types = array( 'application/x-www-form-urlencoded', 'multipart/form-data', ); $is_parsed_type = false; foreach ($parsed_types as $parsed_type) { if (strncmp($content_type, $parsed_type, strlen($parsed_type)) === 0) { $is_parsed_type = true; break; } } if (!$is_parsed_type) { return; } // Check for 'Content-Length'. If there's no data, we don't expect $_POST // to exist. $length = (int)$_SERVER['CONTENT_LENGTH']; if (!$length) { return; } // Time to fatal: we know this was a POST with data that should have been // populated into $_POST, but it wasn't. $config = ini_get('post_max_size'); self::didFatal( "As received by the server, this request had a nonzero content length ". "but no POST data.\n\n". "Normally, this indicates that it exceeds the 'post_max_size' setting ". "in the PHP configuration on the server. Increase the 'post_max_size' ". "setting or reduce the size of the request.\n\n". "Request size according to 'Content-Length' was '{$length}', ". "'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; - } - - - /** - * Set a token to identify the client for purposes of rate limiting. - * - * By default, the `REMOTE_ADDR` is used. If your install is behind a load - * balancer, you may want to parse `X-Forwarded-For` and use that address - * instead. - * - * @param string Client identity for rate limiting. - */ - public static function setRateLimitToken($token) { - self::$rateLimitToken = $token; - } - - - /** - * Get the current client identity for rate limiting. - */ - public static function getRateLimitToken() { - if (self::$rateLimitToken !== null) { - return self::$rateLimitToken; - } - - if (isset($_SERVER['REMOTE_ADDR'])) { - return $_SERVER['REMOTE_ADDR']; - } - - return null; - } - - - /** - * 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. + * Add a new client 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 + * @param PhabricatorClientLimit New limit. + * @return PhabricatorClientLimit The limit. */ - public static function rateLimitRequest($user_identity) { - if (!self::canRateLimit()) { - return; - } - - $score = self::getRateLimitScore($user_identity); - $limit = self::$maximumRate * self::getRateLimitBucketCount(); - if ($score > $limit) { - // 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. - $penalty = 50; - - self::addRateLimitScore($user_identity, $penalty); - $score += $penalty; - - self::didRateLimit($user_identity, $score, $limit); - } + public static function addRateLimit(PhabricatorClientLimit $limit) { + self::$limits[] = $limit; + return $limit; } /** - * Add points to the rate limit score for some user. + * Apply configured rate limits. * - * 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. + * If any limit is exceeded, this method terminates the request. * - * @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; - } - - $is_apcu = (bool)function_exists('apcu_fetch'); - $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 enough. + private static function connectRateLimits() { + $limits = self::$limits; - $bucket_key = self::getRateLimitBucketKey($current); - if ($is_apcu) { - $bucket = apcu_fetch($bucket_key); - } else { - $bucket = apc_fetch($bucket_key); - } - - if (!is_array($bucket)) { - $bucket = array(); - } - - if (empty($bucket[$user_identity])) { - $bucket[$user_identity] = 0; + $reason = null; + $connected = array(); + foreach ($limits as $limit) { + $reason = $limit->didConnect(); + $connected[] = $limit; + if ($reason !== null) { + break; + } } - $bucket[$user_identity] += $score; + // If we're killing the request here, disconnect any limits that we + // connected to try to keep the accounting straight. + if ($reason !== null) { + foreach ($connected as $limit) { + $limit->didDisconnect(array()); + } - if ($is_apcu) { - apcu_store($bucket_key, $bucket); - } else { - apc_store($bucket_key, $bucket); + self::didRateLimit($reason); } } /** - * Determine if rate limiting is available. - * - * Rate limiting depends on APC, and isn't available unless the APC user - * cache is available. + * Tear down rate limiting and allow limits to score the request. * - * @return bool True if rate limiting is available. + * @param map Additional, freeform request state. + * @return void * @task ratelimit */ - private static function canRateLimit() { + public static function disconnectRateLimits(array $request_state) { + $limits = self::$limits; - if (!self::$maximumRate) { - return false; + foreach ($limits as $limit) { + $limit->didDisconnect($request_state); } - - if (!function_exists('apc_fetch') && !function_exists('apcu_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) { - $is_apcu = (bool)function_exists('apcu_fetch'); - - $min_key = self::getRateLimitMinKey(); - - // Identify the oldest bucket stored in APC. - $cur = self::getRateLimitBucket(); - if ($is_apcu) { - $min = apcu_fetch($min_key); - } else { - $min = apc_fetch($min_key); - } - - // If we don't have any buckets stored yet, store the current bucket as - // the oldest bucket. - if (!$min) { - if ($is_apcu) { - apcu_store($min_key, $cur); - } else { - 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++) { - $bucket_key = self::getRateLimitBucketKey($cursor); - if ($is_apcu) { - apcu_delete($bucket_key); - apcu_store($min_key, $cursor + 1); - } else { - apc_delete($bucket_key); - 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_key = self::getRateLimitBucketKey($cursor); - if ($is_apcu) { - $bucket = apcu_fetch($bucket_key); - } else { - $bucket = apc_fetch($bucket_key); - } - 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($user_identity, $score, $limit) { - $message = - "TOO MANY REQUESTS\n". - "You (\"{$user_identity}\") are issuing too many requests ". - "too quickly.\n"; - + private static function didRateLimit($reason) { header( 'Content-Type: text/plain; charset=utf-8', $replace = true, $http_error = 429); - echo $message; + echo $reason; exit(1); } /* -( Startup Timers )----------------------------------------------------- */ /** * Record the beginning of a new startup phase. * * For phases which occur before @{class:PhabricatorStartup} loads, save the * time and record it with @{method:recordStartupPhase} after the class is * available. * * @param string Phase name. * @task phases */ public static function beginStartupPhase($phase) { self::recordStartupPhase($phase, microtime(true)); } /** * Record the start time of a previously executed startup phase. * * For startup phases which occur after @{class:PhabricatorStartup} loads, * use @{method:beginStartupPhase} instead. This method can be used to * record a time before the class loads, then hand it over once the class * becomes available. * * @param string Phase name. * @param float Phase start time, from `microtime(true)`. * @task phases */ public static function recordStartupPhase($phase, $time) { self::$phases[$phase] = $time; } /** * Get information about startup phase timings. * * Sometimes, performance problems can occur before we start the profiler. * Since the profiler can't examine these phases, it isn't useful in * understanding their performance costs. * * Instead, the startup process marks when it enters various phases using * @{method:beginStartupPhase}. A later call to this method can retrieve this * information, which can be examined to gain greater insight into where * time was spent. The output is still crude, but better than nothing. * * @task phases */ public static function getPhases() { return self::$phases; } } diff --git a/webroot/index.php b/webroot/index.php index 59e5b71075..5c7d79bfa1 100644 --- a/webroot/index.php +++ b/webroot/index.php @@ -1,55 +1,60 @@ setException($ex); PhabricatorStartup::endOutputCapture(); $sink->writeResponse($response); } catch (Exception $response_exception) { // If we hit a rendering exception, ignore it and throw the original // exception. It is generally more interesting and more likely to be // the root cause. throw $ex; } } } catch (Exception $ex) { PhabricatorStartup::didEncounterFatalException('Core Exception', $ex, false); } function phabricator_startup() { // Load the PhabricatorStartup class itself. $t_startup = microtime(true); $root = dirname(dirname(__FILE__)); - require_once $root.'/support/PhabricatorStartup.php'; + require_once $root.'/support/startup/PhabricatorStartup.php'; + + // Load client limit classes so the preamble can configure limits. + require_once $root.'/support/startup/PhabricatorClientLimit.php'; + require_once $root.'/support/startup/PhabricatorClientRateLimit.php'; + require_once $root.'/support/startup/PhabricatorClientConnectionLimit.php'; // If the preamble script exists, load it. $t_preamble = microtime(true); $preamble_path = $root.'/support/preamble.php'; if (file_exists($preamble_path)) { require_once $preamble_path; } $t_hook = microtime(true); PhabricatorStartup::didStartup($t_startup); PhabricatorStartup::recordStartupPhase('startup.init', $t_startup); PhabricatorStartup::recordStartupPhase('preamble', $t_preamble); PhabricatorStartup::recordStartupPhase('hook', $t_hook); }