4 * @task data Accessing Request Data
5 * @task cookie Managing Cookies
6 * @task cluster Working With a Phabricator Cluster
8 final class AphrontRequest
extends Phobject
{
10 // NOTE: These magic request-type parameters are automatically included in
11 // certain requests (e.g., by phabricator_form(), JX.Request,
12 // JX.Workflow, and ConduitClient) and help us figure out what sort of
13 // response the client expects.
15 const TYPE_AJAX
= '__ajax__';
16 const TYPE_FORM
= '__form__';
17 const TYPE_CONDUIT
= '__conduit__';
18 const TYPE_WORKFLOW
= '__wflow__';
19 const TYPE_CONTINUE
= '__continue__';
20 const TYPE_PREVIEW
= '__preview__';
21 const TYPE_HISEC
= '__hisec__';
22 const TYPE_QUICKSAND
= '__quicksand__';
28 private $applicationConfiguration;
31 private $uriData = array();
32 private $cookiePrefix;
35 public function __construct($host, $path) {
40 public function setURIMap(array $uri_data) {
41 $this->uriData
= $uri_data;
45 public function getURIMap() {
46 return $this->uriData
;
49 public function getURIData($key, $default = null) {
50 return idx($this->uriData
, $key, $default);
54 * Read line range parameter data from the request.
56 * Applications like Paste, Diffusion, and Harbormaster use "$12-14" in the
57 * URI to allow users to link to particular lines.
59 * @param string URI data key to pull line range information from.
60 * @param int|null Maximum length of the range.
61 * @return null|pair<int, int> Null, or beginning and end of the range.
63 public function getURILineRange($key, $limit) {
64 $range = $this->getURIData($key);
65 return self
::parseURILineRange($range, $limit);
68 public static function parseURILineRange($range, $limit) {
69 if (!strlen($range)) {
73 $range = explode('-', $range, 2);
75 foreach ($range as $key => $value) {
78 // If either value is "0", discard the range.
81 $range[$key] = $value;
84 // If the range is like "$10", treat it like "$10-10".
85 if (count($range) == 1) {
86 $range[] = head($range);
89 // If the range is "$7-5", treat it like "$5-7".
90 if ($range[1] < $range[0]) {
91 $range = array_reverse($range);
94 // If the user specified something like "$1-999999999" and we have a limit,
95 // clamp it to a more reasonable range.
96 if ($limit !== null) {
97 if ($range[1] - $range[0] > $limit) {
98 $range[1] = $range[0] +
$limit;
105 public function setApplicationConfiguration(
106 $application_configuration) {
107 $this->applicationConfiguration
= $application_configuration;
111 public function getApplicationConfiguration() {
112 return $this->applicationConfiguration
;
115 public function setPath($path) {
120 public function getPath() {
124 public function getHost() {
125 // The "Host" header may include a port number, or may be a malicious
126 // header in the form "realdomain.com:ignored@evil.com". Invoke the full
127 // parser to extract the real domain correctly. See here for coverage of
128 // a similar issue in Django:
130 // https://www.djangoproject.com/weblog/2012/oct/17/security/
131 $uri = new PhutilURI('http://'.$this->host
);
132 return $uri->getDomain();
135 public function setSite(AphrontSite
$site) {
140 public function getSite() {
144 public function setController(AphrontController
$controller) {
145 $this->controller
= $controller;
149 public function getController() {
150 return $this->controller
;
154 /* -( Accessing Request Data )--------------------------------------------- */
160 public function setRequestData(array $request_data) {
161 $this->requestData
= $request_data;
169 public function getRequestData() {
170 return $this->requestData
;
177 public function getInt($name, $default = null) {
178 if (isset($this->requestData
[$name])) {
179 // Converting from array to int is "undefined". Don't rely on whatever
180 // PHP decides to do.
181 if (is_array($this->requestData
[$name])) {
184 return (int)$this->requestData
[$name];
194 public function getBool($name, $default = null) {
195 if (isset($this->requestData
[$name])) {
196 if ($this->requestData
[$name] === 'true') {
198 } else if ($this->requestData
[$name] === 'false') {
201 return (bool)$this->requestData
[$name];
212 public function getStr($name, $default = null) {
213 if (isset($this->requestData
[$name])) {
214 $str = (string)$this->requestData
[$name];
215 // Normalize newline craziness.
230 public function getJSONMap($name, $default = array()) {
231 if (!isset($this->requestData
[$name])) {
235 $raw_data = phutil_string_cast($this->requestData
[$name]);
236 $raw_data = trim($raw_data);
237 if (!strlen($raw_data)) {
241 if ($raw_data[0] !== '{') {
244 'Request parameter "%s" is not formatted properly. Expected a '.
245 'JSON object, but value does not start with "{".',
250 $json_object = phutil_json_decode($raw_data);
251 } catch (PhutilJSONParserException
$ex) {
254 'Request parameter "%s" is not formatted properly. Expected a '.
255 'JSON object, but encountered a syntax error: %s.',
267 public function getArr($name, $default = array()) {
268 if (isset($this->requestData
[$name]) &&
269 is_array($this->requestData
[$name])) {
270 return $this->requestData
[$name];
280 public function getStrList($name, $default = array()) {
281 if (!isset($this->requestData
[$name])) {
284 $list = $this->getStr($name);
285 $list = preg_split('/[\s,]+/', $list, $limit = -1, PREG_SPLIT_NO_EMPTY
);
293 public function getExists($name) {
294 return array_key_exists($name, $this->requestData
);
297 public function getFileExists($name) {
298 return isset($_FILES[$name]) &&
299 (idx($_FILES[$name], 'error') !== UPLOAD_ERR_NO_FILE
);
302 public function isHTTPGet() {
303 return ($_SERVER['REQUEST_METHOD'] == 'GET');
306 public function isHTTPPost() {
307 return ($_SERVER['REQUEST_METHOD'] == 'POST');
310 public function isAjax() {
311 return $this->getExists(self
::TYPE_AJAX
) && !$this->isQuicksand();
314 public function isWorkflow() {
315 return $this->getExists(self
::TYPE_WORKFLOW
) && !$this->isQuicksand();
318 public function isQuicksand() {
319 return $this->getExists(self
::TYPE_QUICKSAND
);
322 public function isConduit() {
323 return $this->getExists(self
::TYPE_CONDUIT
);
326 public static function getCSRFTokenName() {
330 public static function getCSRFHeaderName() {
331 return 'X-Phabricator-Csrf';
334 public static function getViaHeaderName() {
335 return 'X-Phabricator-Via';
338 public function validateCSRF() {
339 $token_name = self
::getCSRFTokenName();
340 $token = $this->getStr($token_name);
342 // No token in the request, check the HTTP header which is added for Ajax
345 $token = self
::getHTTPHeader(self
::getCSRFHeaderName());
348 $valid = $this->getUser()->validateCSRFToken($token);
351 // Add some diagnostic details so we can figure out if some CSRF issues
352 // are JS problems or people accessing Ajax URIs directly with their
357 'You are trying to save some data to permanent storage, but the '.
358 'request your browser made included an incorrect token. Reload the '.
359 'page and try again. You may need to clear your cookies.');
361 if ($this->isAjax()) {
362 $info[] = pht('This was an Ajax request.');
364 $info[] = pht('This was a Web request.');
368 $info[] = pht('This request had an invalid CSRF token.');
370 $info[] = pht('This request had no CSRF token.');
373 // Give a more detailed explanation of how to avoid the exception
374 // in developer mode.
375 if (PhabricatorEnv
::getEnvConfig('phabricator.developer-mode')) {
376 // TODO: Clean this up, see T1921.
378 "To avoid this error, use %s to construct forms. If you are already ".
379 "using %s, make sure the form 'action' uses a relative URI (i.e., ".
380 "begins with a '%s'). Forms using absolute URIs do not include CSRF ".
381 "tokens, to prevent leaking tokens to external sites.\n\n".
382 "If this page performs writes which do not require CSRF protection ".
383 "(usually, filling caches or logging), you can use %s to ".
384 "temporarily bypass CSRF protection while writing. You should use ".
385 "this only for writes which can not be protected with normal CSRF ".
387 "Some UI elements (like %s) also have methods which will allow you ".
388 "to render links as forms (like %s).",
389 'phabricator_form()',
390 'phabricator_form()',
392 'AphrontWriteGuard::beginScopedUnguardedWrites()',
393 'PhabricatorActionListView',
394 'setRenderAsForm(true)');
397 $message = implode("\n", $info);
399 // This should only be able to happen if you load a form, pull your
400 // internet for 6 hours, and then reconnect and immediately submit,
401 // but give the user some indication of what happened since the workflow
402 // is incredibly confusing otherwise.
403 throw new AphrontMalformedRequestException(
404 pht('Invalid Request (CSRF)'),
412 public function isFormPost() {
413 $post = $this->getExists(self
::TYPE_FORM
) &&
414 !$this->getExists(self
::TYPE_HISEC
) &&
421 return $this->validateCSRF();
424 public function hasCSRF() {
426 $this->validateCSRF();
428 } catch (AphrontMalformedRequestException
$ex) {
433 public function isFormOrHisecPost() {
434 $post = $this->getExists(self
::TYPE_FORM
) &&
441 return $this->validateCSRF();
445 public function setCookiePrefix($prefix) {
446 $this->cookiePrefix
= $prefix;
450 private function getPrefixedCookieName($name) {
451 if (strlen($this->cookiePrefix
)) {
452 return $this->cookiePrefix
.'_'.$name;
458 public function getCookie($name, $default = null) {
459 $name = $this->getPrefixedCookieName($name);
460 $value = idx($_COOKIE, $name, $default);
462 // Internally, PHP deletes cookies by setting them to the value 'deleted'
463 // with an expiration date in the past.
465 // At least in Safari, the browser may send this cookie anyway in some
466 // circumstances. After logging out, the 302'd GET to /login/ consistently
467 // includes deleted cookies on my local install. If a cookie value is
468 // literally 'deleted', pretend it does not exist.
470 if ($value === 'deleted') {
477 public function clearCookie($name) {
478 $this->setCookieWithExpiration($name, '', time() - (60 * 60 * 24 * 30));
479 unset($_COOKIE[$name]);
483 * Get the domain which cookies should be set on for this request, or null
484 * if the request does not correspond to a valid cookie domain.
486 * @return PhutilURI|null Domain URI, or null if no valid domain exists.
490 private function getCookieDomainURI() {
491 if (PhabricatorEnv
::getEnvConfig('security.require-https') &&
496 $host = $this->getHost();
498 // If there's no base domain configured, just use whatever the request
499 // domain is. This makes setup easier, and we'll tell administrators to
500 // configure a base domain during the setup process.
501 $base_uri = PhabricatorEnv
::getEnvConfig('phabricator.base-uri');
502 if (!strlen($base_uri)) {
503 return new PhutilURI('http://'.$host.'/');
506 $alternates = PhabricatorEnv
::getEnvConfig('phabricator.allowed-uris');
507 $allowed_uris = array_merge(
511 foreach ($allowed_uris as $allowed_uri) {
512 $uri = new PhutilURI($allowed_uri);
513 if ($uri->getDomain() == $host) {
522 * Determine if security policy rules will allow cookies to be set when
523 * responding to the request.
525 * @return bool True if setCookie() will succeed. If this method returns
526 * false, setCookie() will throw.
530 public function canSetCookies() {
531 return (bool)$this->getCookieDomainURI();
536 * Set a cookie which does not expire for a long time.
538 * To set a temporary cookie, see @{method:setTemporaryCookie}.
540 * @param string Cookie name.
541 * @param string Cookie value.
545 public function setCookie($name, $value) {
546 $far_future = time() +
(60 * 60 * 24 * 365 * 5);
547 return $this->setCookieWithExpiration($name, $value, $far_future);
552 * Set a cookie which expires soon.
554 * To set a durable cookie, see @{method:setCookie}.
556 * @param string Cookie name.
557 * @param string Cookie value.
561 public function setTemporaryCookie($name, $value) {
562 return $this->setCookieWithExpiration($name, $value, 0);
567 * Set a cookie with a given expiration policy.
569 * @param string Cookie name.
570 * @param string Cookie value.
571 * @param int Epoch timestamp for cookie expiration.
575 private function setCookieWithExpiration(
582 $base_domain_uri = $this->getCookieDomainURI();
583 if (!$base_domain_uri) {
584 $configured_as = PhabricatorEnv
::getEnvConfig('phabricator.base-uri');
585 $accessed_as = $this->getHost();
587 throw new AphrontMalformedRequestException(
588 pht('Bad Host Header'),
590 'This server is configured as "%s", but you are using the domain '.
591 'name "%s" to access a page which is trying to set a cookie. '.
592 'Access this service on the configured primary domain or a '.
593 'configured alternate domain. Cookies will not be set on other '.
594 'domains for security reasons.',
600 $base_domain = $base_domain_uri->getDomain();
601 $is_secure = ($base_domain_uri->getProtocol() == 'https');
603 $name = $this->getPrefixedCookieName($name);
605 if (php_sapi_name() == 'cli') {
606 // Do nothing, to avoid triggering "Cannot modify header information"
609 // TODO: This is effectively a test for whether we're running in a unit
610 // test or not. Move this actual call to HTTPSink?
622 $_COOKIE[$name] = $value;
627 public function setUser($user) {
632 public function getUser() {
636 public function getViewer() {
640 public function getRequestURI() {
641 $uri_path = phutil_escape_uri($this->getPath());
642 $uri_query = idx($_SERVER, 'QUERY_STRING', '');
644 return id(new PhutilURI($uri_path.'?'.$uri_query))
645 ->removeQueryParam('__path__');
648 public function getAbsoluteRequestURI() {
649 $uri = $this->getRequestURI();
650 $uri->setDomain($this->getHost());
652 if ($this->isHTTPS()) {
658 $uri->setProtocol($protocol);
660 // If the request used a nonstandard port, preserve it while building the
663 // First, get the default port for the request protocol.
664 $default_port = id(new PhutilURI($protocol.'://example.com/'))
665 ->getPortWithProtocolDefault();
667 // NOTE: See note in getHost() about malicious "Host" headers. This
668 // construction defuses some obscure potential attacks.
669 $port = id(new PhutilURI($protocol.'://'.$this->host
))
672 if (($port !== null) && ($port !== $default_port)) {
673 $uri->setPort($port);
679 public function isDialogFormPost() {
680 return $this->isFormPost() && $this->getStr('__dialog__');
683 public function getRemoteAddress() {
684 $address = PhabricatorEnv
::getRemoteAddress();
690 return $address->getAddress();
693 public function isHTTPS() {
694 if (empty($_SERVER['HTTPS'])) {
697 if (!strcasecmp($_SERVER['HTTPS'], 'off')) {
703 public function isContinueRequest() {
704 return $this->isFormOrHisecPost() && $this->getStr('__continue__');
707 public function isPreviewRequest() {
708 return $this->isFormPost() && $this->getStr('__preview__');
712 * Get application request parameters in a flattened form suitable for
713 * inclusion in an HTTP request, excluding parameters with special meanings.
714 * This is primarily useful if you want to ask the user for more input and
715 * then resubmit their request.
717 * @return dict<string, string> Original request parameters.
719 public function getPassthroughRequestParameters($include_quicksand = false) {
720 return self
::flattenData(
721 $this->getPassthroughRequestData($include_quicksand));
725 * Get request data other than "magic" parameters.
727 * @return dict<string, wild> Request data, with magic filtered out.
729 public function getPassthroughRequestData($include_quicksand = false) {
730 $data = $this->getRequestData();
732 // Remove magic parameters like __dialog__ and __ajax__.
733 foreach ($data as $key => $value) {
734 if ($include_quicksand && $key == self
::TYPE_QUICKSAND
) {
737 if (!strncmp($key, '__', 2)) {
747 * Flatten an array of key-value pairs (possibly including arrays as values)
748 * into a list of key-value pairs suitable for submitting via HTTP request
749 * (with arrays flattened).
751 * @param dict<string, wild> Data to flatten.
752 * @return dict<string, string> Flat data suitable for inclusion in an HTTP
755 public static function flattenData(array $data) {
757 foreach ($data as $key => $value) {
758 if (is_array($value)) {
759 foreach (self
::flattenData($value) as $fkey => $fvalue) {
760 $fkey = '['.preg_replace('/(?=\[)|$/', ']', $fkey, $limit = 1);
761 $result[$key.$fkey] = $fvalue;
764 $result[$key] = (string)$value;
775 * Read the value of an HTTP header from `$_SERVER`, or a similar datasource.
777 * This function accepts a canonical header name, like `"Accept-Encoding"`,
778 * and looks up the appropriate value in `$_SERVER` (in this case,
779 * `"HTTP_ACCEPT_ENCODING"`).
781 * @param string Canonical header name, like `"Accept-Encoding"`.
782 * @param wild Default value to return if header is not present.
783 * @param array? Read this instead of `$_SERVER`.
784 * @return string|wild Header value if present, or `$default` if not.
786 public static function getHTTPHeader($name, $default = null, $data = null) {
787 // PHP mangles HTTP headers by uppercasing them and replacing hyphens with
788 // underscores, then prepending 'HTTP_'.
789 $php_index = strtoupper($name);
790 $php_index = str_replace('-', '_', $php_index);
792 $try_names = array();
794 $try_names[] = 'HTTP_'.$php_index;
795 if ($php_index == 'CONTENT_TYPE' ||
$php_index == 'CONTENT_LENGTH') {
796 // These headers may be available under alternate names. See
797 // http://www.php.net/manual/en/reserved.variables.server.php#110763
798 $try_names[] = $php_index;
801 if ($data === null) {
805 foreach ($try_names as $try_name) {
806 if (array_key_exists($try_name, $data)) {
807 return $data[$try_name];
815 /* -( Working With a Phabricator Cluster )--------------------------------- */
819 * Is this a proxied request originating from within the Phabricator cluster?
821 * IMPORTANT: This means the request is dangerous!
823 * These requests are **more dangerous** than normal requests (they can not
824 * be safely proxied, because proxying them may cause a loop). Cluster
825 * requests are not guaranteed to come from a trusted source, and should
826 * never be treated as safer than normal requests. They are strictly less
829 public function isProxiedClusterRequest() {
830 return (bool)self
::getHTTPHeader('X-Phabricator-Cluster');
835 * Build a new @{class:HTTPSFuture} which proxies this request to another
836 * node in the cluster.
838 * IMPORTANT: This is very dangerous!
840 * The future forwards authentication information present in the request.
841 * Proxied requests must only be sent to trusted hosts. (We attempt to
844 * This is not a general-purpose proxying method; it is a specialized
845 * method with niche applications and severe security implications.
847 * @param string URI identifying the host we are proxying the request to.
848 * @return HTTPSFuture New proxy future.
850 * @phutil-external-symbol class PhabricatorStartup
852 public function newClusterProxyFuture($uri) {
853 $uri = new PhutilURI($uri);
855 $domain = $uri->getDomain();
856 $ip = gethostbyname($domain);
860 'Unable to resolve domain "%s"!',
864 if (!PhabricatorEnv
::isClusterAddress($ip)) {
867 'Refusing to proxy a request to IP address ("%s") which is not '.
868 'in the cluster address block (this address was derived by '.
869 'resolving the domain "%s").',
874 $uri->setPath($this->getPath());
875 $uri->removeAllQueryParams();
876 foreach (self
::flattenData($_GET) as $query_key => $query_value) {
877 $uri->appendQueryParam($query_key, $query_value);
880 $input = PhabricatorStartup
::getRawInput();
882 $future = id(new HTTPSFuture($uri))
883 ->addHeader('Host', self
::getHost())
884 ->addHeader('X-Phabricator-Cluster', true)
885 ->setMethod($_SERVER['REQUEST_METHOD'])
888 if (isset($_SERVER['PHP_AUTH_USER'])) {
889 $future->setHTTPBasicAuthCredentials(
890 $_SERVER['PHP_AUTH_USER'],
891 new PhutilOpaqueEnvelope(idx($_SERVER, 'PHP_AUTH_PW', '')));
897 // NOTE: apache_request_headers() might provide a nicer way to do this,
898 // but isn't available under FCGI until PHP 5.4.0.
899 foreach ($_SERVER as $key => $value) {
900 if (!preg_match('/^HTTP_/', $key)) {
904 // Unmangle the header as best we can.
905 $key = substr($key, strlen('HTTP_'));
906 $key = str_replace('_', ' ', $key);
907 $key = strtolower($key);
908 $key = ucwords($key);
909 $key = str_replace(' ', '-', $key);
911 // By default, do not forward headers.
912 $should_forward = false;
914 // Forward "X-Hgarg-..." headers.
915 if (preg_match('/^X-Hgarg-/', $key)) {
916 $should_forward = true;
919 if ($should_forward) {
920 $headers[] = array($key, $value);
925 // In some situations, this may not be mapped into the HTTP_X constants.
926 // CONTENT_LENGTH is similarly affected, but we trust cURL to take care
927 // of that if it matters, since we're handing off a request body.
928 if (empty($seen['Content-Type'])) {
929 if (isset($_SERVER['CONTENT_TYPE'])) {
930 $headers[] = array('Content-Type', $_SERVER['CONTENT_TYPE']);
934 foreach ($headers as $header) {
935 list($key, $value) = $header;
938 case 'Authorization':
939 // Don't forward these headers, we've already handled them elsewhere.
940 unset($headers[$key]);
947 foreach ($headers as $header) {
948 list($key, $value) = $header;
949 $future->addHeader($key, $value);
955 public function updateEphemeralCookies() {
956 $submit_cookie = PhabricatorCookies
::COOKIE_SUBMIT
;
958 $submit_key = $this->getCookie($submit_cookie);
959 if (strlen($submit_key)) {
960 $this->clearCookie($submit_cookie);
961 $this->submitKey
= $submit_key;
966 public function getSubmitKey() {
967 return $this->submitKey
;