diff --git a/src/aphront/configuration/AphrontApplicationConfiguration.php b/src/aphront/configuration/AphrontApplicationConfiguration.php index c269052a4e..365ba8f94a 100644 --- a/src/aphront/configuration/AphrontApplicationConfiguration.php +++ b/src/aphront/configuration/AphrontApplicationConfiguration.php @@ -1,813 +1,817 @@ parseQueryString(idx($_SERVER, 'QUERY_STRING', '')); $cookie_prefix = PhabricatorEnv::getEnvConfig('phabricator.cookie-prefix'); $request = new AphrontRequest($this->getHost(), $this->getPath()); $request->setRequestData($data); $request->setApplicationConfiguration($this); $request->setCookiePrefix($cookie_prefix); return $request; } public function build404Controller() { return array(new Phabricator404Controller(), array()); } public function buildRedirectController($uri, $external) { return array( new PhabricatorRedirectController(), array( 'uri' => $uri, 'external' => $external, ), ); } public function setRequest(AphrontRequest $request) { $this->request = $request; return $this; } public function getRequest() { return $this->request; } public function getConsole() { return $this->console; } public function setConsole($console) { $this->console = $console; return $this; } public function setHost($host) { $this->host = $host; return $this; } public function getHost() { return $this->host; } public function setPath($path) { $this->path = $path; return $this; } public function getPath() { return $this->path; } /** * @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', '-'), )); self::readHTTPPOSTData(); 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__']; $application = new self(); $application->setHost($host); $application->setPath($path); $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); 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 */ 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); } private static function readHTTPPOSTData() { $request_method = idx($_SERVER, 'REQUEST_METHOD'); if ($request_method === 'PUT') { // For PUT requests, do nothing: in particular, do NOT read input. This // allows us to stream input later and process very large PUT requests, // like those coming from Git LFS. return; } // For POST requests, we're going to read the raw input ourselves here // if we can. Among other things, this corrects variable names with // the "." character in them, which PHP normally converts into "_". // There are two major considerations here: whether the // `enable_post_data_reading` option is set, and whether the content // type is "multipart/form-data" or not. // If `enable_post_data_reading` is off, we're free to read the entire // raw request body and parse it -- and we must, because $_POST and // $_FILES are not built for us. If `enable_post_data_reading` is on, // which is the default, we may not be able to read the body (the // documentation says we can't, but empirically we can at least some // of the time). // If the content type is "multipart/form-data", we need to build both // $_POST and $_FILES, which is involved. The body itself is also more // difficult to parse than other requests. $raw_input = PhabricatorStartup::getRawInput(); $parser = new PhutilQueryStringParser(); if (strlen($raw_input)) { $content_type = idx($_SERVER, 'CONTENT_TYPE'); $is_multipart = preg_match('@^multipart/form-data@i', $content_type); if ($is_multipart && !ini_get('enable_post_data_reading')) { $multipart_parser = id(new AphrontMultipartParser()) ->setContentType($content_type); $multipart_parser->beginParse(); $multipart_parser->continueParse($raw_input); $parts = $multipart_parser->endParse(); + // We're building and then parsing a query string so that requests + // with arrays (like "x[]=apple&x[]=banana") work correctly. This also + // means we can't use "phutil_build_http_querystring()", since it + // can't build a query string with duplicate names. + $query_string = array(); foreach ($parts as $part) { if (!$part->isVariable()) { continue; } $name = $part->getName(); $value = $part->getVariableValue(); - - $query_string[] = urlencode($name).'='.urlencode($value); + $query_string[] = rawurlencode($name).'='.rawurlencode($value); } $query_string = implode('&', $query_string); $post = $parser->parseQueryString($query_string); $files = array(); foreach ($parts as $part) { if ($part->isVariable()) { continue; } $files[$part->getName()] = $part->getPHPFileDictionary(); } $_FILES = $files; } else { $post = $parser->parseQueryString($raw_input); } $_POST = $post; PhabricatorStartup::rebuildRequest(); } else if ($_POST) { $post = filter_input_array(INPUT_POST, FILTER_UNSAFE_RAW); if (is_array($post)) { $_POST = $post; PhabricatorStartup::rebuildRequest(); } } } } diff --git a/src/applications/auth/factor/PhabricatorDuoAuthFactor.php b/src/applications/auth/factor/PhabricatorDuoAuthFactor.php index f5e2455c79..33543f41cb 100644 --- a/src/applications/auth/factor/PhabricatorDuoAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorDuoAuthFactor.php @@ -1,802 +1,799 @@ loadConfigurationsForProvider($provider, $user)) { return false; } return true; } public function getConfigurationCreateDescription( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { $messages = array(); if ($this->loadConfigurationsForProvider($provider, $user)) { $messages[] = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setErrors( array( pht( 'You already have Duo authentication attached to your account '. 'for this provider.'), )); } return $messages; } public function getConfigurationListDetails( PhabricatorAuthFactorConfig $config, PhabricatorAuthFactorProvider $provider, PhabricatorUser $viewer) { $duo_user = $config->getAuthFactorConfigProperty('duo.username'); return pht('Duo Username: %s', $duo_user); } public function newEditEngineFields( PhabricatorEditEngine $engine, PhabricatorAuthFactorProvider $provider) { $viewer = $engine->getViewer(); $credential_phid = $provider->getAuthFactorProviderProperty( self::PROP_CREDENTIAL); $hostname = $provider->getAuthFactorProviderProperty(self::PROP_HOSTNAME); $usernames = $provider->getAuthFactorProviderProperty(self::PROP_USERNAMES); $enroll = $provider->getAuthFactorProviderProperty(self::PROP_ENROLL); $credential_type = PassphrasePasswordCredentialType::CREDENTIAL_TYPE; $provides_type = PassphrasePasswordCredentialType::PROVIDES_TYPE; $credentials = id(new PassphraseCredentialQuery()) ->setViewer($viewer) ->withIsDestroyed(false) ->withProvidesTypes(array($provides_type)) ->execute(); $xaction_hostname = PhabricatorAuthFactorProviderDuoHostnameTransaction::TRANSACTIONTYPE; $xaction_credential = PhabricatorAuthFactorProviderDuoCredentialTransaction::TRANSACTIONTYPE; $xaction_usernames = PhabricatorAuthFactorProviderDuoUsernamesTransaction::TRANSACTIONTYPE; $xaction_enroll = PhabricatorAuthFactorProviderDuoEnrollTransaction::TRANSACTIONTYPE; return array( id(new PhabricatorTextEditField()) ->setLabel(pht('Duo API Hostname')) ->setKey('duo.hostname') ->setValue($hostname) ->setTransactionType($xaction_hostname) ->setIsRequired(true), id(new PhabricatorCredentialEditField()) ->setLabel(pht('Duo API Credential')) ->setKey('duo.credential') ->setValue($credential_phid) ->setTransactionType($xaction_credential) ->setCredentialType($credential_type) ->setCredentials($credentials), id(new PhabricatorSelectEditField()) ->setLabel(pht('Duo Username')) ->setKey('duo.usernames') ->setValue($usernames) ->setTransactionType($xaction_usernames) ->setOptions( array( 'username' => pht('Use Phabricator Username'), 'email' => pht('Use Primary Email Address'), )), id(new PhabricatorSelectEditField()) ->setLabel(pht('Create Accounts')) ->setKey('duo.enroll') ->setValue($enroll) ->setTransactionType($xaction_enroll) ->setOptions( array( 'deny' => pht('Require Existing Duo Account'), 'allow' => pht('Create New Duo Account'), )), ); } public function processAddFactorForm( PhabricatorAuthFactorProvider $provider, AphrontFormView $form, AphrontRequest $request, PhabricatorUser $user) { $token = $this->loadMFASyncToken($provider, $request, $form, $user); $enroll = $token->getTemporaryTokenProperty('duo.enroll'); $duo_id = $token->getTemporaryTokenProperty('duo.user-id'); $duo_uri = $token->getTemporaryTokenProperty('duo.uri'); $duo_user = $token->getTemporaryTokenProperty('duo.username'); $is_external = ($enroll === 'external'); $is_auto = ($enroll === 'auto'); $is_blocked = ($enroll === 'blocked'); if (!$token->getIsNewTemporaryToken()) { if ($is_auto) { return $this->newDuoConfig($user, $duo_user); } else if ($is_external || $is_blocked) { $parameters = array( 'username' => $duo_user, ); $result = $this->newDuoFuture($provider) ->setMethod('preauth', $parameters) ->resolve(); $result_code = $result['response']['result']; switch ($result_code) { case 'auth': case 'allow': return $this->newDuoConfig($user, $duo_user); case 'enroll': if ($is_blocked) { // We'll render an equivalent static control below, so skip // rendering here. We explicitly don't want to give the user // an enroll workflow. break; } $duo_uri = $result['response']['enroll_portal_url']; $waiting_icon = id(new PHUIIconView()) ->setIcon('fa-mobile', 'red'); $waiting_control = id(new PHUIFormTimerControl()) ->setIcon($waiting_icon) ->setError(pht('Not Complete')) ->appendChild( pht( 'You have not completed Duo enrollment yet. '. 'Complete enrollment, then click continue.')); $form->appendControl($waiting_control); break; default: case 'deny': break; } } else { $parameters = array( 'user_id' => $duo_id, 'activation_code' => $duo_uri, ); $future = $this->newDuoFuture($provider) ->setMethod('enroll_status', $parameters); $result = $future->resolve(); $response = $result['response']; switch ($response) { case 'success': return $this->newDuoConfig($user, $duo_user); case 'waiting': $waiting_icon = id(new PHUIIconView()) ->setIcon('fa-mobile', 'red'); $waiting_control = id(new PHUIFormTimerControl()) ->setIcon($waiting_icon) ->setError(pht('Not Complete')) ->appendChild( pht( 'You have not activated this enrollment in the Duo '. 'application on your phone yet. Complete activation, then '. 'click continue.')); $form->appendControl($waiting_control); break; case 'invalid': default: throw new Exception( pht( 'This Duo enrollment attempt is invalid or has '. 'expired ("%s"). Cancel the workflow and try again.', $response)); } } } if ($is_blocked) { $blocked_icon = id(new PHUIIconView()) ->setIcon('fa-times', 'red'); $blocked_control = id(new PHUIFormTimerControl()) ->setIcon($blocked_icon) ->appendChild( pht( 'Your Duo account ("%s") has not completed Duo enrollment. '. 'Check your email and complete enrollment to continue.', phutil_tag('strong', array(), $duo_user))); $form->appendControl($blocked_control); } else if ($is_auto) { $auto_icon = id(new PHUIIconView()) ->setIcon('fa-check', 'green'); $auto_control = id(new PHUIFormTimerControl()) ->setIcon($auto_icon) ->appendChild( pht( 'Duo account ("%s") is fully enrolled.', phutil_tag('strong', array(), $duo_user))); $form->appendControl($auto_control); } else { $duo_button = phutil_tag( 'a', array( 'href' => $duo_uri, 'class' => 'button button-grey', 'target' => ($is_external ? '_blank' : null), ), pht('Enroll Duo Account: %s', $duo_user)); $duo_button = phutil_tag( 'div', array( 'class' => 'mfa-form-enroll-button', ), $duo_button); if ($is_external) { $form->appendRemarkupInstructions( pht( 'Complete enrolling your phone with Duo:')); $form->appendControl( id(new AphrontFormMarkupControl()) ->setValue($duo_button)); } else { $form->appendRemarkupInstructions( pht( 'Scan this QR code with the Duo application on your mobile '. 'phone:')); $qr_code = $this->newQRCode($duo_uri); $form->appendChild($qr_code); $form->appendRemarkupInstructions( pht( 'If you are currently using your phone to view this page, '. 'click this button to open the Duo application:')); $form->appendControl( id(new AphrontFormMarkupControl()) ->setValue($duo_button)); } $form->appendRemarkupInstructions( pht( 'Once you have completed setup on your phone, click continue.')); } } protected function newMFASyncTokenProperties( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { $duo_user = $this->getDuoUsername($provider, $user); // Duo automatically normalizes usernames to lowercase. Just do that here // so that our value agrees more closely with Duo. $duo_user = phutil_utf8_strtolower($duo_user); $parameters = array( 'username' => $duo_user, ); $result = $this->newDuoFuture($provider) ->setMethod('preauth', $parameters) ->resolve(); $external_uri = null; $result_code = $result['response']['result']; switch ($result_code) { case 'auth': case 'allow': // If the user already has a Duo account, they don't need to do // anything. return array( 'duo.enroll' => 'auto', 'duo.username' => $duo_user, ); case 'enroll': if (!$this->shouldAllowDuoEnrollment($provider)) { return array( 'duo.enroll' => 'blocked', 'duo.username' => $duo_user, ); } $external_uri = $result['response']['enroll_portal_url']; // Otherwise, enrollment is permitted so we're going to continue. break; default: case 'deny': return $this->newResult() ->setIsError(true) ->setErrorMessage( pht('Your account is not permitted to access this system.')); } // Duo's "/enroll" API isn't repeatable for the same username. If we're // the first call, great: we can do inline enrollment, which is way more // user friendly. Otherwise, we have to send the user on an adventure. $parameters = array( 'username' => $duo_user, 'valid_secs' => phutil_units('1 hour in seconds'), ); try { $result = $this->newDuoFuture($provider) ->setMethod('enroll', $parameters) ->resolve(); } catch (HTTPFutureHTTPResponseStatus $ex) { return array( 'duo.enroll' => 'external', 'duo.username' => $duo_user, 'duo.uri' => $external_uri, ); } return array( 'duo.enroll' => 'inline', 'duo.uri' => $result['response']['activation_code'], 'duo.username' => $duo_user, 'duo.user-id' => $result['response']['user_id'], ); } protected function newIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { // If we already issued a valid challenge for this workflow and session, // don't issue a new one. $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); if ($challenge) { return array(); } if (!$this->hasCSRF($config)) { return $this->newResult() ->setIsContinue(true) ->setErrorMessage( pht( 'An authorization request will be pushed to the Duo '. 'application on your phone.')); } $provider = $config->getFactorProvider(); // Otherwise, issue a new challenge. $duo_user = (string)$config->getAuthFactorConfigProperty('duo.username'); $parameters = array( 'username' => $duo_user, ); $response = $this->newDuoFuture($provider) ->setMethod('preauth', $parameters) ->resolve(); $response = $response['response']; $next_step = $response['result']; $status_message = $response['status_msg']; switch ($next_step) { case 'auth': // We're good to go. break; case 'allow': // Duo is telling us to bypass MFA. For now, refuse. return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'Duo is not requiring a challenge, which defeats the '. 'purpose of MFA. Duo must be configured to challenge you.')); case 'enroll': return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'Your Duo account ("%s") requires enrollment. Contact your '. 'Duo administrator for help. Duo status message: %s', $duo_user, $status_message)); case 'deny': default: return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'Duo has denied you access. Duo status message ("%s"): %s', $next_step, $status_message)); } $has_push = false; $devices = $response['devices']; foreach ($devices as $device) { $capabilities = array_fuse($device['capabilities']); if (isset($capabilities['push'])) { $has_push = true; break; } } if (!$has_push) { return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'This factor has been removed from your device, so Phabricator '. 'can not send you a challenge. To continue, an administrator '. 'must strip this factor from your account.')); } $push_info = array( pht('Domain') => $this->getInstallDisplayName(), ); - foreach ($push_info as $k => $v) { - $push_info[$k] = rawurlencode($k).'='.rawurlencode($v); - } - $push_info = implode('&', $push_info); + $push_info = phutil_build_http_querystring($push_info); $parameters = array( 'username' => $duo_user, 'factor' => 'push', 'async' => '1', // Duo allows us to specify a device, or to pass "auto" to have it pick // the first one. For now, just let it pick. 'device' => 'auto', // This is a hard-coded prefix for the word "... request" in the Duo UI, // which defaults to "Login". We could pass richer information from // workflows here, but it's not very flexible anyway. 'type' => 'Authentication', 'display_username' => $viewer->getUsername(), 'pushinfo' => $push_info, ); $result = $this->newDuoFuture($provider) ->setMethod('auth', $parameters) ->resolve(); $duo_xaction = $result['response']['txid']; // The Duo push timeout is 60 seconds. Set our challenge to expire slightly // more quickly so that we'll re-issue a new challenge before Duo times out. // This should keep users away from a dead-end where they can't respond to // Duo but Phabricator won't issue a new challenge yet. $ttl_seconds = 55; return array( $this->newChallenge($config, $viewer) ->setChallengeKey($duo_xaction) ->setChallengeTTL(PhabricatorTime::getNow() + $ttl_seconds), ); } protected function newResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, array $challenges) { $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); if ($challenge->getIsAnsweredChallenge()) { return $this->newResult() ->setAnsweredChallenge($challenge); } $provider = $config->getFactorProvider(); $duo_xaction = $challenge->getChallengeKey(); $parameters = array( 'txid' => $duo_xaction, ); // This endpoint always long-polls, so use a timeout to force it to act // more asynchronously. try { $result = $this->newDuoFuture($provider) ->setHTTPMethod('GET') ->setMethod('auth_status', $parameters) ->setTimeout(5) ->resolve(); $state = $result['response']['result']; $status = $result['response']['status']; } catch (HTTPFutureCURLResponseStatus $exception) { if ($exception->isTimeout()) { $state = 'waiting'; $status = 'poll'; } else { throw $exception; } } $now = PhabricatorTime::getNow(); switch ($state) { case 'allow': $ttl = PhabricatorTime::getNow() + phutil_units('15 minutes in seconds'); $challenge ->markChallengeAsAnswered($ttl); return $this->newResult() ->setAnsweredChallenge($challenge); case 'waiting': // No result yet, we'll render a default state later on. break; default: case 'deny': if ($status === 'timeout') { return $this->newResult() ->setIsError(true) ->setErrorMessage( pht( 'This request has timed out because you took too long to '. 'respond.')); } else { $wait_duration = ($challenge->getChallengeTTL() - $now) + 1; return $this->newResult() ->setIsWait(true) ->setErrorMessage( pht( 'You denied this request. Wait %s second(s) to try again.', new PhutilNumber($wait_duration))); } break; } return null; } public function renderValidateFactorForm( PhabricatorAuthFactorConfig $config, AphrontFormView $form, PhabricatorUser $viewer, PhabricatorAuthFactorResult $result) { $control = $this->newAutomaticControl($result); if (!$control) { $result = $this->newResult() ->setIsContinue(true) ->setErrorMessage( pht( 'A challenge has been sent to your phone. Open the Duo '. 'application and confirm the challenge, then continue.')); $control = $this->newAutomaticControl($result); } $control ->setLabel(pht('Duo')) ->setCaption(pht('Factor Name: %s', $config->getFactorName())); $form->appendChild($control); } public function getRequestHasChallengeResponse( PhabricatorAuthFactorConfig $config, AphrontRequest $request) { $value = $this->getChallengeResponseFromRequest($config, $request); return (bool)strlen($value); } protected function newResultFromChallengeResponse( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, AphrontRequest $request, array $challenges) { $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); $code = $this->getChallengeResponseFromRequest( $config, $request); $result = $this->newResult() ->setValue($code); if ($challenge->getIsAnsweredChallenge()) { return $result->setAnsweredChallenge($challenge); } if (phutil_hashes_are_identical($code, $challenge->getChallengeKey())) { $ttl = PhabricatorTime::getNow() + phutil_units('15 minutes in seconds'); $challenge ->markChallengeAsAnswered($ttl); return $result->setAnsweredChallenge($challenge); } if (strlen($code)) { $error_message = pht('Invalid'); } else { $error_message = pht('Required'); } $result->setErrorMessage($error_message); return $result; } private function newDuoFuture(PhabricatorAuthFactorProvider $provider) { $credential_phid = $provider->getAuthFactorProviderProperty( self::PROP_CREDENTIAL); $omnipotent = PhabricatorUser::getOmnipotentUser(); $credential = id(new PassphraseCredentialQuery()) ->setViewer($omnipotent) ->withPHIDs(array($credential_phid)) ->needSecrets(true) ->executeOne(); if (!$credential) { throw new Exception( pht( 'Unable to load Duo API credential ("%s").', $credential_phid)); } $duo_key = $credential->getUsername(); $duo_secret = $credential->getSecret(); if (!$duo_secret) { throw new Exception( pht( 'Duo API credential ("%s") has no secret key.', $credential_phid)); } $duo_host = $provider->getAuthFactorProviderProperty( self::PROP_HOSTNAME); self::requireDuoAPIHostname($duo_host); return id(new PhabricatorDuoFuture()) ->setIntegrationKey($duo_key) ->setSecretKey($duo_secret) ->setAPIHostname($duo_host) ->setTimeout(10) ->setHTTPMethod('POST'); } private function getDuoUsername( PhabricatorAuthFactorProvider $provider, PhabricatorUser $user) { $mode = $provider->getAuthFactorProviderProperty(self::PROP_USERNAMES); switch ($mode) { case 'username': return $user->getUsername(); case 'email': return $user->loadPrimaryEmailAddress(); default: throw new Exception( pht( 'Duo username pairing mode ("%s") is not supported.', $mode)); } } private function shouldAllowDuoEnrollment( PhabricatorAuthFactorProvider $provider) { $mode = $provider->getAuthFactorProviderProperty(self::PROP_ENROLL); switch ($mode) { case 'deny': return false; case 'allow': return true; default: throw new Exception( pht( 'Duo enrollment mode ("%s") is not supported.', $mode)); } } private function newDuoConfig(PhabricatorUser $user, $duo_user) { $config_properties = array( 'duo.username' => $duo_user, ); $config = $this->newConfigForUser($user) ->setFactorName(pht('Duo (%s)', $duo_user)) ->setProperties($config_properties); return $config; } public static function requireDuoAPIHostname($hostname) { if (preg_match('/\.duosecurity\.com\z/', $hostname)) { return; } throw new Exception( pht( 'Duo API hostname ("%s") is invalid, hostname must be '. '"*.duosecurity.com".', $hostname)); } } diff --git a/src/applications/auth/future/PhabricatorDuoFuture.php b/src/applications/auth/future/PhabricatorDuoFuture.php index fd95906da1..81a5a2a2b8 100644 --- a/src/applications/auth/future/PhabricatorDuoFuture.php +++ b/src/applications/auth/future/PhabricatorDuoFuture.php @@ -1,155 +1,151 @@ integrationKey = $integration_key; return $this; } public function setSecretKey(PhutilOpaqueEnvelope $key) { $this->secretKey = $key; return $this; } public function setAPIHostname($hostname) { $this->apiHostname = $hostname; return $this; } public function setMethod($method, array $parameters) { $this->method = $method; $this->parameters = $parameters; return $this; } public function setTimeout($timeout) { $this->timeout = $timeout; return $this; } public function getTimeout() { return $this->timeout; } public function setHTTPMethod($method) { $this->httpMethod = $method; return $this; } public function getHTTPMethod() { return $this->httpMethod; } protected function getProxiedFuture() { if (!$this->future) { if ($this->integrationKey === null) { throw new PhutilInvalidStateException('setIntegrationKey'); } if ($this->secretKey === null) { throw new PhutilInvalidStateException('setSecretKey'); } if ($this->apiHostname === null) { throw new PhutilInvalidStateException('setAPIHostname'); } if ($this->method === null || $this->parameters === null) { throw new PhutilInvalidStateException('setMethod'); } $path = (string)urisprintf('/auth/v2/%s', $this->method); $host = $this->apiHostname; $host = phutil_utf8_strtolower($host); $uri = id(new PhutilURI('')) ->setProtocol('https') ->setDomain($host) ->setPath($path); $data = $this->parameters; $date = date('r'); $http_method = $this->getHTTPMethod(); ksort($data); - $data_parts = array(); - foreach ($data as $key => $value) { - $data_parts[] = rawurlencode($key).'='.rawurlencode($value); - } - $data_parts = implode('&', $data_parts); + $data_parts = phutil_build_http_querystring($data); $corpus = array( $date, $http_method, $host, $path, $data_parts, ); $corpus = implode("\n", $corpus); $signature = hash_hmac( 'sha1', $corpus, $this->secretKey->openEnvelope()); $signature = new PhutilOpaqueEnvelope($signature); if ($http_method === 'GET') { $uri->setQueryParams($data); $data = array(); } $future = id(new HTTPSFuture($uri, $data)) ->setHTTPBasicAuthCredentials($this->integrationKey, $signature) ->setMethod($http_method) ->addHeader('Accept', 'application/json') ->addHeader('Date', $date); $timeout = $this->getTimeout(); if ($timeout) { $future->setTimeout($timeout); } $this->future = $future; } return $this->future; } protected function didReceiveResult($result) { list($status, $body, $headers) = $result; if ($status->isError()) { throw $status; } try { $data = phutil_json_decode($body); } catch (PhutilJSONParserException $ex) { throw new PhutilProxyException( pht('Expected JSON response from Duo.'), $ex); } return $data; } } diff --git a/src/applications/diffusion/controller/DiffusionServeController.php b/src/applications/diffusion/controller/DiffusionServeController.php index 5a5c446a29..cb4ad0ba95 100644 --- a/src/applications/diffusion/controller/DiffusionServeController.php +++ b/src/applications/diffusion/controller/DiffusionServeController.php @@ -1,1242 +1,1242 @@ getRequest()->setUser($viewer); $this->serviceViewer = $viewer; return $this; } public function getServiceViewer() { return $this->serviceViewer; } public function setServiceRepository(PhabricatorRepository $repository) { $this->serviceRepository = $repository; return $this; } public function getServiceRepository() { return $this->serviceRepository; } public function getIsGitLFSRequest() { return $this->isGitLFSRequest; } public function getGitLFSToken() { return $this->gitLFSToken; } public function isVCSRequest(AphrontRequest $request) { $identifier = $this->getRepositoryIdentifierFromRequest($request); if ($identifier === null) { return null; } $content_type = $request->getHTTPHeader('Content-Type'); $user_agent = idx($_SERVER, 'HTTP_USER_AGENT'); $request_type = $request->getHTTPHeader('X-Phabricator-Request-Type'); // This may have a "charset" suffix, so only match the prefix. $lfs_pattern = '(^application/vnd\\.git-lfs\\+json(;|\z))'; $vcs = null; if ($request->getExists('service')) { $service = $request->getStr('service'); // We get this initially for `info/refs`. // Git also gives us a User-Agent like "git/1.8.2.3". $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if (strncmp($user_agent, 'git/', 4) === 0) { $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if ($content_type == 'application/x-git-upload-pack-request') { // We get this for `git-upload-pack`. $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if ($content_type == 'application/x-git-receive-pack-request') { // We get this for `git-receive-pack`. $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; } else if (preg_match($lfs_pattern, $content_type)) { // This is a Git LFS HTTP API request. $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; $this->isGitLFSRequest = true; } else if ($request_type == 'git-lfs') { // This is a Git LFS object content request. $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_GIT; $this->isGitLFSRequest = true; } else if ($request->getExists('cmd')) { // Mercurial also sends an Accept header like // "application/mercurial-0.1", and a User-Agent like // "mercurial/proto-1.0". $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL; } else { // Subversion also sends an initial OPTIONS request (vs GET/POST), and // has a User-Agent like "SVN/1.8.3 (x86_64-apple-darwin11.4.2) // serf/1.3.2". $dav = $request->getHTTPHeader('DAV'); $dav = new PhutilURI($dav); if ($dav->getDomain() === 'subversion.tigris.org') { $vcs = PhabricatorRepositoryType::REPOSITORY_TYPE_SVN; } } return $vcs; } public function handleRequest(AphrontRequest $request) { $service_exception = null; $response = null; try { $response = $this->serveRequest($request); } catch (Exception $ex) { $service_exception = $ex; } try { $remote_addr = $request->getRemoteAddress(); if ($request->isHTTPS()) { $remote_protocol = PhabricatorRepositoryPullEvent::PROTOCOL_HTTPS; } else { $remote_protocol = PhabricatorRepositoryPullEvent::PROTOCOL_HTTP; } $pull_event = id(new PhabricatorRepositoryPullEvent()) ->setEpoch(PhabricatorTime::getNow()) ->setRemoteAddress($remote_addr) ->setRemoteProtocol($remote_protocol); if ($response) { $response_code = $response->getHTTPResponseCode(); if ($response_code == 200) { $pull_event ->setResultType(PhabricatorRepositoryPullEvent::RESULT_PULL) ->setResultCode($response_code); } else { $pull_event ->setResultType(PhabricatorRepositoryPullEvent::RESULT_ERROR) ->setResultCode($response_code); } if ($response instanceof PhabricatorVCSResponse) { $pull_event->setProperties( array( 'response.message' => $response->getMessage(), )); } } else { $pull_event ->setResultType(PhabricatorRepositoryPullEvent::RESULT_EXCEPTION) ->setResultCode(500) ->setProperties( array( 'exception.class' => get_class($ex), 'exception.message' => $ex->getMessage(), )); } $viewer = $this->getServiceViewer(); if ($viewer) { $pull_event->setPullerPHID($viewer->getPHID()); } $repository = $this->getServiceRepository(); if ($repository) { $pull_event->setRepositoryPHID($repository->getPHID()); } $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $pull_event->save(); unset($unguarded); } catch (Exception $ex) { if ($service_exception) { throw $service_exception; } throw $ex; } if ($service_exception) { throw $service_exception; } return $response; } private function serveRequest(AphrontRequest $request) { $identifier = $this->getRepositoryIdentifierFromRequest($request); // If authentication credentials have been provided, try to find a user // that actually matches those credentials. // We require both the username and password to be nonempty, because Git // won't prompt users who provide a username but no password otherwise. // See T10797 for discussion. $have_user = strlen(idx($_SERVER, 'PHP_AUTH_USER')); $have_pass = strlen(idx($_SERVER, 'PHP_AUTH_PW')); if ($have_user && $have_pass) { $username = $_SERVER['PHP_AUTH_USER']; $password = new PhutilOpaqueEnvelope($_SERVER['PHP_AUTH_PW']); // Try Git LFS auth first since we can usually reject it without doing // any queries, since the username won't match the one we expect or the // request won't be LFS. $viewer = $this->authenticateGitLFSUser($username, $password); // If that failed, try normal auth. Note that we can use normal auth on // LFS requests, so this isn't strictly an alternative to LFS auth. if (!$viewer) { $viewer = $this->authenticateHTTPRepositoryUser($username, $password); } if (!$viewer) { return new PhabricatorVCSResponse( 403, pht('Invalid credentials.')); } } else { // User hasn't provided credentials, which means we count them as // being "not logged in". $viewer = new PhabricatorUser(); } $this->setServiceViewer($viewer); $allow_public = PhabricatorEnv::getEnvConfig('policy.allow-public'); $allow_auth = PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth'); if (!$allow_public) { if (!$viewer->isLoggedIn()) { if ($allow_auth) { return new PhabricatorVCSResponse( 401, pht('You must log in to access repositories.')); } else { return new PhabricatorVCSResponse( 403, pht('Public and authenticated HTTP access are both forbidden.')); } } } try { $repository = id(new PhabricatorRepositoryQuery()) ->setViewer($viewer) ->withIdentifiers(array($identifier)) ->needURIs(true) ->executeOne(); if (!$repository) { return new PhabricatorVCSResponse( 404, pht('No such repository exists.')); } } catch (PhabricatorPolicyException $ex) { if ($viewer->isLoggedIn()) { return new PhabricatorVCSResponse( 403, pht('You do not have permission to access this repository.')); } else { if ($allow_auth) { return new PhabricatorVCSResponse( 401, pht('You must log in to access this repository.')); } else { return new PhabricatorVCSResponse( 403, pht( 'This repository requires authentication, which is forbidden '. 'over HTTP.')); } } } $response = $this->validateGitLFSRequest($repository, $viewer); if ($response) { return $response; } $this->setServiceRepository($repository); if (!$repository->isTracked()) { return new PhabricatorVCSResponse( 403, pht('This repository is inactive.')); } $is_push = !$this->isReadOnlyRequest($repository); if ($this->getIsGitLFSRequest() && $this->getGitLFSToken()) { // We allow git LFS requests over HTTP even if the repository does not // otherwise support HTTP reads or writes, as long as the user is using a // token from SSH. If they're using HTTP username + password auth, they // have to obey the normal HTTP rules. } else { // For now, we don't distinguish between HTTP and HTTPS-originated // requests that are proxied within the cluster, so the user can connect // with HTTPS but we may be on HTTP by the time we reach this part of // the code. Allow things to move forward as long as either protocol // can be served. $proto_https = PhabricatorRepositoryURI::BUILTIN_PROTOCOL_HTTPS; $proto_http = PhabricatorRepositoryURI::BUILTIN_PROTOCOL_HTTP; $can_read = $repository->canServeProtocol($proto_https, false) || $repository->canServeProtocol($proto_http, false); if (!$can_read) { return new PhabricatorVCSResponse( 403, pht('This repository is not available over HTTP.')); } if ($is_push) { $can_write = $repository->canServeProtocol($proto_https, true) || $repository->canServeProtocol($proto_http, true); if (!$can_write) { return new PhabricatorVCSResponse( 403, pht('This repository is read-only over HTTP.')); } } } if ($is_push) { $can_push = PhabricatorPolicyFilter::hasCapability( $viewer, $repository, DiffusionPushCapability::CAPABILITY); if (!$can_push) { if ($viewer->isLoggedIn()) { $error_code = 403; $error_message = pht( 'You do not have permission to push to this repository ("%s").', $repository->getDisplayName()); if ($this->getIsGitLFSRequest()) { return DiffusionGitLFSResponse::newErrorResponse( $error_code, $error_message); } else { return new PhabricatorVCSResponse( $error_code, $error_message); } } else { if ($allow_auth) { return new PhabricatorVCSResponse( 401, pht('You must log in to push to this repository.')); } else { return new PhabricatorVCSResponse( 403, pht( 'Pushing to this repository requires authentication, '. 'which is forbidden over HTTP.')); } } } } $vcs_type = $repository->getVersionControlSystem(); $req_type = $this->isVCSRequest($request); if ($vcs_type != $req_type) { switch ($req_type) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $result = new PhabricatorVCSResponse( 500, pht( 'This repository ("%s") is not a Git repository.', $repository->getDisplayName())); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $result = new PhabricatorVCSResponse( 500, pht( 'This repository ("%s") is not a Mercurial repository.', $repository->getDisplayName())); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $result = new PhabricatorVCSResponse( 500, pht( 'This repository ("%s") is not a Subversion repository.', $repository->getDisplayName())); break; default: $result = new PhabricatorVCSResponse( 500, pht('Unknown request type.')); break; } } else { switch ($vcs_type) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $result = $this->serveVCSRequest($repository, $viewer); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $result = new PhabricatorVCSResponse( 500, pht( 'Phabricator does not support HTTP access to Subversion '. 'repositories.')); break; default: $result = new PhabricatorVCSResponse( 500, pht('Unknown version control system.')); break; } } $code = $result->getHTTPResponseCode(); if ($is_push && ($code == 200)) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $repository->writeStatusMessage( PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE, PhabricatorRepositoryStatusMessage::CODE_OKAY); unset($unguarded); } return $result; } private function serveVCSRequest( PhabricatorRepository $repository, PhabricatorUser $viewer) { // We can serve Git LFS requests first, since we don't need to proxy them. // It's also important that LFS requests never fall through to standard // service pathways, because that would let you use LFS tokens to read // normal repository data. if ($this->getIsGitLFSRequest()) { return $this->serveGitLFSRequest($repository, $viewer); } // If this repository is hosted on a service, we need to proxy the request // to a host which can serve it. $is_cluster_request = $this->getRequest()->isProxiedClusterRequest(); $uri = $repository->getAlmanacServiceURI( $viewer, array( 'neverProxy' => $is_cluster_request, 'protocols' => array( 'http', 'https', ), 'writable' => !$this->isReadOnlyRequest($repository), )); if ($uri) { $future = $this->getRequest()->newClusterProxyFuture($uri); return id(new AphrontHTTPProxyResponse()) ->setHTTPFuture($future); } // Otherwise, we're going to handle the request locally. $vcs_type = $repository->getVersionControlSystem(); switch ($vcs_type) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $result = $this->serveGitRequest($repository, $viewer); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $result = $this->serveMercurialRequest($repository, $viewer); break; } return $result; } private function isReadOnlyRequest( PhabricatorRepository $repository) { $request = $this->getRequest(); $method = $_SERVER['REQUEST_METHOD']; // TODO: This implementation is safe by default, but very incomplete. if ($this->getIsGitLFSRequest()) { return $this->isGitLFSReadOnlyRequest($repository); } switch ($repository->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: $service = $request->getStr('service'); $path = $this->getRequestDirectoryPath($repository); // NOTE: Service names are the reverse of what you might expect, as they // are from the point of view of the server. The main read service is // "git-upload-pack", and the main write service is "git-receive-pack". if ($method == 'GET' && $path == '/info/refs' && $service == 'git-upload-pack') { return true; } if ($path == '/git-upload-pack') { return true; } break; case PhabricatorRepositoryType::REPOSITORY_TYPE_MERCURIAL: $cmd = $request->getStr('cmd'); if ($cmd == 'batch') { $cmds = idx($this->getMercurialArguments(), 'cmds'); return DiffusionMercurialWireProtocol::isReadOnlyBatchCommand($cmds); } return DiffusionMercurialWireProtocol::isReadOnlyCommand($cmd); case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: break; } return false; } /** * @phutil-external-symbol class PhabricatorStartup */ private function serveGitRequest( PhabricatorRepository $repository, PhabricatorUser $viewer) { $request = $this->getRequest(); $request_path = $this->getRequestDirectoryPath($repository); $repository_root = $repository->getLocalPath(); // Rebuild the query string to strip `__magic__` parameters and prevent // issues where we might interpret inputs like "service=read&service=write" // differently than the server does and pass it an unsafe command. // NOTE: This does not use getPassthroughRequestParameters() because // that code is HTTP-method agnostic and will encode POST data. $query_data = $_GET; foreach ($query_data as $key => $value) { if (!strncmp($key, '__', 2)) { unset($query_data[$key]); } } - $query_string = http_build_query($query_data, '', '&'); + $query_string = phutil_build_http_querystring($query_data); // We're about to wipe out PATH with the rest of the environment, so // resolve the binary first. $bin = Filesystem::resolveBinary('git-http-backend'); if (!$bin) { throw new Exception( pht( 'Unable to find `%s` in %s!', 'git-http-backend', '$PATH')); } // NOTE: We do not set HTTP_CONTENT_ENCODING here, because we already // decompressed the request when we read the request body, so the body is // just plain data with no encoding. $env = array( 'REQUEST_METHOD' => $_SERVER['REQUEST_METHOD'], 'QUERY_STRING' => $query_string, 'CONTENT_TYPE' => $request->getHTTPHeader('Content-Type'), 'REMOTE_ADDR' => $_SERVER['REMOTE_ADDR'], 'GIT_PROJECT_ROOT' => $repository_root, 'GIT_HTTP_EXPORT_ALL' => '1', 'PATH_INFO' => $request_path, 'REMOTE_USER' => $viewer->getUsername(), // TODO: Set these correctly. // GIT_COMMITTER_NAME // GIT_COMMITTER_EMAIL ) + $this->getCommonEnvironment($viewer); $input = PhabricatorStartup::getRawInput(); $command = csprintf('%s', $bin); $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $cluster_engine = id(new DiffusionRepositoryClusterEngine()) ->setViewer($viewer) ->setRepository($repository); $did_write_lock = false; if ($this->isReadOnlyRequest($repository)) { $cluster_engine->synchronizeWorkingCopyBeforeRead(); } else { $did_write_lock = true; $cluster_engine->synchronizeWorkingCopyBeforeWrite(); } $caught = null; try { list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command)) ->setEnv($env, true) ->write($input) ->resolve(); } catch (Exception $ex) { $caught = $ex; } if ($did_write_lock) { $cluster_engine->synchronizeWorkingCopyAfterWrite(); } unset($unguarded); if ($caught) { throw $caught; } if ($err) { if ($this->isValidGitShallowCloneResponse($stdout, $stderr)) { // Ignore the error if the response passes this special check for // validity. $err = 0; } } if ($err) { return new PhabricatorVCSResponse( 500, pht( 'Error %d: %s', $err, phutil_utf8ize($stderr))); } return id(new DiffusionGitResponse())->setGitData($stdout); } private function getRequestDirectoryPath(PhabricatorRepository $repository) { $request = $this->getRequest(); $request_path = $request->getRequestURI()->getPath(); $info = PhabricatorRepository::parseRepositoryServicePath( $request_path, $repository->getVersionControlSystem()); $base_path = $info['path']; // For Git repositories, strip an optional directory component if it // isn't the name of a known Git resource. This allows users to clone // repositories as "/diffusion/X/anything.git", for example. if ($repository->isGit()) { $known = array( 'info', 'git-upload-pack', 'git-receive-pack', ); foreach ($known as $key => $path) { $known[$key] = preg_quote($path, '@'); } $known = implode('|', $known); if (preg_match('@^/([^/]+)/('.$known.')(/|$)@', $base_path)) { $base_path = preg_replace('@^/([^/]+)@', '', $base_path); } } return $base_path; } private function authenticateGitLFSUser( $username, PhutilOpaqueEnvelope $password) { // Never accept these credentials for requests which aren't LFS requests. if (!$this->getIsGitLFSRequest()) { return null; } // If we have the wrong username, don't bother checking if the token // is right. if ($username !== DiffusionGitLFSTemporaryTokenType::HTTP_USERNAME) { return null; } $lfs_pass = $password->openEnvelope(); $lfs_hash = PhabricatorHash::weakDigest($lfs_pass); $token = id(new PhabricatorAuthTemporaryTokenQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withTokenTypes(array(DiffusionGitLFSTemporaryTokenType::TOKENTYPE)) ->withTokenCodes(array($lfs_hash)) ->withExpired(false) ->executeOne(); if (!$token) { return null; } $user = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs(array($token->getUserPHID())) ->executeOne(); if (!$user) { return null; } if (!$user->isUserActivated()) { return null; } $this->gitLFSToken = $token; return $user; } private function authenticateHTTPRepositoryUser( $username, PhutilOpaqueEnvelope $password) { if (!PhabricatorEnv::getEnvConfig('diffusion.allow-http-auth')) { // No HTTP auth permitted. return null; } if (!strlen($username)) { // No username. return null; } if (!strlen($password->openEnvelope())) { // No password. return null; } $user = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withUsernames(array($username)) ->executeOne(); if (!$user) { // Username doesn't match anything. return null; } if (!$user->isUserActivated()) { // User is not activated. return null; } $request = $this->getRequest(); $content_source = PhabricatorContentSource::newFromRequest($request); $engine = id(new PhabricatorAuthPasswordEngine()) ->setViewer($user) ->setContentSource($content_source) ->setPasswordType(PhabricatorAuthPassword::PASSWORD_TYPE_VCS) ->setObject($user); if (!$engine->isValidPassword($password)) { return null; } return $user; } private function serveMercurialRequest( PhabricatorRepository $repository, PhabricatorUser $viewer) { $request = $this->getRequest(); $bin = Filesystem::resolveBinary('hg'); if (!$bin) { throw new Exception( pht( 'Unable to find `%s` in %s!', 'hg', '$PATH')); } $env = $this->getCommonEnvironment($viewer); $input = PhabricatorStartup::getRawInput(); $cmd = $request->getStr('cmd'); $args = $this->getMercurialArguments(); $args = $this->formatMercurialArguments($cmd, $args); if (strlen($input)) { $input = strlen($input)."\n".$input."0\n"; } $command = csprintf( '%s -R %s serve --stdio', $bin, $repository->getLocalPath()); $command = PhabricatorDaemon::sudoCommandAsDaemonUser($command); list($err, $stdout, $stderr) = id(new ExecFuture('%C', $command)) ->setEnv($env, true) ->setCWD($repository->getLocalPath()) ->write("{$cmd}\n{$args}{$input}") ->resolve(); if ($err) { return new PhabricatorVCSResponse( 500, pht('Error %d: %s', $err, $stderr)); } if ($cmd == 'getbundle' || $cmd == 'changegroup' || $cmd == 'changegroupsubset') { // We're not completely sure that "changegroup" and "changegroupsubset" // actually work, they're for very old Mercurial. $body = gzcompress($stdout); } else if ($cmd == 'unbundle') { // This includes diagnostic information and anything echoed by commit // hooks. We ignore `stdout` since it just has protocol garbage, and // substitute `stderr`. $body = strlen($stderr)."\n".$stderr; } else { list($length, $body) = explode("\n", $stdout, 2); if ($cmd == 'capabilities') { $body = DiffusionMercurialWireProtocol::filterBundle2Capability($body); } } return id(new DiffusionMercurialResponse())->setContent($body); } private function getMercurialArguments() { // Mercurial sends arguments in HTTP headers. "Why?", you might wonder, // "Why would you do this?". $args_raw = array(); for ($ii = 1;; $ii++) { $header = 'HTTP_X_HGARG_'.$ii; if (!array_key_exists($header, $_SERVER)) { break; } $args_raw[] = $_SERVER[$header]; } $args_raw = implode('', $args_raw); return id(new PhutilQueryStringParser()) ->parseQueryString($args_raw); } private function formatMercurialArguments($command, array $arguments) { $spec = DiffusionMercurialWireProtocol::getCommandArgs($command); $out = array(); // Mercurial takes normal arguments like this: // // name // value $has_star = false; foreach ($spec as $arg_key) { if ($arg_key == '*') { $has_star = true; continue; } if (isset($arguments[$arg_key])) { $value = $arguments[$arg_key]; $size = strlen($value); $out[] = "{$arg_key} {$size}\n{$value}"; unset($arguments[$arg_key]); } } if ($has_star) { // Mercurial takes arguments for variable argument lists roughly like // this: // // * // argname1 // argvalue1 // argname2 // argvalue2 $count = count($arguments); $out[] = "* {$count}\n"; foreach ($arguments as $key => $value) { if (in_array($key, $spec)) { // We already added this argument above, so skip it. continue; } $size = strlen($value); $out[] = "{$key} {$size}\n{$value}"; } } return implode('', $out); } private function isValidGitShallowCloneResponse($stdout, $stderr) { // If you execute `git clone --depth N ...`, git sends a request which // `git-http-backend` responds to by emitting valid output and then exiting // with a failure code and an error message. If we ignore this error, // everything works. // This is a pretty funky fix: it would be nice to more precisely detect // that a request is a `--depth N` clone request, but we don't have any code // to decode protocol frames yet. Instead, look for reasonable evidence // in the error and output that we're looking at a `--depth` clone. // For evidence this isn't completely crazy, see: // https://github.com/schacon/grack/pull/7 $stdout_regexp = '(^Content-Type: application/x-git-upload-pack-result)m'; $stderr_regexp = '(The remote end hung up unexpectedly)'; $has_pack = preg_match($stdout_regexp, $stdout); $is_hangup = preg_match($stderr_regexp, $stderr); return $has_pack && $is_hangup; } private function getCommonEnvironment(PhabricatorUser $viewer) { $remote_address = $this->getRequest()->getRemoteAddress(); return array( DiffusionCommitHookEngine::ENV_USER => $viewer->getUsername(), DiffusionCommitHookEngine::ENV_REMOTE_ADDRESS => $remote_address, DiffusionCommitHookEngine::ENV_REMOTE_PROTOCOL => 'http', ); } private function validateGitLFSRequest( PhabricatorRepository $repository, PhabricatorUser $viewer) { if (!$this->getIsGitLFSRequest()) { return null; } if (!$repository->canUseGitLFS()) { return new PhabricatorVCSResponse( 403, pht( 'The requested repository ("%s") does not support Git LFS.', $repository->getDisplayName())); } // If this is using an LFS token, sanity check that we're using it on the // correct repository. This shouldn't really matter since the user could // just request a proper token anyway, but it suspicious and should not // be permitted. $token = $this->getGitLFSToken(); if ($token) { $resource = $token->getTokenResource(); if ($resource !== $repository->getPHID()) { return new PhabricatorVCSResponse( 403, pht( 'The authentication token provided in the request is bound to '. 'a different repository than the requested repository ("%s").', $repository->getDisplayName())); } } return null; } private function serveGitLFSRequest( PhabricatorRepository $repository, PhabricatorUser $viewer) { if (!$this->getIsGitLFSRequest()) { throw new Exception(pht('This is not a Git LFS request!')); } $path = $this->getGitLFSRequestPath($repository); $matches = null; if (preg_match('(^upload/(.*)\z)', $path, $matches)) { $oid = $matches[1]; return $this->serveGitLFSUploadRequest($repository, $viewer, $oid); } else if ($path == 'objects/batch') { return $this->serveGitLFSBatchRequest($repository, $viewer); } else { return DiffusionGitLFSResponse::newErrorResponse( 404, pht( 'Git LFS operation "%s" is not supported by this server.', $path)); } } private function serveGitLFSBatchRequest( PhabricatorRepository $repository, PhabricatorUser $viewer) { $input = $this->getGitLFSInput(); $operation = idx($input, 'operation'); switch ($operation) { case 'upload': $want_upload = true; break; case 'download': $want_upload = false; break; default: return DiffusionGitLFSResponse::newErrorResponse( 404, pht( 'Git LFS batch operation "%s" is not supported by this server.', $operation)); } $objects = idx($input, 'objects', array()); $hashes = array(); foreach ($objects as $object) { $hashes[] = idx($object, 'oid'); } if ($hashes) { $refs = id(new PhabricatorRepositoryGitLFSRefQuery()) ->setViewer($viewer) ->withRepositoryPHIDs(array($repository->getPHID())) ->withObjectHashes($hashes) ->execute(); $refs = mpull($refs, null, 'getObjectHash'); } else { $refs = array(); } $file_phids = mpull($refs, 'getFilePHID'); if ($file_phids) { $files = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs($file_phids) ->execute(); $files = mpull($files, null, 'getPHID'); } else { $files = array(); } $authorization = null; $output = array(); foreach ($objects as $object) { $oid = idx($object, 'oid'); $size = idx($object, 'size'); $ref = idx($refs, $oid); $error = null; // NOTE: If we already have a ref for this object, we only emit a // "download" action. The client should not upload the file again. $actions = array(); if ($ref) { $file = idx($files, $ref->getFilePHID()); if ($file) { // Git LFS may prompt users for authentication if the action does // not provide an "Authorization" header and does not have a query // parameter named "token". See here for discussion: // $no_authorization = 'Basic '.base64_encode('none'); $get_uri = $file->getCDNURI('data'); $actions['download'] = array( 'href' => $get_uri, 'header' => array( 'Authorization' => $no_authorization, 'X-Phabricator-Request-Type' => 'git-lfs', ), ); } else { $error = array( 'code' => 404, 'message' => pht( 'Object "%s" was previously uploaded, but no longer exists '. 'on this server.', $oid), ); } } else if ($want_upload) { if (!$authorization) { // Here, we could reuse the existing authorization if we have one, // but it's a little simpler to just generate a new one // unconditionally. $authorization = $this->newGitLFSHTTPAuthorization( $repository, $viewer, $operation); } $put_uri = $repository->getGitLFSURI("info/lfs/upload/{$oid}"); $actions['upload'] = array( 'href' => $put_uri, 'header' => array( 'Authorization' => $authorization, 'X-Phabricator-Request-Type' => 'git-lfs', ), ); } $object = array( 'oid' => $oid, 'size' => $size, ); if ($actions) { $object['actions'] = $actions; } if ($error) { $object['error'] = $error; } $output[] = $object; } $output = array( 'objects' => $output, ); return id(new DiffusionGitLFSResponse()) ->setContent($output); } private function serveGitLFSUploadRequest( PhabricatorRepository $repository, PhabricatorUser $viewer, $oid) { $ref = id(new PhabricatorRepositoryGitLFSRefQuery()) ->setViewer($viewer) ->withRepositoryPHIDs(array($repository->getPHID())) ->withObjectHashes(array($oid)) ->executeOne(); if ($ref) { return DiffusionGitLFSResponse::newErrorResponse( 405, pht( 'Content for object "%s" is already known to this server. It can '. 'not be uploaded again.', $oid)); } // Remove the execution time limit because uploading large files may take // a while. set_time_limit(0); $request_stream = new AphrontRequestStream(); $request_iterator = $request_stream->getIterator(); $hashing_iterator = id(new PhutilHashingIterator($request_iterator)) ->setAlgorithm('sha256'); $source = id(new PhabricatorIteratorFileUploadSource()) ->setName('lfs-'.$oid) ->setViewPolicy(PhabricatorPolicies::POLICY_NOONE) ->setIterator($hashing_iterator); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $file = $source->uploadFile(); unset($unguarded); $hash = $hashing_iterator->getHash(); if ($hash !== $oid) { return DiffusionGitLFSResponse::newErrorResponse( 400, pht( 'Uploaded data is corrupt or invalid. Expected hash "%s", actual '. 'hash "%s".', $oid, $hash)); } $ref = id(new PhabricatorRepositoryGitLFSRef()) ->setRepositoryPHID($repository->getPHID()) ->setObjectHash($hash) ->setByteSize($file->getByteSize()) ->setAuthorPHID($viewer->getPHID()) ->setFilePHID($file->getPHID()); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); // Attach the file to the repository to give users permission // to access it. $file->attachToObject($repository->getPHID()); $ref->save(); unset($unguarded); // This is just a plain HTTP 200 with no content, which is what `git lfs` // expects. return new DiffusionGitLFSResponse(); } private function newGitLFSHTTPAuthorization( PhabricatorRepository $repository, PhabricatorUser $viewer, $operation) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $authorization = DiffusionGitLFSTemporaryTokenType::newHTTPAuthorization( $repository, $viewer, $operation); unset($unguarded); return $authorization; } private function getGitLFSRequestPath(PhabricatorRepository $repository) { $request_path = $this->getRequestDirectoryPath($repository); $matches = null; if (preg_match('(^/info/lfs(?:\z|/)(.*))', $request_path, $matches)) { return $matches[1]; } return null; } private function getGitLFSInput() { if (!$this->gitLFSInput) { $input = PhabricatorStartup::getRawInput(); $input = phutil_json_decode($input); $this->gitLFSInput = $input; } return $this->gitLFSInput; } private function isGitLFSReadOnlyRequest(PhabricatorRepository $repository) { if (!$this->getIsGitLFSRequest()) { return false; } $path = $this->getGitLFSRequestPath($repository); if ($path === 'objects/batch') { $input = $this->getGitLFSInput(); $operation = idx($input, 'operation'); switch ($operation) { case 'download': return true; default: return false; } } return false; } }