Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F15281218
D19143.id45864.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
21 KB
Referenced Files
None
Subscribers
None
D19143.id45864.diff
View Options
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
Details
Attached
Mime Type
text/plain
Expires
Tue, Mar 4, 4:21 PM (17 h, 28 m)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
7221048
Default Alt Text
D19143.id45864.diff (21 KB)
Attached To
Mode
D19143: Emit a "Content-Security-Policy" HTTP header
Attached
Detach File
Event Timeline
Log In to Comment