Page MenuHomePhabricator

D19143.diff
No OneTemporary

D19143.diff

diff --git a/resources/celerity/map.php b/resources/celerity/map.php
--- a/resources/celerity/map.php
+++ b/resources/celerity/map.php
@@ -10,7 +10,7 @@
'conpherence.pkg.css' => 'e68cf1fa',
'conpherence.pkg.js' => '15191c65',
'core.pkg.css' => '2fa91e14',
- 'core.pkg.js' => 'dc13d4b7',
+ 'core.pkg.js' => '7aa5bd92',
'darkconsole.pkg.js' => '1f9a31bc',
'differential.pkg.css' => '113e692c',
'differential.pkg.js' => 'f6d809c0',
@@ -211,12 +211,12 @@
'rsrc/externals/font/lato/lato-regular.woff' => '13d39fe2',
'rsrc/externals/font/lato/lato-regular.woff2' => '57a9f742',
'rsrc/externals/javelin/core/Event.js' => '2ee659ce',
- 'rsrc/externals/javelin/core/Stratcom.js' => '6ad39b6f',
+ 'rsrc/externals/javelin/core/Stratcom.js' => '327f418a',
'rsrc/externals/javelin/core/__tests__/event-stop-and-kill.js' => '717554e4',
'rsrc/externals/javelin/core/__tests__/install.js' => 'c432ee85',
'rsrc/externals/javelin/core/__tests__/stratcom.js' => '88bf7313',
'rsrc/externals/javelin/core/__tests__/util.js' => 'e251703d',
- 'rsrc/externals/javelin/core/init.js' => '3010e992',
+ 'rsrc/externals/javelin/core/init.js' => '638a4e2b',
'rsrc/externals/javelin/core/init_node.js' => 'c234aded',
'rsrc/externals/javelin/core/install.js' => '05270951',
'rsrc/externals/javelin/core/util.js' => '93cc50d6',
@@ -722,7 +722,7 @@
'javelin-install' => '05270951',
'javelin-json' => '69adf288',
'javelin-leader' => '7f243deb',
- 'javelin-magical-init' => '3010e992',
+ 'javelin-magical-init' => '638a4e2b',
'javelin-mask' => '8a41885b',
'javelin-quicksand' => '6b8ef10b',
'javelin-reactor' => '2b8de964',
@@ -735,7 +735,7 @@
'javelin-router' => '29274e2b',
'javelin-scrollbar' => '9065f639',
'javelin-sound' => '949c0fe5',
- 'javelin-stratcom' => '6ad39b6f',
+ 'javelin-stratcom' => '327f418a',
'javelin-tokenizer' => '8d3bc1b2',
'javelin-typeahead' => '70baed2f',
'javelin-typeahead-composite-source' => '503e17fd',
@@ -1131,6 +1131,12 @@
'javelin-dom',
'javelin-workflow',
),
+ '327f418a' => array(
+ 'javelin-install',
+ 'javelin-event',
+ 'javelin-util',
+ 'javelin-magical-init',
+ ),
'358b8c04' => array(
'javelin-install',
'javelin-util',
@@ -1446,12 +1452,6 @@
'69adf288' => array(
'javelin-install',
),
- '6ad39b6f' => array(
- 'javelin-install',
- 'javelin-event',
- 'javelin-util',
- 'javelin-magical-init',
- ),
'6b8ef10b' => array(
'javelin-install',
),
diff --git a/src/aphront/response/AphrontResponse.php b/src/aphront/response/AphrontResponse.php
--- a/src/aphront/response/AphrontResponse.php
+++ b/src/aphront/response/AphrontResponse.php
@@ -7,9 +7,11 @@
private $canCDN;
private $responseCode = 200;
private $lastModified = null;
-
+ private $contentSecurityPolicyURIs;
+ private $disableContentSecurityPolicy;
protected $frameable;
+
public function setRequest($request) {
$this->request = $request;
return $this;
@@ -19,6 +21,32 @@
return $this->request;
}
+ final public function addContentSecurityPolicyURI($kind, $uri) {
+ if ($this->contentSecurityPolicyURIs === null) {
+ $this->contentSecurityPolicyURIs = array(
+ 'script' => array(),
+ 'connect' => array(),
+ 'frame' => array(),
+ );
+ }
+
+ if (!isset($this->contentSecurityPolicyURIs[$kind])) {
+ throw new Exception(
+ pht(
+ 'Unknown Content-Security-Policy URI kind "%s".',
+ $kind));
+ }
+
+ $this->contentSecurityPolicyURIs[$kind][] = (string)$uri;
+
+ return $this;
+ }
+
+ final public function setDisableContentSecurityPolicy($disable) {
+ $this->disableContentSecurityPolicy = $disable;
+ return $this;
+ }
+
/* -( Content )------------------------------------------------------------ */
@@ -59,9 +87,106 @@
);
}
+ $csp = $this->newContentSecurityPolicyHeader();
+ if ($csp !== null) {
+ $headers[] = array('Content-Security-Policy', $csp);
+ }
+
return $headers;
}
+ private function newContentSecurityPolicyHeader() {
+ if ($this->disableContentSecurityPolicy) {
+ return null;
+ }
+
+ $csp = array();
+
+ $cdn = PhabricatorEnv::getEnvConfig('security.alternate-file-domain');
+ if ($cdn) {
+ $default = $this->newContentSecurityPolicySource($cdn);
+ } else {
+ $default = "'self'";
+ }
+
+ $csp[] = "default-src {$default}";
+
+ // We use "data:" URIs to inline small images into CSS. This policy allows
+ // "data:" URIs to be used anywhere, but there doesn't appear to be a way
+ // to say that "data:" URIs are okay in CSS files but not in the document.
+ $csp[] = "img-src {$default} data:";
+
+ // We use inline style="..." attributes in various places, many of which
+ // are legitimate. We also currently use a <style> tag to implement the
+ // "Monospaced Font Preference" setting.
+ $csp[] = "style-src {$default} 'unsafe-inline'";
+
+ // On a small number of pages, including the Stripe workflow and the
+ // ReCAPTCHA challenge, we embed external Javascript directly.
+ $csp[] = $this->newContentSecurityPolicy('script', $default);
+
+ // We need to specify that we can connect to ourself in order for AJAX
+ // requests to work.
+ $csp[] = $this->newContentSecurityPolicy('connect', "'self'");
+
+ // DarkConsole and PHPAST both use frames to render some content.
+ $csp[] = $this->newContentSecurityPolicy('frame', "'self'");
+
+ // This is a more modern flavor of of "X-Frame-Options" and prevents
+ // clickjacking attacks where the page is included in a tiny iframe and
+ // the user is convinced to click a element on the page, which really
+ // clicks a dangerous button hidden under a picture of a cat.
+ if ($this->frameable) {
+ $csp[] = "frame-ancestors 'self'";
+ } else {
+ $csp[] = "frame-ancestors 'none'";
+ }
+
+ $csp = implode('; ', $csp);
+
+ return $csp;
+ }
+
+ private function newContentSecurityPolicy($type, $defaults) {
+ if ($defaults === null) {
+ $sources = array();
+ } else {
+ $sources = (array)$defaults;
+ }
+
+ $uris = $this->contentSecurityPolicyURIs;
+ if (isset($uris[$type])) {
+ foreach ($uris[$type] as $uri) {
+ $sources[] = $this->newContentSecurityPolicySource($uri);
+ }
+ }
+ $sources = array_unique($sources);
+
+ return "{$type}-src ".implode(' ', $sources);
+ }
+
+ private function newContentSecurityPolicySource($uri) {
+ // Some CSP URIs are ultimately user controlled (like notification server
+ // URIs and CDN URIs) so attempt to stop an attacker from injecting an
+ // unsafe source (like 'unsafe-eval') into the CSP header.
+
+ $uri = id(new PhutilURI($uri))
+ ->setPath(null)
+ ->setFragment(null)
+ ->setQueryParams(array());
+
+ $uri = (string)$uri;
+ if (preg_match('/[ ;\']/', $uri)) {
+ throw new Exception(
+ pht(
+ 'Attempting to emit a response with an unsafe source ("%s") in the '.
+ 'Content-Security-Policy header.',
+ $uri));
+ }
+
+ return $uri;
+ }
+
public function setCacheDurationInSeconds($duration) {
$this->cacheable = $duration;
return $this;
diff --git a/src/applications/celerity/CelerityStaticResourceResponse.php b/src/applications/celerity/CelerityStaticResourceResponse.php
--- a/src/applications/celerity/CelerityStaticResourceResponse.php
+++ b/src/applications/celerity/CelerityStaticResourceResponse.php
@@ -17,6 +17,7 @@
private $behaviors = array();
private $hasRendered = array();
private $postprocessorKey;
+ private $contentSecurityPolicyURIs = array();
public function __construct() {
if (isset($_REQUEST['__metablock__'])) {
@@ -37,6 +38,15 @@
return $this->metadataBlock.'_'.$id;
}
+ public function addContentSecurityPolicyURI($kind, $uri) {
+ $this->contentSecurityPolicyURIs[$kind][] = $uri;
+ return $this;
+ }
+
+ public function getContentSecurityPolicyURIMap() {
+ return $this->contentSecurityPolicyURIs;
+ }
+
public function getMetadataBlock() {
return $this->metadataBlock;
}
@@ -196,23 +206,16 @@
$type));
}
- public function renderHTMLFooter() {
+ public function renderHTMLFooter($is_frameable) {
$this->metadataLocked = true;
- $data = array();
- if ($this->metadata) {
- $json_metadata = AphrontResponse::encodeJSONForHTTPResponse(
- $this->metadata);
- $this->metadata = array();
- } else {
- $json_metadata = '{}';
- }
- // Even if there is no metadata on the page, Javelin uses the mergeData()
- // call to start dispatching the event queue.
- $data[] = 'JX.Stratcom.mergeData('.$this->metadataBlock.', '.
- $json_metadata.');';
+ $merge_data = array(
+ 'block' => $this->metadataBlock,
+ 'data' => $this->metadata,
+ );
+ $this->metadata = array();
- $onload = array();
+ $behavior_lists = array();
if ($this->behaviors) {
$behaviors = $this->behaviors;
$this->behaviors = array();
@@ -241,24 +244,52 @@
if (!$group) {
continue;
}
- $group_json = AphrontResponse::encodeJSONForHTTPResponse(
- $group);
- $onload[] = 'JX.initBehaviors('.$group_json.')';
+ $behavior_lists[] = $group;
}
}
- if ($onload) {
- foreach ($onload as $func) {
- $data[] = 'JX.onload(function(){'.$func.'});';
- }
+ $initializers = array();
+
+ // Even if there is no metadata on the page, Javelin uses the mergeData()
+ // call to start dispatching the event queue, so we always want to include
+ // this initializer.
+ $initializers[] = array(
+ 'kind' => 'merge',
+ 'data' => $merge_data,
+ );
+
+ foreach ($behavior_lists as $behavior_list) {
+ $initializers[] = array(
+ 'kind' => 'behaviors',
+ 'data' => $behavior_list,
+ );
}
- if ($data) {
- $data = implode("\n", $data);
- return self::renderInlineScript($data);
- } else {
- return '';
+ if ($is_frameable) {
+ $initializers[] = array(
+ 'data' => 'frameable',
+ 'kind' => (bool)$is_frameable,
+ );
+ }
+
+ $tags = array();
+ foreach ($initializers as $initializer) {
+ $data = $initializer['data'];
+ if (is_array($data)) {
+ $json_data = AphrontResponse::encodeJSONForHTTPResponse($data);
+ } else {
+ $json_data = json_encode($data);
+ }
+
+ $tags[] = phutil_tag(
+ 'data',
+ array(
+ 'data-javelin-init-kind' => $initializer['kind'],
+ 'data-javelin-init-data' => $json_data,
+ ));
}
+
+ return $tags;
}
public static function renderInlineScript($data) {
diff --git a/src/applications/celerity/controller/CelerityResourceController.php b/src/applications/celerity/controller/CelerityResourceController.php
--- a/src/applications/celerity/controller/CelerityResourceController.php
+++ b/src/applications/celerity/controller/CelerityResourceController.php
@@ -106,6 +106,11 @@
$response = id(new AphrontFileResponse())
->setMimeType($type_map[$type]);
+ // The "Content-Security-Policy" header has no effect on the actual
+ // resources, only on the main request. Disable it on the resource
+ // responses to limit confusion.
+ $response->setDisableContentSecurityPolicy(true);
+
$range = AphrontRequest::getHTTPHeader('Range');
if (strlen($range)) {
diff --git a/src/applications/phortune/provider/PhortuneStripePaymentProvider.php b/src/applications/phortune/provider/PhortuneStripePaymentProvider.php
--- a/src/applications/phortune/provider/PhortuneStripePaymentProvider.php
+++ b/src/applications/phortune/provider/PhortuneStripePaymentProvider.php
@@ -275,12 +275,18 @@
AphrontRequest $request,
array $errors) {
+ $src = 'https://js.stripe.com/v2/';
+
$ccform = id(new PhortuneCreditCardForm())
->setSecurityAssurance(
pht('Payments are processed securely by Stripe.'))
->setUser($request->getUser())
->setErrors($errors)
- ->addScript('https://js.stripe.com/v2/');
+ ->addScript($src);
+
+ CelerityAPI::getStaticResourceResponse()
+ ->addContentSecurityPolicyURI('script', $src)
+ ->addContentSecurityPolicyURI('frame', $src);
Javelin::initBehavior(
'stripe-payment-form',
diff --git a/src/view/form/control/AphrontFormRecaptchaControl.php b/src/view/form/control/AphrontFormRecaptchaControl.php
--- a/src/view/form/control/AphrontFormRecaptchaControl.php
+++ b/src/view/form/control/AphrontFormRecaptchaControl.php
@@ -42,16 +42,24 @@
$js = 'https://www.google.com/recaptcha/api.js';
$pubkey = PhabricatorEnv::getEnvConfig('recaptcha.public-key');
+ CelerityAPI::getStaticResourceResponse()
+ ->addContentSecurityPolicyURI('script', $js)
+ ->addContentSecurityPolicyURI('script', 'https://www.gstatic.com/')
+ ->addContentSecurityPolicyURI('frame', 'https://www.google.com/');
+
return array(
- phutil_tag('div', array(
- 'class' => 'g-recaptcha',
- 'data-sitekey' => $pubkey,
- )),
-
- phutil_tag('script', array(
- 'type' => 'text/javascript',
- 'src' => $js,
- )),
+ phutil_tag(
+ 'div',
+ array(
+ 'class' => 'g-recaptcha',
+ 'data-sitekey' => $pubkey,
+ )),
+ phutil_tag(
+ 'script',
+ array(
+ 'type' => 'text/javascript',
+ 'src' => $js,
+ )),
);
}
}
diff --git a/src/view/page/AphrontPageView.php b/src/view/page/AphrontPageView.php
--- a/src/view/page/AphrontPageView.php
+++ b/src/view/page/AphrontPageView.php
@@ -59,9 +59,15 @@
),
array($body, $tail));
+ if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) {
+ $data_fragment = phutil_safe_html(' data-developer-mode="1"');
+ } else {
+ $data_fragment = null;
+ }
+
$response = hsprintf(
'<!DOCTYPE html>'.
- '<html>'.
+ '<html%s>'.
'<head>'.
'<meta charset="UTF-8" />'.
'<title>%s</title>'.
@@ -69,6 +75,7 @@
'</head>'.
'%s'.
'</html>',
+ $data_fragment,
$title,
$head,
$body);
diff --git a/src/view/page/PhabricatorBarePageView.php b/src/view/page/PhabricatorBarePageView.php
--- a/src/view/page/PhabricatorBarePageView.php
+++ b/src/view/page/PhabricatorBarePageView.php
@@ -59,11 +59,6 @@
}
protected function getHead() {
- $framebust = null;
- if (!$this->getFrameable()) {
- $framebust = '(top == self) || top.location.replace(self.location.href);';
- }
-
$viewport_tag = null;
if ($this->getDeviceReady()) {
$viewport_tag = phutil_tag(
@@ -140,9 +135,8 @@
}
}
- $developer = PhabricatorEnv::getEnvConfig('phabricator.developer-mode');
return hsprintf(
- '%s%s%s%s%s%s%s%s%s',
+ '%s%s%s%s%s%s%s%s',
$viewport_tag,
$mask_icon,
$icon_tag_76,
@@ -150,8 +144,6 @@
$icon_tag_152,
$favicon_tag,
$referrer_tag,
- CelerityStaticResourceResponse::renderInlineScript(
- $framebust.jsprintf('window.__DEV__=%d;', ($developer ? 1 : 0))),
$response->renderResourcesOfType('css'));
}
diff --git a/src/view/page/PhabricatorStandardPageView.php b/src/view/page/PhabricatorStandardPageView.php
--- a/src/view/page/PhabricatorStandardPageView.php
+++ b/src/view/page/PhabricatorStandardPageView.php
@@ -608,12 +608,15 @@
Javelin::initBehavior(
'aphlict-listen',
array(
- 'websocketURI' => (string)$client_uri,
+ 'websocketURI' => (string)$client_uri,
) + $this->buildAphlictListenConfigData());
+
+ CelerityAPI::getStaticResourceResponse()
+ ->addContentSecurityPolicyURI('connect', $client_uri);
}
}
- $tail[] = $response->renderHTMLFooter();
+ $tail[] = $response->renderHTMLFooter($this->getFrameable());
return $tail;
}
@@ -860,6 +863,19 @@
$blacklist[] = $application->getQuicksandURIPatternBlacklist();
}
+ // See T4340. Currently, Phortune and Auth both require pulling in external
+ // Javascript (for Stripe card management and Recaptcha, respectively).
+ // This can put us in a position where the user loads a page with a
+ // restrictive Content-Security-Policy, then uses Quicksand to navigate to
+ // a page which needs to load external scripts. For now, just blacklist
+ // these entire applications since we aren't giving up anything
+ // significant by doing so.
+
+ $blacklist[] = array(
+ '/phortune/.*',
+ '/auth/.*',
+ );
+
return array_mergev($blacklist);
}
@@ -903,9 +919,17 @@
->setContent($content);
} else {
$content = $this->render();
+
$response = id(new AphrontWebpageResponse())
->setContent($content)
->setFrameable($this->getFrameable());
+
+ $static = CelerityAPI::getStaticResourceResponse();
+ foreach ($static->getContentSecurityPolicyURIMap() as $kind => $uris) {
+ foreach ($uris as $uri) {
+ $response->addContentSecurityPolicyURI($kind, $uri);
+ }
+ }
}
return $response;
diff --git a/webroot/rsrc/externals/javelin/core/Stratcom.js b/webroot/rsrc/externals/javelin/core/Stratcom.js
--- a/webroot/rsrc/externals/javelin/core/Stratcom.js
+++ b/webroot/rsrc/externals/javelin/core/Stratcom.js
@@ -517,6 +517,36 @@
return len ? this._execContext[len - 1].event : null;
},
+ initialize: function(initializers) {
+ var frameable = false;
+
+ for (var ii = 0; ii < initializers.length; ii++) {
+ var kind = initializers[ii].kind;
+ var data = initializers[ii].data;
+ switch (kind) {
+ case 'behaviors':
+ JX.initBehaviors(data);
+ break;
+ case 'merge':
+ JX.Stratcom.mergeData(data.block, data.data);
+ JX.Stratcom.ready = true;
+ break;
+ case 'frameable':
+ frameable = !!data;
+ break;
+ }
+ }
+
+ // If the initializer tags did not explicitly allow framing, framebust.
+ // This protects us from clickjacking attacks on older versions of IE.
+ // The "X-Frame-Options" and "Content-Security-Policy" headers provide
+ // more modern variations of this protection.
+ if (!frameable) {
+ if (window.top != window.self) {
+ window.top.location.replace(window.self.location.href);
+ }
+ }
+ },
/**
* Merge metadata. You must call this (even if you have no metadata) to
@@ -542,7 +572,6 @@
} else {
this._data[block] = data;
if (block === 0) {
- JX.Stratcom.ready = true;
JX.flushHoldingQueue('install-init', function(fn) {
fn();
});
diff --git a/webroot/rsrc/externals/javelin/core/init.js b/webroot/rsrc/externals/javelin/core/init.js
--- a/webroot/rsrc/externals/javelin/core/init.js
+++ b/webroot/rsrc/externals/javelin/core/init.js
@@ -46,25 +46,51 @@
makeHoldingQueue('behavior');
makeHoldingQueue('install-init');
- window.__DEV__ = window.__DEV__ || 0;
-
var loaded = false;
var onload = [];
var master_event_queue = [];
var root = document.documentElement;
var has_add_event_listener = !!root.addEventListener;
+ window.__DEV__ = !!root.getAttribute('data-developer-mode');
+
JX.__rawEventQueue = function(what) {
master_event_queue.push(what);
- // Evade static analysis - JX.Stratcom
+ var ii;
var Stratcom = JX['Stratcom'];
- if (Stratcom && Stratcom.ready) {
- // Empty the queue now so that exceptions don't cause us to repeatedly
- // try to handle events.
+
+ if (!loaded && what.type == 'domready') {
+ var initializers = [];
+
+ var tags = JX.DOM.scry(document.body, 'data');
+ for (ii = 0; ii < tags.length; ii++) {
+
+ // Ignore tags which are not immediate children of the document
+ // body. If an attacker somehow injects arbitrary tags into the
+ // content of the document, that should not give them access to
+ // modify initialization behaviors.
+ if (tags[ii].parentNode !== document.body) {
+ continue;
+ }
+
+ var tag_kind = tags[ii].getAttribute('data-javelin-init-kind');
+ var tag_data = tags[ii].getAttribute('data-javelin-init-data');
+ tag_data = JX.JSON.parse(tag_data);
+
+ initializers.push({kind: tag_kind, data: tag_data});
+ }
+
+ Stratcom.initialize(initializers);
+ loaded = true;
+ }
+
+ if (loaded) {
+ // Empty the queue now so that exceptions don't cause us to repeatedly
+ // try to handle events.
var local_queue = master_event_queue;
master_event_queue = [];
- for (var ii = 0; ii < local_queue.length; ++ii) {
+ for (ii = 0; ii < local_queue.length; ++ii) {
var evt = local_queue[ii];
// Sometimes IE gives us events which throw when ".type" is accessed;
@@ -72,11 +98,10 @@
// figure out where these are coming from.
try { var test = evt.type; } catch (x) { continue; }
- if (!loaded && evt.type == 'domready') {
+ if (evt.type == 'domready') {
// NOTE: Firefox interprets "document.body.id = null" as the string
// literal "null".
document.body && (document.body.id = '');
- loaded = true;
for (var jj = 0; jj < onload.length; jj++) {
onload[jj]();
}

File Metadata

Mime Type
text/plain
Expires
Mon, May 13, 11:13 PM (1 w, 2 d ago)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
6293730
Default Alt Text
D19143.diff (21 KB)

Event Timeline