@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
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}