[ Index ] |
PHP Cross Reference of Phabricator |
[Summary view] [Print] [Text view]
1 <?php 2 3 /** 4 * @task data Accessing Request Data 5 * @task cookie Managing Cookies 6 * 7 */ 8 final class AphrontRequest { 9 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. 14 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 23 private $host; 24 private $path; 25 private $requestData; 26 private $user; 27 private $applicationConfiguration; 28 private $uriData; 29 30 final public function __construct($host, $path) { 31 $this->host = $host; 32 $this->path = $path; 33 } 34 35 final public function setURIMap(array $uri_data) { 36 $this->uriData = $uri_data; 37 return $this; 38 } 39 40 final public function getURIMap() { 41 return $this->uriData; 42 } 43 44 final public function getURIData($key, $default = null) { 45 return idx($this->uriData, $key, $default); 46 } 47 48 final public function setApplicationConfiguration( 49 $application_configuration) { 50 $this->applicationConfiguration = $application_configuration; 51 return $this; 52 } 53 54 final public function getApplicationConfiguration() { 55 return $this->applicationConfiguration; 56 } 57 58 final public function setPath($path) { 59 $this->path = $path; 60 return $this; 61 } 62 63 final public function getPath() { 64 return $this->path; 65 } 66 67 final public function getHost() { 68 // The "Host" header may include a port number, or may be a malicious 69 // header in the form "realdomain.com:[email protected]". Invoke the full 70 // parser to extract the real domain correctly. See here for coverage of 71 // a similar issue in Django: 72 // 73 // https://www.djangoproject.com/weblog/2012/oct/17/security/ 74 $uri = new PhutilURI('http://'.$this->host); 75 return $uri->getDomain(); 76 } 77 78 79 /* -( Accessing Request Data )--------------------------------------------- */ 80 81 82 /** 83 * @task data 84 */ 85 final public function setRequestData(array $request_data) { 86 $this->requestData = $request_data; 87 return $this; 88 } 89 90 91 /** 92 * @task data 93 */ 94 final public function getRequestData() { 95 return $this->requestData; 96 } 97 98 99 /** 100 * @task data 101 */ 102 final public function getInt($name, $default = null) { 103 if (isset($this->requestData[$name])) { 104 return (int)$this->requestData[$name]; 105 } else { 106 return $default; 107 } 108 } 109 110 111 /** 112 * @task data 113 */ 114 final public function getBool($name, $default = null) { 115 if (isset($this->requestData[$name])) { 116 if ($this->requestData[$name] === 'true') { 117 return true; 118 } else if ($this->requestData[$name] === 'false') { 119 return false; 120 } else { 121 return (bool)$this->requestData[$name]; 122 } 123 } else { 124 return $default; 125 } 126 } 127 128 129 /** 130 * @task data 131 */ 132 final public function getStr($name, $default = null) { 133 if (isset($this->requestData[$name])) { 134 $str = (string)$this->requestData[$name]; 135 // Normalize newline craziness. 136 $str = str_replace( 137 array("\r\n", "\r"), 138 array("\n", "\n"), 139 $str); 140 return $str; 141 } else { 142 return $default; 143 } 144 } 145 146 147 /** 148 * @task data 149 */ 150 final public function getArr($name, $default = array()) { 151 if (isset($this->requestData[$name]) && 152 is_array($this->requestData[$name])) { 153 return $this->requestData[$name]; 154 } else { 155 return $default; 156 } 157 } 158 159 160 /** 161 * @task data 162 */ 163 final public function getStrList($name, $default = array()) { 164 if (!isset($this->requestData[$name])) { 165 return $default; 166 } 167 $list = $this->getStr($name); 168 $list = preg_split('/[\s,]+/', $list, $limit = -1, PREG_SPLIT_NO_EMPTY); 169 return $list; 170 } 171 172 173 /** 174 * @task data 175 */ 176 final public function getExists($name) { 177 return array_key_exists($name, $this->requestData); 178 } 179 180 final public function getFileExists($name) { 181 return isset($_FILES[$name]) && 182 (idx($_FILES[$name], 'error') !== UPLOAD_ERR_NO_FILE); 183 } 184 185 final public function isHTTPGet() { 186 return ($_SERVER['REQUEST_METHOD'] == 'GET'); 187 } 188 189 final public function isHTTPPost() { 190 return ($_SERVER['REQUEST_METHOD'] == 'POST'); 191 } 192 193 final public function isAjax() { 194 return $this->getExists(self::TYPE_AJAX); 195 } 196 197 final public function isJavelinWorkflow() { 198 return $this->getExists(self::TYPE_WORKFLOW); 199 } 200 201 final public function isConduit() { 202 return $this->getExists(self::TYPE_CONDUIT); 203 } 204 205 public static function getCSRFTokenName() { 206 return '__csrf__'; 207 } 208 209 public static function getCSRFHeaderName() { 210 return 'X-Phabricator-Csrf'; 211 } 212 213 final public function validateCSRF() { 214 $token_name = self::getCSRFTokenName(); 215 $token = $this->getStr($token_name); 216 217 // No token in the request, check the HTTP header which is added for Ajax 218 // requests. 219 if (empty($token)) { 220 $token = self::getHTTPHeader(self::getCSRFHeaderName()); 221 } 222 223 $valid = $this->getUser()->validateCSRFToken($token); 224 if (!$valid) { 225 226 // Add some diagnostic details so we can figure out if some CSRF issues 227 // are JS problems or people accessing Ajax URIs directly with their 228 // browsers. 229 $more_info = array(); 230 231 if ($this->isAjax()) { 232 $more_info[] = pht('This was an Ajax request.'); 233 } else { 234 $more_info[] = pht('This was a Web request.'); 235 } 236 237 if ($token) { 238 $more_info[] = pht('This request had an invalid CSRF token.'); 239 } else { 240 $more_info[] = pht('This request had no CSRF token.'); 241 } 242 243 // Give a more detailed explanation of how to avoid the exception 244 // in developer mode. 245 if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) { 246 // TODO: Clean this up, see T1921. 247 $more_info[] = 248 "To avoid this error, use phabricator_form() to construct forms. ". 249 "If you are already using phabricator_form(), make sure the form ". 250 "'action' uses a relative URI (i.e., begins with a '/'). Forms ". 251 "using absolute URIs do not include CSRF tokens, to prevent ". 252 "leaking tokens to external sites.\n\n". 253 "If this page performs writes which do not require CSRF ". 254 "protection (usually, filling caches or logging), you can use ". 255 "AphrontWriteGuard::beginScopedUnguardedWrites() to temporarily ". 256 "bypass CSRF protection while writing. You should use this only ". 257 "for writes which can not be protected with normal CSRF ". 258 "mechanisms.\n\n". 259 "Some UI elements (like PhabricatorActionListView) also have ". 260 "methods which will allow you to render links as forms (like ". 261 "setRenderAsForm(true))."; 262 } 263 264 // This should only be able to happen if you load a form, pull your 265 // internet for 6 hours, and then reconnect and immediately submit, 266 // but give the user some indication of what happened since the workflow 267 // is incredibly confusing otherwise. 268 throw new AphrontCSRFException( 269 pht( 270 "You are trying to save some data to Phabricator, but the request ". 271 "your browser made included an incorrect token. Reload the page ". 272 "and try again. You may need to clear your cookies.\n\n%s", 273 implode("\n", $more_info))); 274 } 275 276 return true; 277 } 278 279 final public function isFormPost() { 280 $post = $this->getExists(self::TYPE_FORM) && 281 !$this->getExists(self::TYPE_HISEC) && 282 $this->isHTTPPost(); 283 284 if (!$post) { 285 return false; 286 } 287 288 return $this->validateCSRF(); 289 } 290 291 final public function isFormOrHisecPost() { 292 $post = $this->getExists(self::TYPE_FORM) && 293 $this->isHTTPPost(); 294 295 if (!$post) { 296 return false; 297 } 298 299 return $this->validateCSRF(); 300 } 301 302 303 final public function setCookiePrefix($prefix) { 304 $this->cookiePrefix = $prefix; 305 return $this; 306 } 307 308 final private function getPrefixedCookieName($name) { 309 if (strlen($this->cookiePrefix)) { 310 return $this->cookiePrefix.'_'.$name; 311 } else { 312 return $name; 313 } 314 } 315 316 final public function getCookie($name, $default = null) { 317 $name = $this->getPrefixedCookieName($name); 318 $value = idx($_COOKIE, $name, $default); 319 320 // Internally, PHP deletes cookies by setting them to the value 'deleted' 321 // with an expiration date in the past. 322 323 // At least in Safari, the browser may send this cookie anyway in some 324 // circumstances. After logging out, the 302'd GET to /login/ consistently 325 // includes deleted cookies on my local install. If a cookie value is 326 // literally 'deleted', pretend it does not exist. 327 328 if ($value === 'deleted') { 329 return null; 330 } 331 332 return $value; 333 } 334 335 final public function clearCookie($name) { 336 $name = $this->getPrefixedCookieName($name); 337 $this->setCookieWithExpiration($name, '', time() - (60 * 60 * 24 * 30)); 338 unset($_COOKIE[$name]); 339 } 340 341 /** 342 * Get the domain which cookies should be set on for this request, or null 343 * if the request does not correspond to a valid cookie domain. 344 * 345 * @return PhutilURI|null Domain URI, or null if no valid domain exists. 346 * 347 * @task cookie 348 */ 349 private function getCookieDomainURI() { 350 if (PhabricatorEnv::getEnvConfig('security.require-https') && 351 !$this->isHTTPS()) { 352 return null; 353 } 354 355 $host = $this->getHost(); 356 357 // If there's no base domain configured, just use whatever the request 358 // domain is. This makes setup easier, and we'll tell administrators to 359 // configure a base domain during the setup process. 360 $base_uri = PhabricatorEnv::getEnvConfig('phabricator.base-uri'); 361 if (!strlen($base_uri)) { 362 return new PhutilURI('http://'.$host.'/'); 363 } 364 365 $alternates = PhabricatorEnv::getEnvConfig('phabricator.allowed-uris'); 366 $allowed_uris = array_merge( 367 array($base_uri), 368 $alternates); 369 370 foreach ($allowed_uris as $allowed_uri) { 371 $uri = new PhutilURI($allowed_uri); 372 if ($uri->getDomain() == $host) { 373 return $uri; 374 } 375 } 376 377 return null; 378 } 379 380 /** 381 * Determine if security policy rules will allow cookies to be set when 382 * responding to the request. 383 * 384 * @return bool True if setCookie() will succeed. If this method returns 385 * false, setCookie() will throw. 386 * 387 * @task cookie 388 */ 389 final public function canSetCookies() { 390 return (bool)$this->getCookieDomainURI(); 391 } 392 393 394 /** 395 * Set a cookie which does not expire for a long time. 396 * 397 * To set a temporary cookie, see @{method:setTemporaryCookie}. 398 * 399 * @param string Cookie name. 400 * @param string Cookie value. 401 * @return this 402 * @task cookie 403 */ 404 final public function setCookie($name, $value) { 405 $far_future = time() + (60 * 60 * 24 * 365 * 5); 406 return $this->setCookieWithExpiration($name, $value, $far_future); 407 } 408 409 410 /** 411 * Set a cookie which expires soon. 412 * 413 * To set a durable cookie, see @{method:setCookie}. 414 * 415 * @param string Cookie name. 416 * @param string Cookie value. 417 * @return this 418 * @task cookie 419 */ 420 final public function setTemporaryCookie($name, $value) { 421 return $this->setCookieWithExpiration($name, $value, 0); 422 } 423 424 425 /** 426 * Set a cookie with a given expiration policy. 427 * 428 * @param string Cookie name. 429 * @param string Cookie value. 430 * @param int Epoch timestamp for cookie expiration. 431 * @return this 432 * @task cookie 433 */ 434 final private function setCookieWithExpiration( 435 $name, 436 $value, 437 $expire) { 438 439 $is_secure = false; 440 441 $base_domain_uri = $this->getCookieDomainURI(); 442 if (!$base_domain_uri) { 443 $configured_as = PhabricatorEnv::getEnvConfig('phabricator.base-uri'); 444 $accessed_as = $this->getHost(); 445 446 throw new Exception( 447 pht( 448 'This Phabricator install is configured as "%s", but you are '. 449 'using the domain name "%s" to access a page which is trying to '. 450 'set a cookie. Acccess Phabricator on the configured primary '. 451 'domain or a configured alternate domain. Phabricator will not '. 452 'set cookies on other domains for security reasons.', 453 $configured_as, 454 $accessed_as)); 455 } 456 457 $base_domain = $base_domain_uri->getDomain(); 458 $is_secure = ($base_domain_uri->getProtocol() == 'https'); 459 460 $name = $this->getPrefixedCookieName($name); 461 462 if (php_sapi_name() == 'cli') { 463 // Do nothing, to avoid triggering "Cannot modify header information" 464 // warnings. 465 466 // TODO: This is effectively a test for whether we're running in a unit 467 // test or not. Move this actual call to HTTPSink? 468 } else { 469 setcookie( 470 $name, 471 $value, 472 $expire, 473 $path = '/', 474 $base_domain, 475 $is_secure, 476 $http_only = true); 477 } 478 479 $_COOKIE[$name] = $value; 480 481 return $this; 482 } 483 484 final public function setUser($user) { 485 $this->user = $user; 486 return $this; 487 } 488 489 final public function getUser() { 490 return $this->user; 491 } 492 493 final public function getViewer() { 494 return $this->user; 495 } 496 497 final public function getRequestURI() { 498 $get = $_GET; 499 unset($get['__path__']); 500 $path = phutil_escape_uri($this->getPath()); 501 return id(new PhutilURI($path))->setQueryParams($get); 502 } 503 504 final public function isDialogFormPost() { 505 return $this->isFormPost() && $this->getStr('__dialog__'); 506 } 507 508 final public function getRemoteAddr() { 509 return $_SERVER['REMOTE_ADDR']; 510 } 511 512 public function isHTTPS() { 513 if (empty($_SERVER['HTTPS'])) { 514 return false; 515 } 516 if (!strcasecmp($_SERVER['HTTPS'], 'off')) { 517 return false; 518 } 519 return true; 520 } 521 522 public function isContinueRequest() { 523 return $this->isFormPost() && $this->getStr('__continue__'); 524 } 525 526 public function isPreviewRequest() { 527 return $this->isFormPost() && $this->getStr('__preview__'); 528 } 529 530 /** 531 * Get application request parameters in a flattened form suitable for 532 * inclusion in an HTTP request, excluding parameters with special meanings. 533 * This is primarily useful if you want to ask the user for more input and 534 * then resubmit their request. 535 * 536 * @return dict<string, string> Original request parameters. 537 */ 538 public function getPassthroughRequestParameters() { 539 return self::flattenData($this->getPassthroughRequestData()); 540 } 541 542 /** 543 * Get request data other than "magic" parameters. 544 * 545 * @return dict<string, wild> Request data, with magic filtered out. 546 */ 547 public function getPassthroughRequestData() { 548 $data = $this->getRequestData(); 549 550 // Remove magic parameters like __dialog__ and __ajax__. 551 foreach ($data as $key => $value) { 552 if (!strncmp($key, '__', 2)) { 553 unset($data[$key]); 554 } 555 } 556 557 return $data; 558 } 559 560 561 /** 562 * Flatten an array of key-value pairs (possibly including arrays as values) 563 * into a list of key-value pairs suitable for submitting via HTTP request 564 * (with arrays flattened). 565 * 566 * @param dict<string, wild> Data to flatten. 567 * @return dict<string, string> Flat data suitable for inclusion in an HTTP 568 * request. 569 */ 570 public static function flattenData(array $data) { 571 $result = array(); 572 foreach ($data as $key => $value) { 573 if (is_array($value)) { 574 foreach (self::flattenData($value) as $fkey => $fvalue) { 575 $fkey = '['.preg_replace('/(?=\[)|$/', ']', $fkey, $limit = 1); 576 $result[$key.$fkey] = $fvalue; 577 } 578 } else { 579 $result[$key] = (string)$value; 580 } 581 } 582 583 ksort($result); 584 585 return $result; 586 } 587 588 589 /** 590 * Read the value of an HTTP header from `$_SERVER`, or a similar datasource. 591 * 592 * This function accepts a canonical header name, like `"Accept-Encoding"`, 593 * and looks up the appropriate value in `$_SERVER` (in this case, 594 * `"HTTP_ACCEPT_ENCODING"`). 595 * 596 * @param string Canonical header name, like `"Accept-Encoding"`. 597 * @param wild Default value to return if header is not present. 598 * @param array? Read this instead of `$_SERVER`. 599 * @return string|wild Header value if present, or `$default` if not. 600 */ 601 public static function getHTTPHeader($name, $default = null, $data = null) { 602 // PHP mangles HTTP headers by uppercasing them and replacing hyphens with 603 // underscores, then prepending 'HTTP_'. 604 $php_index = strtoupper($name); 605 $php_index = str_replace('-', '_', $php_index); 606 607 $try_names = array(); 608 609 $try_names[] = 'HTTP_'.$php_index; 610 if ($php_index == 'CONTENT_TYPE' || $php_index == 'CONTENT_LENGTH') { 611 // These headers may be available under alternate names. See 612 // http://www.php.net/manual/en/reserved.variables.server.php#110763 613 $try_names[] = $php_index; 614 } 615 616 if ($data === null) { 617 $data = $_SERVER; 618 } 619 620 foreach ($try_names as $try_name) { 621 if (array_key_exists($try_name, $data)) { 622 return $data[$try_name]; 623 } 624 } 625 626 return $default; 627 } 628 629 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Sun Nov 30 09:20:46 2014 | Cross-referenced by PHPXref 0.7.1 |