diff --git a/src/applications/differential/field/DifferentialRevisionIDCommitMessageField.php b/src/applications/differential/field/DifferentialRevisionIDCommitMessageField.php index 8c85553e71..ac8ba4ebd4 100644 --- a/src/applications/differential/field/DifferentialRevisionIDCommitMessageField.php +++ b/src/applications/differential/field/DifferentialRevisionIDCommitMessageField.php @@ -1,100 +1,86 @@ <?php final class DifferentialRevisionIDCommitMessageField extends DifferentialCommitMessageField { const FIELDKEY = 'revisionID'; public function getFieldName() { return pht('Differential Revision'); } public function getFieldOrder() { return 200000; } public function isTemplateField() { return false; } public function parseFieldValue($value) { // If the complete commit message we are parsing has unrecognized custom // fields at the end, they can end up parsed into the field value for this // field. For example, if the message looks like this: // Differential Revision: xyz // Some-Other-Field: abc // ...we will receive "xyz\nSome-Other-Field: abc" as the field value for // this field. Ideally, the install would define these fields so they can // parse formally, but we can reasonably assume that only the first line // of any value we encounter actually contains a revision identifier, so // start by throwing away any other lines. $value = trim($value); $value = phutil_split_lines($value, false); $value = head($value); $value = trim($value); // If the value is just "D123" or similar, parse the ID from it directly. $matches = null; if (preg_match('/^[dD]([1-9]\d*)\z/', $value, $matches)) { return (int)$matches[1]; } // Otherwise, try to extract a URI value. return self::parseRevisionIDFromURI($value); } private static function parseRevisionIDFromURI($uri_string) { $uri = new PhutilURI($uri_string); $path = $uri->getPath(); - $matches = null; - if (preg_match('#^/D(\d+)$#', $path, $matches)) { - $id = (int)$matches[1]; - - $prod_uri = new PhutilURI(PhabricatorEnv::getProductionURI('/D'.$id)); - - // Make sure the URI is the same as our URI. Basically, we want to ignore - // commits from other Phabricator installs. - if ($uri->getDomain() == $prod_uri->getDomain()) { - return $id; - } - - $allowed_uris = PhabricatorEnv::getAllowedURIs('/D'.$id); - - foreach ($allowed_uris as $allowed_uri) { - if ($uri_string == $allowed_uri) { - return $id; - } + if (PhabricatorEnv::isSelfURI($uri_string)) { + $matches = null; + if (preg_match('#^/D(\d+)$#', $path, $matches)) { + return (int)$matches[1]; } } return null; } public function readFieldValueFromObject(DifferentialRevision $revision) { return $revision->getID(); } public function readFieldValueFromConduit($value) { if (is_int($value)) { $value = (string)$value; } return $this->readStringFieldValueFromConduit($value); } public function renderFieldValue($value) { if (!strlen($value)) { return null; } return PhabricatorEnv::getProductionURI('/D'.$value); } public function getFieldTransactions($value) { return array(); } } diff --git a/src/applications/differential/field/__tests__/DifferentialCommitMessageFieldTestCase.php b/src/applications/differential/field/__tests__/DifferentialCommitMessageFieldTestCase.php index 295da8ca59..41fd2992f4 100644 --- a/src/applications/differential/field/__tests__/DifferentialCommitMessageFieldTestCase.php +++ b/src/applications/differential/field/__tests__/DifferentialCommitMessageFieldTestCase.php @@ -1,30 +1,31 @@ <?php final class DifferentialCommitMessageFieldTestCase extends PhabricatorTestCase { public function testRevisionCommitMessageFieldParsing() { $base_uri = 'https://www.example.com/'; $tests = array( 'D123' => 123, 'd123' => 123, " \n d123 \n " => 123, "D123\nSome-Custom-Field: The End" => 123, "{$base_uri}D123" => 123, "{$base_uri}D123\nSome-Custom-Field: The End" => 123, + 'https://www.other.com/D123' => null, ); $env = PhabricatorEnv::beginScopedEnv(); $env->overrideEnvConfig('phabricator.base-uri', $base_uri); foreach ($tests as $input => $expect) { $actual = id(new DifferentialRevisionIDCommitMessageField()) ->parseFieldValue($input); $this->assertEqual($expect, $actual, pht('Parse of: %s', $input)); } unset($env); } } diff --git a/src/infrastructure/env/PhabricatorEnv.php b/src/infrastructure/env/PhabricatorEnv.php index b50c85c0f0..70ddb80630 100644 --- a/src/infrastructure/env/PhabricatorEnv.php +++ b/src/infrastructure/env/PhabricatorEnv.php @@ -1,940 +1,963 @@ <?php /** * Manages the execution environment configuration, exposing APIs to read * configuration settings and other similar values that are derived directly * from configuration settings. * * * = Reading Configuration = * * The primary role of this class is to provide an API for reading * Phabricator configuration, @{method:getEnvConfig}: * * $value = PhabricatorEnv::getEnvConfig('some.key', $default); * * The class also handles some URI construction based on configuration, via * the methods @{method:getURI}, @{method:getProductionURI}, * @{method:getCDNURI}, and @{method:getDoclink}. * * For configuration which allows you to choose a class to be responsible for * some functionality (e.g., which mail adapter to use to deliver email), * @{method:newObjectFromConfig} provides a simple interface that validates * the configured value. * * * = Unit Test Support = * * In unit tests, you can use @{method:beginScopedEnv} to create a temporary, * mutable environment. The method returns a scope guard object which restores * the environment when it is destroyed. For example: * * public function testExample() { * $env = PhabricatorEnv::beginScopedEnv(); * $env->overrideEnv('some.key', 'new-value-for-this-test'); * * // Some test which depends on the value of 'some.key'. * * } * * Your changes will persist until the `$env` object leaves scope or is * destroyed. * * You should //not// use this in normal code. * * * @task read Reading Configuration * @task uri URI Validation * @task test Unit Test Support * @task internal Internals */ final class PhabricatorEnv extends Phobject { private static $sourceStack; private static $repairSource; private static $overrideSource; private static $requestBaseURI; private static $cache; private static $localeCode; private static $readOnly; private static $readOnlyReason; const READONLY_CONFIG = 'config'; const READONLY_UNREACHABLE = 'unreachable'; const READONLY_SEVERED = 'severed'; const READONLY_MASTERLESS = 'masterless'; /** * @phutil-external-symbol class PhabricatorStartup */ public static function initializeWebEnvironment() { self::initializeCommonEnvironment(false); } public static function initializeScriptEnvironment($config_optional) { self::initializeCommonEnvironment($config_optional); // NOTE: This is dangerous in general, but we know we're in a script context // and are not vulnerable to CSRF. AphrontWriteGuard::allowDangerousUnguardedWrites(true); // There are several places where we log information (about errors, events, // service calls, etc.) for analysis via DarkConsole or similar. These are // useful for web requests, but grow unboundedly in long-running scripts and // daemons. Discard data as it arrives in these cases. PhutilServiceProfiler::getInstance()->enableDiscardMode(); DarkConsoleErrorLogPluginAPI::enableDiscardMode(); DarkConsoleEventPluginAPI::enableDiscardMode(); } private static function initializeCommonEnvironment($config_optional) { PhutilErrorHandler::initialize(); self::resetUmask(); self::buildConfigurationSourceStack($config_optional); // Force a valid timezone. If both PHP and Phabricator configuration are // invalid, use UTC. $tz = self::getEnvConfig('phabricator.timezone'); if ($tz) { @date_default_timezone_set($tz); } $ok = @date_default_timezone_set(date_default_timezone_get()); if (!$ok) { date_default_timezone_set('UTC'); } // Prepend '/support/bin' and append any paths to $PATH if we need to. $env_path = getenv('PATH'); $phabricator_path = dirname(phutil_get_library_root('phabricator')); $support_path = $phabricator_path.'/support/bin'; $env_path = $support_path.PATH_SEPARATOR.$env_path; $append_dirs = self::getEnvConfig('environment.append-paths'); if (!empty($append_dirs)) { $append_path = implode(PATH_SEPARATOR, $append_dirs); $env_path = $env_path.PATH_SEPARATOR.$append_path; } putenv('PATH='.$env_path); // Write this back into $_ENV, too, so ExecFuture picks it up when creating // subprocess environments. $_ENV['PATH'] = $env_path; // If an instance identifier is defined, write it into the environment so // it's available to subprocesses. $instance = self::getEnvConfig('cluster.instance'); if (strlen($instance)) { putenv('PHABRICATOR_INSTANCE='.$instance); $_ENV['PHABRICATOR_INSTANCE'] = $instance; } PhabricatorEventEngine::initialize(); // TODO: Add a "locale.default" config option once we have some reasonable // defaults which aren't silly nonsense. self::setLocaleCode('en_US'); } public static function beginScopedLocale($locale_code) { return new PhabricatorLocaleScopeGuard($locale_code); } public static function getLocaleCode() { return self::$localeCode; } public static function setLocaleCode($locale_code) { if (!$locale_code) { return; } if ($locale_code == self::$localeCode) { return; } try { $locale = PhutilLocale::loadLocale($locale_code); $translations = PhutilTranslation::getTranslationMapForLocale( $locale_code); $override = self::getEnvConfig('translation.override'); if (!is_array($override)) { $override = array(); } PhutilTranslator::getInstance() ->setLocale($locale) ->setTranslations($override + $translations); self::$localeCode = $locale_code; } catch (Exception $ex) { // Just ignore this; the user likely has an out-of-date locale code. } } private static function buildConfigurationSourceStack($config_optional) { self::dropConfigCache(); $stack = new PhabricatorConfigStackSource(); self::$sourceStack = $stack; $default_source = id(new PhabricatorConfigDefaultSource()) ->setName(pht('Global Default')); $stack->pushSource($default_source); $env = self::getSelectedEnvironmentName(); if ($env) { $stack->pushSource( id(new PhabricatorConfigFileSource($env)) ->setName(pht("File '%s'", $env))); } $stack->pushSource( id(new PhabricatorConfigLocalSource()) ->setName(pht('Local Config'))); // If the install overrides the database adapter, we might need to load // the database adapter class before we can push on the database config. // This config is locked and can't be edited from the web UI anyway. foreach (self::getEnvConfig('load-libraries') as $library) { phutil_load_library($library); } // Drop any class map caches, since they will have generated without // any classes from libraries. Without this, preflight setup checks can // cause generation of a setup check cache that omits checks defined in // libraries, for example. PhutilClassMapQuery::deleteCaches(); // If custom libraries specify config options, they won't get default // values as the Default source has already been loaded, so we get it to // pull in all options from non-phabricator libraries now they are loaded. $default_source->loadExternalOptions(); // If this install has site config sources, load them now. $site_sources = id(new PhutilClassMapQuery()) ->setAncestorClass('PhabricatorConfigSiteSource') ->setSortMethod('getPriority') ->execute(); foreach ($site_sources as $site_source) { $stack->pushSource($site_source); } $masters = PhabricatorDatabaseRef::getMasterDatabaseRefs(); if (!$masters) { self::setReadOnly(true, self::READONLY_MASTERLESS); } else { // If any master is severed, we drop to readonly mode. In theory we // could try to continue if we're only missing some applications, but // this is very complex and we're unlikely to get it right. foreach ($masters as $master) { // Give severed masters one last chance to get healthy. if ($master->isSevered()) { $master->checkHealth(); } if ($master->isSevered()) { self::setReadOnly(true, self::READONLY_SEVERED); break; } } } try { $stack->pushSource( id(new PhabricatorConfigDatabaseSource('default')) ->setName(pht('Database'))); } catch (AphrontSchemaQueryException $exception) { // If the database is not available, just skip this configuration // source. This happens during `bin/storage upgrade`, `bin/conf` before // schema setup, etc. } catch (PhabricatorClusterStrandedException $ex) { // This means we can't connect to any database host. That's fine as // long as we're running a setup script like `bin/storage`. if (!$config_optional) { throw $ex; } } } public static function repairConfig($key, $value) { if (!self::$repairSource) { self::$repairSource = id(new PhabricatorConfigDictionarySource(array())) ->setName(pht('Repaired Config')); self::$sourceStack->pushSource(self::$repairSource); } self::$repairSource->setKeys(array($key => $value)); self::dropConfigCache(); } public static function overrideConfig($key, $value) { if (!self::$overrideSource) { self::$overrideSource = id(new PhabricatorConfigDictionarySource(array())) ->setName(pht('Overridden Config')); self::$sourceStack->pushSource(self::$overrideSource); } self::$overrideSource->setKeys(array($key => $value)); self::dropConfigCache(); } public static function getUnrepairedEnvConfig($key, $default = null) { foreach (self::$sourceStack->getStack() as $source) { if ($source === self::$repairSource) { continue; } $result = $source->getKeys(array($key)); if ($result) { return $result[$key]; } } return $default; } public static function getSelectedEnvironmentName() { $env_var = 'PHABRICATOR_ENV'; $env = idx($_SERVER, $env_var); if (!$env) { $env = getenv($env_var); } if (!$env) { $env = idx($_ENV, $env_var); } if (!$env) { $root = dirname(phutil_get_library_root('phabricator')); $path = $root.'/conf/local/ENVIRONMENT'; if (Filesystem::pathExists($path)) { $env = trim(Filesystem::readFile($path)); } } return $env; } /* -( Reading Configuration )---------------------------------------------- */ /** * Get the current configuration setting for a given key. * * If the key is not found, then throw an Exception. * * @task read */ public static function getEnvConfig($key) { if (!self::$sourceStack) { throw new Exception( pht( 'Trying to read configuration "%s" before configuration has been '. 'initialized.', $key)); } if (isset(self::$cache[$key])) { return self::$cache[$key]; } if (array_key_exists($key, self::$cache)) { return self::$cache[$key]; } $result = self::$sourceStack->getKeys(array($key)); if (array_key_exists($key, $result)) { self::$cache[$key] = $result[$key]; return $result[$key]; } else { throw new Exception( pht( "No config value specified for key '%s'.", $key)); } } /** * Get the current configuration setting for a given key. If the key * does not exist, return a default value instead of throwing. This is * primarily useful for migrations involving keys which are slated for * removal. * * @task read */ public static function getEnvConfigIfExists($key, $default = null) { try { return self::getEnvConfig($key); } catch (Exception $ex) { return $default; } } /** * Get the fully-qualified URI for a path. * * @task read */ public static function getURI($path) { return rtrim(self::getAnyBaseURI(), '/').$path; } /** * Get the fully-qualified production URI for a path. * * @task read */ public static function getProductionURI($path) { // If we're passed a URI which already has a domain, simply return it // unmodified. In particular, files may have URIs which point to a CDN // domain. $uri = new PhutilURI($path); if ($uri->getDomain()) { return $path; } $production_domain = self::getEnvConfig('phabricator.production-uri'); if (!$production_domain) { $production_domain = self::getAnyBaseURI(); } return rtrim($production_domain, '/').$path; } - public static function getAllowedURIs($path) { - $uri = new PhutilURI($path); - if ($uri->getDomain()) { - return $path; + + public static function isSelfURI($raw_uri) { + $uri = new PhutilURI($raw_uri); + + $host = $uri->getDomain(); + if (!strlen($host)) { + return false; } + $host = phutil_utf8_strtolower($host); + + $self_map = self::getSelfURIMap(); + return isset($self_map[$host]); + } + + private static function getSelfURIMap() { + $self_uris = array(); + $self_uris[] = self::getProductionURI('/'); + $self_uris[] = self::getURI('/'); + $allowed_uris = self::getEnvConfig('phabricator.allowed-uris'); - $return = array(); foreach ($allowed_uris as $allowed_uri) { - $return[] = rtrim($allowed_uri, '/').$path; + $self_uris[] = $allowed_uri; } - return $return; - } + $self_map = array(); + foreach ($self_uris as $self_uri) { + $host = id(new PhutilURI($self_uri))->getDomain(); + if (!strlen($host)) { + continue; + } + + $host = phutil_utf8_strtolower($host); + $self_map[$host] = $host; + } + return $self_map; + } /** * Get the fully-qualified production URI for a static resource path. * * @task read */ public static function getCDNURI($path) { $alt = self::getEnvConfig('security.alternate-file-domain'); if (!$alt) { $alt = self::getAnyBaseURI(); } $uri = new PhutilURI($alt); $uri->setPath($path); return (string)$uri; } /** * Get the fully-qualified production URI for a documentation resource. * * @task read */ public static function getDoclink($resource, $type = 'article') { $uri = new PhutilURI('https://secure.phabricator.com/diviner/find/'); $uri->setQueryParam('name', $resource); $uri->setQueryParam('type', $type); $uri->setQueryParam('jump', true); return (string)$uri; } /** * Build a concrete object from a configuration key. * * @task read */ public static function newObjectFromConfig($key, $args = array()) { $class = self::getEnvConfig($key); return newv($class, $args); } public static function getAnyBaseURI() { $base_uri = self::getEnvConfig('phabricator.base-uri'); if (!$base_uri) { $base_uri = self::getRequestBaseURI(); } if (!$base_uri) { throw new Exception( pht( "Define '%s' in your configuration to continue.", 'phabricator.base-uri')); } return $base_uri; } public static function getRequestBaseURI() { return self::$requestBaseURI; } public static function setRequestBaseURI($uri) { self::$requestBaseURI = $uri; } public static function isReadOnly() { if (self::$readOnly !== null) { return self::$readOnly; } return self::getEnvConfig('cluster.read-only'); } public static function setReadOnly($read_only, $reason) { self::$readOnly = $read_only; self::$readOnlyReason = $reason; } public static function getReadOnlyMessage() { $reason = self::getReadOnlyReason(); switch ($reason) { case self::READONLY_MASTERLESS: return pht( 'Phabricator is in read-only mode (no writable database '. 'is configured).'); case self::READONLY_UNREACHABLE: return pht( 'Phabricator is in read-only mode (unreachable master).'); case self::READONLY_SEVERED: return pht( 'Phabricator is in read-only mode (major interruption).'); } return pht('Phabricator is in read-only mode.'); } public static function getReadOnlyURI() { return urisprintf( '/readonly/%s/', self::getReadOnlyReason()); } public static function getReadOnlyReason() { if (!self::isReadOnly()) { return null; } if (self::$readOnlyReason !== null) { return self::$readOnlyReason; } return self::READONLY_CONFIG; } /* -( Unit Test Support )-------------------------------------------------- */ /** * @task test */ public static function beginScopedEnv() { return new PhabricatorScopedEnv(self::pushTestEnvironment()); } /** * @task test */ private static function pushTestEnvironment() { self::dropConfigCache(); $source = new PhabricatorConfigDictionarySource(array()); self::$sourceStack->pushSource($source); return spl_object_hash($source); } /** * @task test */ public static function popTestEnvironment($key) { self::dropConfigCache(); $source = self::$sourceStack->popSource(); $stack_key = spl_object_hash($source); if ($stack_key !== $key) { self::$sourceStack->pushSource($source); throw new Exception( pht( 'Scoped environments were destroyed in a different order than they '. 'were initialized.')); } } /* -( URI Validation )----------------------------------------------------- */ /** * Detect if a URI satisfies either @{method:isValidLocalURIForLink} or * @{method:isValidRemoteURIForLink}, i.e. is a page on this server or the * URI of some other resource which has a valid protocol. This rejects * garbage URIs and URIs with protocols which do not appear in the * `uri.allowed-protocols` configuration, notably 'javascript:' URIs. * * NOTE: This method is generally intended to reject URIs which it may be * unsafe to put in an "href" link attribute. * * @param string URI to test. * @return bool True if the URI identifies a web resource. * @task uri */ public static function isValidURIForLink($uri) { return self::isValidLocalURIForLink($uri) || self::isValidRemoteURIForLink($uri); } /** * Detect if a URI identifies some page on this server. * * NOTE: This method is generally intended to reject URIs which it may be * unsafe to issue a "Location:" redirect to. * * @param string URI to test. * @return bool True if the URI identifies a local page. * @task uri */ public static function isValidLocalURIForLink($uri) { $uri = (string)$uri; if (!strlen($uri)) { return false; } if (preg_match('/\s/', $uri)) { // PHP hasn't been vulnerable to header injection attacks for a bunch of // years, but we can safely reject these anyway since they're never valid. return false; } // Chrome (at a minimum) interprets backslashes in Location headers and the // URL bar as forward slashes. This is probably intended to reduce user // error caused by confusion over which key is "forward slash" vs "back // slash". // // However, it means a URI like "/\evil.com" is interpreted like // "//evil.com", which is a protocol relative remote URI. // // Since we currently never generate URIs with backslashes in them, reject // these unconditionally rather than trying to figure out how browsers will // interpret them. if (preg_match('/\\\\/', $uri)) { return false; } // Valid URIs must begin with '/', followed by the end of the string or some // other non-'/' character. This rejects protocol-relative URIs like // "//evil.com/evil_stuff/". return (bool)preg_match('@^/([^/]|$)@', $uri); } /** * Detect if a URI identifies some valid linkable remote resource. * * @param string URI to test. * @return bool True if a URI idenfies a remote resource with an allowed * protocol. * @task uri */ public static function isValidRemoteURIForLink($uri) { try { self::requireValidRemoteURIForLink($uri); return true; } catch (Exception $ex) { return false; } } /** * Detect if a URI identifies a valid linkable remote resource, throwing a * detailed message if it does not. * * A valid linkable remote resource can be safely linked or redirected to. * This is primarily a protocol whitelist check. * * @param string URI to test. * @return void * @task uri */ public static function requireValidRemoteURIForLink($raw_uri) { $uri = new PhutilURI($raw_uri); $proto = $uri->getProtocol(); if (!strlen($proto)) { throw new Exception( pht( 'URI "%s" is not a valid linkable resource. A valid linkable '. 'resource URI must specify a protocol.', $raw_uri)); } $protocols = self::getEnvConfig('uri.allowed-protocols'); if (!isset($protocols[$proto])) { throw new Exception( pht( 'URI "%s" is not a valid linkable resource. A valid linkable '. 'resource URI must use one of these protocols: %s.', $raw_uri, implode(', ', array_keys($protocols)))); } $domain = $uri->getDomain(); if (!strlen($domain)) { throw new Exception( pht( 'URI "%s" is not a valid linkable resource. A valid linkable '. 'resource URI must specify a domain.', $raw_uri)); } } /** * Detect if a URI identifies a valid fetchable remote resource. * * @param string URI to test. * @param list<string> Allowed protocols. * @return bool True if the URI is a valid fetchable remote resource. * @task uri */ public static function isValidRemoteURIForFetch($uri, array $protocols) { try { self::requireValidRemoteURIForFetch($uri, $protocols); return true; } catch (Exception $ex) { return false; } } /** * Detect if a URI identifies a valid fetchable remote resource, throwing * a detailed message if it does not. * * A valid fetchable remote resource can be safely fetched using a request * originating on this server. This is a primarily an address check against * the outbound address blacklist. * * @param string URI to test. * @param list<string> Allowed protocols. * @return pair<string, string> Pre-resolved URI and domain. * @task uri */ public static function requireValidRemoteURIForFetch( $raw_uri, array $protocols) { $uri = new PhutilURI($raw_uri); $proto = $uri->getProtocol(); if (!strlen($proto)) { throw new Exception( pht( 'URI "%s" is not a valid fetchable resource. A valid fetchable '. 'resource URI must specify a protocol.', $raw_uri)); } $protocols = array_fuse($protocols); if (!isset($protocols[$proto])) { throw new Exception( pht( 'URI "%s" is not a valid fetchable resource. A valid fetchable '. 'resource URI must use one of these protocols: %s.', $raw_uri, implode(', ', array_keys($protocols)))); } $domain = $uri->getDomain(); if (!strlen($domain)) { throw new Exception( pht( 'URI "%s" is not a valid fetchable resource. A valid fetchable '. 'resource URI must specify a domain.', $raw_uri)); } $addresses = gethostbynamel($domain); if (!$addresses) { throw new Exception( pht( 'URI "%s" is not a valid fetchable resource. The domain "%s" could '. 'not be resolved.', $raw_uri, $domain)); } foreach ($addresses as $address) { if (self::isBlacklistedOutboundAddress($address)) { throw new Exception( pht( 'URI "%s" is not a valid fetchable resource. The domain "%s" '. 'resolves to the address "%s", which is blacklisted for '. 'outbound requests.', $raw_uri, $domain, $address)); } } $resolved_uri = clone $uri; $resolved_uri->setDomain(head($addresses)); return array($resolved_uri, $domain); } /** * Determine if an IP address is in the outbound address blacklist. * * @param string IP address. * @return bool True if the address is blacklisted. */ public static function isBlacklistedOutboundAddress($address) { $blacklist = self::getEnvConfig('security.outbound-blacklist'); return PhutilCIDRList::newList($blacklist)->containsAddress($address); } public static function isClusterRemoteAddress() { $cluster_addresses = self::getEnvConfig('cluster.addresses'); if (!$cluster_addresses) { return false; } $address = self::getRemoteAddress(); if (!$address) { throw new Exception( pht( 'Unable to test remote address against cluster whitelist: '. 'REMOTE_ADDR is not defined or not valid.')); } return self::isClusterAddress($address); } public static function isClusterAddress($address) { $cluster_addresses = self::getEnvConfig('cluster.addresses'); if (!$cluster_addresses) { throw new Exception( pht( 'Phabricator is not configured to serve cluster requests. '. 'Set `cluster.addresses` in the configuration to whitelist '. 'cluster hosts before sending requests that use a cluster '. 'authentication mechanism.')); } return PhutilCIDRList::newList($cluster_addresses) ->containsAddress($address); } public static function getRemoteAddress() { $address = idx($_SERVER, 'REMOTE_ADDR'); if (!$address) { return null; } try { return PhutilIPAddress::newAddress($address); } catch (Exception $ex) { return null; } } /* -( Internals )---------------------------------------------------------- */ /** * @task internal */ public static function envConfigExists($key) { return array_key_exists($key, self::$sourceStack->getKeys(array($key))); } /** * @task internal */ public static function getAllConfigKeys() { return self::$sourceStack->getAllKeys(); } public static function getConfigSourceStack() { return self::$sourceStack; } /** * @task internal */ public static function overrideTestEnvConfig($stack_key, $key, $value) { $tmp = array(); // If we don't have the right key, we'll throw when popping the last // source off the stack. do { $source = self::$sourceStack->popSource(); array_unshift($tmp, $source); if (spl_object_hash($source) == $stack_key) { $source->setKeys(array($key => $value)); break; } } while (true); foreach ($tmp as $source) { self::$sourceStack->pushSource($source); } self::dropConfigCache(); } private static function dropConfigCache() { self::$cache = array(); } private static function resetUmask() { // Reset the umask to the common standard umask. The umask controls default // permissions when files are created and propagates to subprocesses. // "022" is the most common umask, but sometimes it is set to something // unusual by the calling environment. // Since various things rely on this umask to work properly and we are // not aware of any legitimate reasons to adjust it, unconditionally // normalize it until such reasons arise. See T7475 for discussion. umask(022); } /** * Get the path to an empty directory which is readable by all of the system * user accounts that Phabricator acts as. * * In some cases, a binary needs some valid HOME or CWD to continue, but not * all user accounts have valid home directories and even if they do they * may not be readable after a `sudo` operation. * * @return string Path to an empty directory suitable for use as a CWD. */ public static function getEmptyCWD() { $root = dirname(phutil_get_library_root('phabricator')); return $root.'/support/empty/'; } } diff --git a/src/infrastructure/env/__tests__/PhabricatorEnvTestCase.php b/src/infrastructure/env/__tests__/PhabricatorEnvTestCase.php index d47258822b..f73299aa12 100644 --- a/src/infrastructure/env/__tests__/PhabricatorEnvTestCase.php +++ b/src/infrastructure/env/__tests__/PhabricatorEnvTestCase.php @@ -1,221 +1,256 @@ <?php final class PhabricatorEnvTestCase extends PhabricatorTestCase { public function testLocalURIForLink() { $map = array( '/' => true, '/D123' => true, '/path/to/something/' => true, "/path/to/\nHeader: x" => false, 'http://evil.com/' => false, '//evil.com/evil/' => false, 'javascript:lol' => false, '' => false, null => false, '/\\evil.com' => false, ); foreach ($map as $uri => $expect) { $this->assertEqual( $expect, PhabricatorEnv::isValidLocalURIForLink($uri), pht('Valid local resource: %s', $uri)); } } public function testRemoteURIForLink() { $map = array( 'http://example.com/' => true, 'derp://example.com/' => false, 'javascript:alert(1)' => false, 'http://127.0.0.1/' => true, 'http://169.254.169.254/latest/meta-data/hostname' => true, ); foreach ($map as $uri => $expect) { $this->assertEqual( $expect, PhabricatorEnv::isValidRemoteURIForLink($uri), pht('Valid linkable remote URI: %s', $uri)); } } public function testRemoteURIForFetch() { $map = array( 'http://example.com/' => true, // No domain or protocol. '' => false, // No domain. 'http://' => false, // No protocol. 'evil.com' => false, // No protocol. '//evil.com' => false, // Bad protocol. 'javascript://evil.com/' => false, 'file:///etc/shadow' => false, // Unresolvable hostname. 'http://u1VcxwUp368SIFzl7rkWWg23KM5JPB2kTHHngxjXCQc.zzz/' => false, // Domains explicitly in blacklisted IP space. 'http://127.0.0.1/' => false, 'http://169.254.169.254/latest/meta-data/hostname' => false, // Domain resolves into blacklisted IP space. 'http://localhost/' => false, ); $protocols = array('http', 'https'); foreach ($map as $uri => $expect) { $this->assertEqual( $expect, PhabricatorEnv::isValidRemoteURIForFetch($uri, $protocols), pht('Valid fetchable remote URI: %s', $uri)); } } public function testDictionarySource() { $source = new PhabricatorConfigDictionarySource(array('x' => 1)); $this->assertEqual( array( 'x' => 1, ), $source->getKeys(array('x', 'z'))); $source->setKeys(array('z' => 2)); $this->assertEqual( array( 'x' => 1, 'z' => 2, ), $source->getKeys(array('x', 'z'))); $source->setKeys(array('x' => 3)); $this->assertEqual( array( 'x' => 3, 'z' => 2, ), $source->getKeys(array('x', 'z'))); $source->deleteKeys(array('x')); $this->assertEqual( array( 'z' => 2, ), $source->getKeys(array('x', 'z'))); } public function testStackSource() { $s1 = new PhabricatorConfigDictionarySource(array('x' => 1)); $s2 = new PhabricatorConfigDictionarySource(array('x' => 2)); $stack = new PhabricatorConfigStackSource(); $this->assertEqual(array(), $stack->getKeys(array('x'))); $stack->pushSource($s1); $this->assertEqual(array('x' => 1), $stack->getKeys(array('x'))); $stack->pushSource($s2); $this->assertEqual(array('x' => 2), $stack->getKeys(array('x'))); $stack->setKeys(array('x' => 3)); $this->assertEqual(array('x' => 3), $stack->getKeys(array('x'))); $stack->popSource(); $this->assertEqual(array('x' => 1), $stack->getKeys(array('x'))); $stack->popSource(); $this->assertEqual(array(), $stack->getKeys(array('x'))); $caught = null; try { $stack->popSource(); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($caught instanceof Exception); } public function testOverrides() { $outer = PhabricatorEnv::beginScopedEnv(); $outer->overrideEnvConfig('test.value', 1); $this->assertEqual(1, PhabricatorEnv::getEnvConfig('test.value')); $inner = PhabricatorEnv::beginScopedEnv(); $inner->overrideEnvConfig('test.value', 2); $this->assertEqual(2, PhabricatorEnv::getEnvConfig('test.value')); if (phutil_is_hiphop_runtime()) { $inner->__destruct(); } unset($inner); $this->assertEqual(1, PhabricatorEnv::getEnvConfig('test.value')); if (phutil_is_hiphop_runtime()) { $outer->__destruct(); } unset($outer); } public function testOverrideOrder() { $outer = PhabricatorEnv::beginScopedEnv(); $inner = PhabricatorEnv::beginScopedEnv(); $caught = null; try { $outer->__destruct(); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue( $caught instanceof Exception, pht( 'Destroying a scoped environment which is not on the top of the '. 'stack should throw.')); if (phutil_is_hiphop_runtime()) { $inner->__destruct(); } unset($inner); if (phutil_is_hiphop_runtime()) { $outer->__destruct(); } unset($outer); } public function testGetEnvExceptions() { $caught = null; try { PhabricatorEnv::getEnvConfig('not.a.real.config.option'); } catch (Exception $ex) { $caught = $ex; } $this->assertTrue($caught instanceof Exception); $caught = null; try { PhabricatorEnv::getEnvConfig('test.value'); } catch (Exception $ex) { $caught = $ex; } $this->assertFalse($caught instanceof Exception); } + public function testSelfURI() { + $base_uri = 'https://allowed.example.com/'; + + $allowed_uris = array( + 'https://old.example.com/', + ); + + $env = PhabricatorEnv::beginScopedEnv(); + $env->overrideEnvConfig('phabricator.base-uri', $base_uri); + $env->overrideEnvConfig('phabricator.allowed-uris', $allowed_uris); + + $map = array( + 'https://allowed.example.com/' => true, + 'https://allowed.example.com' => true, + 'https://allowed.EXAMPLE.com' => true, + 'http://allowed.example.com/' => true, + 'https://allowed.example.com/path/to/resource.png' => true, + + 'https://old.example.com/' => true, + 'https://old.example.com' => true, + 'https://old.EXAMPLE.com' => true, + 'http://old.example.com/' => true, + 'https://old.example.com/path/to/resource.png' => true, + + 'https://other.example.com/' => false, + ); + + foreach ($map as $input => $expect) { + $this->assertEqual( + $expect, + PhabricatorEnv::isSelfURI($input), + pht('Is self URI? %s', $input)); + } + } + }