@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at upstream/main 449 lines 14 kB view raw
1<?php 2 3abstract class AphrontResponse extends Phobject { 4 5 private $request; 6 private $cacheable = false; 7 private $canCDN; 8 private $responseCode = 200; 9 private $lastModified = null; 10 private $contentSecurityPolicyURIs; 11 private $disableContentSecurityPolicy; 12 protected $frameable; 13 private $headers = array(); 14 15 public function setRequest($request) { 16 $this->request = $request; 17 return $this; 18 } 19 20 public function getRequest() { 21 return $this->request; 22 } 23 24 final public function addContentSecurityPolicyURI($kind, $uri) { 25 if ($this->contentSecurityPolicyURIs === null) { 26 $this->contentSecurityPolicyURIs = array( 27 'script-src' => array(), 28 'connect-src' => array(), 29 'frame-src' => array(), 30 'form-action' => array(), 31 'object-src' => array(), 32 ); 33 } 34 35 if (!isset($this->contentSecurityPolicyURIs[$kind])) { 36 throw new Exception( 37 pht( 38 'Unknown Content-Security-Policy URI kind "%s".', 39 $kind)); 40 } 41 42 $this->contentSecurityPolicyURIs[$kind][] = (string)$uri; 43 44 return $this; 45 } 46 47 final public function setDisableContentSecurityPolicy($disable) { 48 $this->disableContentSecurityPolicy = $disable; 49 return $this; 50 } 51 52 final public function addHeader($key, $value) { 53 $this->headers[] = array($key, $value); 54 return $this; 55 } 56 57 58/* -( Content )------------------------------------------------------------ */ 59 60 61 public function getContentIterator() { 62 // By default, make sure responses are truly returning a string, not some 63 // kind of object that behaves like a string. 64 65 // We're going to remove the execution time limit before dumping the 66 // response into the sink, and want any rendering that's going to occur 67 // to happen BEFORE we release the limit. 68 69 return array( 70 (string)$this->buildResponseString(), 71 ); 72 } 73 74 public function buildResponseString() { 75 throw new PhutilMethodNotImplementedException(); 76 } 77 78 79/* -( Metadata )----------------------------------------------------------- */ 80 81 82 public function getHeaders() { 83 $headers = array(); 84 if (!$this->frameable) { 85 $headers[] = array('X-Frame-Options', 'Deny'); 86 } 87 88 if ($this->getRequest() && $this->getRequest()->isHTTPS()) { 89 $hsts_key = 'security.strict-transport-security'; 90 $use_hsts = PhabricatorEnv::getEnvConfig($hsts_key); 91 if ($use_hsts) { 92 $duration = phutil_units('365 days in seconds'); 93 } else { 94 // If HSTS has been disabled, tell browsers to turn it off. This may 95 // not be effective because we can only disable it over a valid HTTPS 96 // connection, but it best represents the configured intent. 97 $duration = 0; 98 } 99 100 $headers[] = array( 101 'Strict-Transport-Security', 102 "max-age={$duration}; includeSubdomains; preload", 103 ); 104 } 105 106 $csp = $this->newContentSecurityPolicyHeader(); 107 if ($csp !== null) { 108 $headers[] = array('Content-Security-Policy', $csp); 109 } 110 111 $headers[] = array('Referrer-Policy', 'no-referrer'); 112 113 foreach ($this->headers as $header) { 114 $headers[] = $header; 115 } 116 117 return $headers; 118 } 119 120 private function newContentSecurityPolicyHeader() { 121 if ($this->disableContentSecurityPolicy) { 122 return null; 123 } 124 125 // NOTE: We may return a response during preflight checks (for example, 126 // if a user has a bad version of PHP). 127 128 // In this case, setup isn't complete yet and we can't access environmental 129 // configuration. If we aren't able to read the environment, just decline 130 // to emit a Content-Security-Policy header. 131 132 try { 133 $cdn = PhabricatorEnv::getEnvConfig('security.alternate-file-domain'); 134 $base_uri = PhabricatorEnv::getURI('/'); 135 } catch (Exception $ex) { 136 return null; 137 } 138 139 $csp = array(); 140 if ($cdn) { 141 $default = $this->newContentSecurityPolicySource($cdn); 142 } else { 143 // If an alternate file domain is not configured and the user is viewing 144 // a Phame blog on a custom domain or some other custom site, we'll still 145 // serve resources from the main site. Include the main site explicitly. 146 $base_uri = $this->newContentSecurityPolicySource($base_uri); 147 148 $default = "'self' {$base_uri}"; 149 } 150 151 $csp[] = "default-src {$default}"; 152 153 // We use "data:" URIs to inline small images into CSS. This policy allows 154 // "data:" URIs to be used anywhere, but there doesn't appear to be a way 155 // to say that "data:" URIs are okay in CSS files but not in the document. 156 $csp[] = "img-src {$default} data:"; 157 158 // We use inline style="..." attributes in various places, many of which 159 // are legitimate. We also currently use a <style> tag to implement the 160 // "Monospaced Font Preference" setting. 161 $csp[] = "style-src {$default} 'unsafe-inline'"; 162 163 // On a small number of pages, including the Stripe workflow and the 164 // ReCAPTCHA challenge, we embed external Javascript directly. 165 $csp[] = $this->newContentSecurityPolicy('script-src', $default); 166 167 // We need to specify that we can connect to ourself in order for AJAX 168 // requests to work. 169 $csp[] = $this->newContentSecurityPolicy('connect-src', "'self'"); 170 171 // DarkConsole and PHPAST both use frames to render some content. 172 $csp[] = $this->newContentSecurityPolicy('frame-src', "'self'"); 173 174 // This is a more modern flavor of "X-Frame-Options" and prevents 175 // clickjacking attacks where the page is included in a tiny iframe and 176 // the user is convinced to click a element on the page, which really 177 // clicks a dangerous button hidden under a picture of a cat. 178 if ($this->frameable) { 179 $csp[] = "frame-ancestors 'self'"; 180 } else { 181 $csp[] = "frame-ancestors 'none'"; 182 } 183 184 // Block relics of the old world: Flash, Java applets, and so on. Note 185 // that Chrome prevents the user from viewing PDF documents if they are 186 // served with a policy which excludes the domain they are served from. 187 $csp[] = $this->newContentSecurityPolicy('object-src', "'none'"); 188 189 // Don't allow forms to submit offsite. 190 191 // This can result in some trickiness with file downloads if applications 192 // try to start downloads by submitting a dialog. Redirect to the file's 193 // download URI instead of submitting a form to it. 194 $csp[] = $this->newContentSecurityPolicy('form-action', "'self'"); 195 196 // Block use of "<base>" to change the origin of relative URIs on the page. 197 $csp[] = "base-uri 'none'"; 198 199 $csp = implode('; ', $csp); 200 201 return $csp; 202 } 203 204 private function newContentSecurityPolicy($type, $defaults) { 205 if ($defaults === null) { 206 $sources = array(); 207 } else { 208 $sources = (array)$defaults; 209 } 210 211 $uris = $this->contentSecurityPolicyURIs; 212 if (isset($uris[$type])) { 213 foreach ($uris[$type] as $uri) { 214 $sources[] = $this->newContentSecurityPolicySource($uri); 215 } 216 } 217 $sources = array_unique($sources); 218 219 return $type.' '.implode(' ', $sources); 220 } 221 222 private function newContentSecurityPolicySource($uri) { 223 // Some CSP URIs are ultimately user controlled (like notification server 224 // URIs and CDN URIs) so attempt to stop an attacker from injecting an 225 // unsafe source (like 'unsafe-eval') into the CSP header. 226 227 $uri = id(new PhutilURI($uri)) 228 ->setPath(null) 229 ->setFragment(null) 230 ->removeAllQueryParams(); 231 232 $uri = (string)$uri; 233 if (preg_match('/[ ;\']/', $uri)) { 234 throw new Exception( 235 pht( 236 'Attempting to emit a response with an unsafe source ("%s") in the '. 237 'Content-Security-Policy header.', 238 $uri)); 239 } 240 241 return $uri; 242 } 243 244 public function setCacheDurationInSeconds($duration) { 245 $this->cacheable = $duration; 246 return $this; 247 } 248 249 public function setCanCDN($can_cdn) { 250 $this->canCDN = $can_cdn; 251 return $this; 252 } 253 254 public function setLastModified($epoch_timestamp) { 255 $this->lastModified = $epoch_timestamp; 256 return $this; 257 } 258 259 public function setHTTPResponseCode($code) { 260 $this->responseCode = $code; 261 return $this; 262 } 263 264 public function getHTTPResponseCode() { 265 return $this->responseCode; 266 } 267 268 public function getHTTPResponseMessage() { 269 switch ($this->getHTTPResponseCode()) { 270 case 100: return 'Continue'; 271 case 101: return 'Switching Protocols'; 272 case 200: return 'OK'; 273 case 201: return 'Created'; 274 case 202: return 'Accepted'; 275 case 203: return 'Non-Authoritative Information'; 276 case 204: return 'No Content'; 277 case 205: return 'Reset Content'; 278 case 206: return 'Partial Content'; 279 case 300: return 'Multiple Choices'; 280 case 301: return 'Moved Permanently'; 281 case 302: return 'Found'; 282 case 303: return 'See Other'; 283 case 304: return 'Not Modified'; 284 case 305: return 'Use Proxy'; 285 case 306: return 'Switch Proxy'; 286 case 307: return 'Temporary Redirect'; 287 case 400: return 'Bad Request'; 288 case 401: return 'Unauthorized'; 289 case 402: return 'Payment Required'; 290 case 403: return 'Forbidden'; 291 case 404: return 'Not Found'; 292 case 405: return 'Method Not Allowed'; 293 case 406: return 'Not Acceptable'; 294 case 407: return 'Proxy Authentication Required'; 295 case 408: return 'Request Timeout'; 296 case 409: return 'Conflict'; 297 case 410: return 'Gone'; 298 case 411: return 'Length Required'; 299 case 412: return 'Precondition Failed'; 300 case 413: return 'Request Entity Too Large'; 301 case 414: return 'Request-URI Too Long'; 302 case 415: return 'Unsupported Media Type'; 303 case 416: return 'Requested Range Not Satisfiable'; 304 case 417: return 'Expectation Failed'; 305 case 418: return "I'm a teapot"; 306 case 426: return 'Upgrade Required'; 307 case 500: return 'Internal Server Error'; 308 case 501: return 'Not Implemented'; 309 case 502: return 'Bad Gateway'; 310 case 503: return 'Service Unavailable'; 311 case 504: return 'Gateway Timeout'; 312 case 505: return 'HTTP Version Not Supported'; 313 default: return ''; 314 } 315 } 316 317 public function setFrameable($frameable) { 318 $this->frameable = $frameable; 319 return $this; 320 } 321 322 public static function processValueForJSONEncoding(&$value, $key) { 323 if ($value instanceof PhutilSafeHTMLProducerInterface) { 324 // This renders the producer down to PhutilSafeHTML, which will then 325 // be simplified into a string below. 326 $value = hsprintf('%s', $value); 327 } 328 329 if ($value instanceof PhutilSafeHTML) { 330 // TODO: Javelin supports implicit conversion of '__html' objects to 331 // JX.HTML, but only for Ajax responses, not behaviors. Just leave things 332 // as they are for now (where behaviors treat responses as HTML or plain 333 // text at their discretion). 334 $value = $value->getHTMLContent(); 335 } 336 } 337 338 public static function encodeJSONForHTTPResponse(array $object) { 339 340 array_walk_recursive( 341 $object, 342 array(self::class, 'processValueForJSONEncoding')); 343 344 $response = phutil_json_encode($object); 345 346 // Prevent content sniffing attacks by encoding "<" and ">", so browsers 347 // won't try to execute the document as HTML even if they ignore 348 // Content-Type and X-Content-Type-Options. See T865. 349 $response = str_replace( 350 array('<', '>'), 351 array('\u003c', '\u003e'), 352 $response); 353 354 return $response; 355 } 356 357 protected function addJSONShield($json_response) { 358 // Add a shield to prevent "JSON Hijacking" attacks where an attacker 359 // requests a JSON response using a normal <script /> tag and then uses 360 // Object.prototype.__defineSetter__() or similar to read response data. 361 // This header causes the browser to loop infinitely instead of handing over 362 // sensitive data. 363 364 $shield = 'for (;;);'; 365 366 $response = $shield.$json_response; 367 368 return $response; 369 } 370 371 public function getCacheHeaders() { 372 $headers = array(); 373 if ($this->cacheable) { 374 $cache_control = array(); 375 $cache_control[] = sprintf('max-age=%d', $this->cacheable); 376 377 if ($this->canCDN) { 378 $cache_control[] = 'public'; 379 } else { 380 $cache_control[] = 'private'; 381 } 382 383 $headers[] = array( 384 'Cache-Control', 385 implode(', ', $cache_control), 386 ); 387 388 $headers[] = array( 389 'Expires', 390 $this->formatEpochTimestampForHTTPHeader(time() + $this->cacheable), 391 ); 392 } else { 393 $headers[] = array( 394 'Cache-Control', 395 'no-store', 396 ); 397 $headers[] = array( 398 'Expires', 399 'Sat, 01 Jan 2000 00:00:00 GMT', 400 ); 401 } 402 403 if ($this->lastModified) { 404 $headers[] = array( 405 'Last-Modified', 406 $this->formatEpochTimestampForHTTPHeader($this->lastModified), 407 ); 408 } 409 410 // IE has a feature where it may override an explicit Content-Type 411 // declaration by inferring a content type. This can be a security risk 412 // and we always explicitly transmit the correct Content-Type header, so 413 // prevent IE from using inferred content types. This only offers protection 414 // on recent versions of IE; IE6/7 and Opera currently ignore this header. 415 $headers[] = array('X-Content-Type-Options', 'nosniff'); 416 417 return $headers; 418 } 419 420 private function formatEpochTimestampForHTTPHeader($epoch_timestamp) { 421 return gmdate('D, d M Y H:i:s', $epoch_timestamp).' GMT'; 422 } 423 424 protected function shouldCompressResponse() { 425 return true; 426 } 427 428 public function willBeginWrite() { 429 // If we've already sent headers, these "ini_set()" calls will warn that 430 // they have no effect. Today, this always happens because we're inside 431 // a unit test, so just skip adjusting the setting. 432 433 if (!headers_sent()) { 434 if ($this->shouldCompressResponse()) { 435 // Enable automatic compression here. Webservers sometimes do this for 436 // us, but we now detect the absence of compression and warn users about 437 // it so try to cover our bases more thoroughly. 438 ini_set('zlib.output_compression', 1); 439 } else { 440 ini_set('zlib.output_compression', 0); 441 } 442 } 443 } 444 445 public function didCompleteWrite($aborted) { 446 return; 447 } 448 449}