Commit ce6f3ec8 authored by effulgentsia's avatar effulgentsia

Issue #2554219 by nod_, larowlan, tim.plunkett, regilero, droplet, japerry,...

Issue #2554219 by nod_, larowlan, tim.plunkett, regilero, droplet, japerry, samuel.mortenson, Pere Orga, effulgentsia, benjy, Gábor Hojtsy, greggles, Wim Leers, David_Rothstein, pwolanin, neclimdul, EclipseGc, znerol: Port Cross-site Scripting - Ajax system fixes from SA-CORE-2015-003 to Drupal 8
parent a2cdc3e9
......@@ -87,6 +87,7 @@ drupal.ajax:
libraries: null
theme: null
theme_token: null
ajaxTrustedUrl: {}
dependencies:
- core/jquery
- core/drupal
......
......@@ -101,6 +101,13 @@ public function onResponse(FilterResponseEvent $event) {
// @see https://www.drupal.org/node/1009382
$response->setContent('<textarea>' . $response->getContent() . '</textarea>');
}
// User-uploaded files cannot set any response headers, so a custom header
// is used to indicate to ajax.js that this response is safe. Note that
// most Ajax requests bound using the Form API will be protected by having
// the URL flagged as trusted in Drupal.settings, so this header is used
// only for things like custom markup that gets Ajax behaviors attached.
$response->headers->set('X-Drupal-Ajax-Token', 1);
}
}
......
......@@ -943,6 +943,11 @@ public function doBuildForm($form_id, &$element, FormStateInterface &$form_state
$element['#attributes']['enctype'] = 'multipart/form-data';
}
// Allow Ajax submissions to the form action to bypass verification. This
// is especially useful for multipart forms, which cannot be verified via
// a response header.
$element['#attached']['drupalSettings']['ajaxTrustedUrl'][$element['#action']] = TRUE;
// If a form contains a single textfield, and the ENTER key is pressed
// within it, Internet Explorer submits the form with no POST data
// identifying any submit button. Other browsers submit POST data as
......
......@@ -304,6 +304,7 @@ public static function preRenderAjaxForm($element) {
}
$element['#attached']['drupalSettings']['ajax'][$element['#id']] = $settings;
$element['#attached']['drupalSettings']['ajaxTrustedUrl'][$settings['url']] = TRUE;
// Indicate that Ajax processing was successful.
$element['#ajax_processed'] = TRUE;
......
......@@ -95,8 +95,10 @@
* XMLHttpRequest object used for the failed request.
* @param {string} uri
* The URI where the error occurred.
* @param {string} customMessage
* The custom message.
*/
Drupal.AjaxError = function (xmlhttp, uri) {
Drupal.AjaxError = function (xmlhttp, uri, customMessage) {
var statusCode;
var statusText;
......@@ -140,12 +142,14 @@
// We don't need readyState except for status == 0.
readyStateText = xmlhttp.status === 0 ? ("\n" + Drupal.t("ReadyState: !readyState", {'!readyState': xmlhttp.readyState})) : "";
customMessage = customMessage ? ("\n" + Drupal.t("CustomMessage: !customMessage", {'!customMessage': customMessage})) : "";
/**
* Formatted and translated error message.
*
* @type {string}
*/
this.message = statusCode + pathText + statusText + responseText + readyStateText;
this.message = statusCode + pathText + statusText + customMessage + responseText + readyStateText;
/**
* Used by some browsers to display a more accurate stack trace.
......@@ -338,7 +342,13 @@
// 2. /nojs$ - The end of a URL string.
// 3. /nojs? - Followed by a query (e.g. path/nojs?destination=foobar).
// 4. /nojs# - Followed by a fragment (e.g.: path/nojs#myfragment).
var originalUrl = this.url;
this.url = this.url.replace(/\/nojs(\/|$|\?|#)/g, '/ajax$1');
// If the 'nojs' version of the URL is trusted, also trust the 'ajax'
// version.
if (drupalSettings.ajaxTrustedUrl[originalUrl]) {
drupalSettings.ajaxTrustedUrl[this.url] = true;
}
// Set the options for the ajaxSubmit function.
// The 'this' variable will not persist inside of the options object.
......@@ -377,18 +387,36 @@
ajax.ajaxing = true;
return ajax.beforeSend(xmlhttprequest, options);
},
success: function (response, status) {
success: function (response, status, xmlhttprequest) {
// Sanity check for browser support (object expected).
// When using iFrame uploads, responses must be returned as a string.
if (typeof response === 'string') {
response = $.parseJSON(response);
}
// Prior to invoking the response's commands, verify that they can be
// trusted by checking for a response header. See
// \Drupal\Core\EventSubscriber\AjaxResponseSubscriber for details.
// - Empty responses are harmless so can bypass verification. This
// avoids an alert message for server-generated no-op responses that
// skip Ajax rendering.
// - Ajax objects with trusted URLs (e.g., ones defined server-side via
// #ajax) can bypass header verification. This is especially useful
// for Ajax with multipart forms. Because IFRAME transport is used,
// the response headers cannot be accessed for verification.
if (response !== null && !drupalSettings.ajaxTrustedUrl[ajax.url]) {
if (xmlhttprequest.getResponseHeader('X-Drupal-Ajax-Token') !== '1') {
var customMessage = Drupal.t("The response failed verification so will not be processed.");
return ajax.error(xmlhttprequest, ajax.url, customMessage);
}
}
return ajax.success(response, status);
},
complete: function (response, status) {
complete: function (xmlhttprequest, status) {
ajax.ajaxing = false;
if (status === 'error' || status === 'parsererror') {
return ajax.error(response, ajax.url);
return ajax.error(xmlhttprequest, ajax.url);
}
},
dataType: 'json',
......@@ -411,6 +439,9 @@
// Bind the ajaxSubmit function to the element event.
$(ajax.element).on(element_settings.event, function (event) {
if (!drupalSettings.ajaxTrustedUrl[ajax.url] && !Drupal.url.isLocal(ajax.url)) {
throw new Error(Drupal.t('The callback URL is not local and not trusted: !url', {'!url': ajax.url}));
}
return ajax.eventResponse(this, event);
});
......@@ -760,10 +791,11 @@
/**
* Handler for the form redirection error.
*
* @param {object} response
* @param {object} xmlhttprequest
* @param {string} uri
* @param {string} customMessage
*/
Drupal.Ajax.prototype.error = function (response, uri) {
Drupal.Ajax.prototype.error = function (xmlhttprequest, uri, customMessage) {
// Remove the progress element.
if (this.progress.element) {
$(this.progress.element).remove();
......@@ -777,10 +809,10 @@
$(this.element).prop('disabled', false);
// Reattach behaviors, if they were detached in beforeSerialize().
if (this.$form) {
var settings = response.settings || this.settings || drupalSettings;
var settings = this.settings || drupalSettings;
Drupal.attachBehaviors(this.$form.get(0), settings);
}
throw new Drupal.AjaxError(response, uri);
throw new Drupal.AjaxError(xmlhttprequest, uri, customMessage);
};
/**
......
......@@ -400,6 +400,81 @@ if (window.jQuery) {
return drupalSettings.path.baseUrl + drupalSettings.path.pathPrefix + path;
};
/**
* Returns the passed in URL as an absolute URL.
*
* @param url
* The URL string to be normalized to an absolute URL.
*
* @return
* The normalized, absolute URL.
*
* @see https://github.com/angular/angular.js/blob/v1.4.4/src/ng/urlUtils.js
* @see https://grack.com/blog/2009/11/17/absolutizing-url-in-javascript
* @see https://github.com/jquery/jquery-ui/blob/1.11.4/ui/tabs.js#L53
*/
Drupal.url.toAbsolute = function (url) {
var urlParsingNode = document.createElement('a');
// Decode the URL first; this is required by IE <= 6. Decoding non-UTF-8
// strings may throw an exception.
try {
url = decodeURIComponent(url);
}
catch (e) {
// Empty.
}
urlParsingNode.setAttribute('href', url);
// IE <= 7 normalizes the URL when assigned to the anchor node similar to
// the other browsers.
return urlParsingNode.cloneNode(false).href;
};
/**
* Returns true if the URL is within Drupal's base path.
*
* @param url
* The URL string to be tested.
*
* @return
* Boolean true if local.
*
* @see https://github.com/jquery/jquery-ui/blob/1.11.4/ui/tabs.js#L58
*/
Drupal.url.isLocal = function (url) {
// Always use browser-derived absolute URLs in the comparison, to avoid
// attempts to break out of the base path using directory traversal.
var absoluteUrl = Drupal.url.toAbsolute(url);
var protocol = location.protocol;
// Consider URLs that match this site's base URL but use HTTPS instead of HTTP
// as local as well.
if (protocol === 'http:' && absoluteUrl.indexOf('https:') === 0) {
protocol = 'https:';
}
var baseUrl = protocol + '//' + location.host + drupalSettings.basePath.slice(0, -1);
// Decoding non-UTF-8 strings may throw an exception.
try {
absoluteUrl = decodeURIComponent(absoluteUrl);
}
catch (e) {
// Empty.
}
try {
baseUrl = decodeURIComponent(baseUrl);
}
catch (e) {
// Empty.
}
// The given URL matches the site's base URL, or has a path under the site's
// base URL.
return absoluteUrl === baseUrl || absoluteUrl.indexOf(baseUrl + '/') === 0;
};
/**
* Format a string containing a count of items.
*
......
......@@ -70,6 +70,13 @@
*/
protected $curlHandle;
/**
* Whether or not to assert the presence of the X-Drupal-Ajax-Token.
*
* @var bool
*/
protected $assertAjaxHeader = TRUE;
/**
* The headers of the page currently loaded in the internal browser.
*
......@@ -1896,6 +1903,9 @@ protected function drupalPostAjaxForm($path, $edit, $triggering_element, $ajax_p
// Submit the POST request.
$return = Json::decode($this->drupalPostForm(NULL, $edit, array('path' => $ajax_path, 'triggering_element' => $triggering_element), $options, $headers, $form_html_id, $extra_post));
if ($this->assertAjaxHeader) {
$this->assertIdentical($this->drupalGetHeader('X-Drupal-Ajax-Token'), '1', 'Ajax response header found.');
}
// Change the page content by applying the returned commands.
if (!empty($ajax_settings) && !empty($return)) {
......
......@@ -48,6 +48,9 @@ function testSimpleAjaxFormValue() {
// Verify that AJAX elements with invalid callbacks return error code 500.
// Ensure the test error log is empty before these tests.
$this->assertNoErrorsLogged();
// We don't need to check for the X-Drupal-Ajax-Token header with these
// invalid requests.
$this->assertAjaxHeader = FALSE;
foreach (array('null', 'empty', 'nonexistent') as $key) {
$element_name = 'select_' . $key . '_callback';
$edit = array(
......@@ -56,6 +59,8 @@ function testSimpleAjaxFormValue() {
$commands = $this->drupalPostAjaxForm('ajax_forms_test_get_form', $edit, $element_name);
$this->assertResponse(500);
}
// Switch this back to the default.
$this->assertAjaxHeader = TRUE;
// The exceptions are expected. Do not interpret them as a test failure.
// Not using File API; a potential error must trigger a PHP warning.
unlink(\Drupal::root() . '/' . $this->siteDirectory . '/error.log');
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment