diff --git a/core/core.libraries.yml b/core/core.libraries.yml index e539dc7468c408c05681603b28eaf988fb4f8abd..92b3f439eddd0721f877c26b98320b0adbe0ea6a 100644 --- a/core/core.libraries.yml +++ b/core/core.libraries.yml @@ -87,6 +87,7 @@ drupal.ajax: libraries: null theme: null theme_token: null + ajaxTrustedUrl: {} dependencies: - core/jquery - core/drupal diff --git a/core/lib/Drupal/Core/EventSubscriber/AjaxResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/AjaxResponseSubscriber.php index f35b8526a53eee080afb5e21d5aa2dd5305e064e..c5081df08aefe85ea454258f51ae16892ef0052d 100644 --- a/core/lib/Drupal/Core/EventSubscriber/AjaxResponseSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/AjaxResponseSubscriber.php @@ -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); } } diff --git a/core/lib/Drupal/Core/Form/FormBuilder.php b/core/lib/Drupal/Core/Form/FormBuilder.php index fe6d104ee4e8c3077cd6a29fd8f33a6b19d3f958..725faaa21300c8d99cd74941e86d4fab8b0609d3 100644 --- a/core/lib/Drupal/Core/Form/FormBuilder.php +++ b/core/lib/Drupal/Core/Form/FormBuilder.php @@ -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 diff --git a/core/lib/Drupal/Core/Render/Element/RenderElement.php b/core/lib/Drupal/Core/Render/Element/RenderElement.php index 54463b67ce422b7222fe739ea7c777663bd544be..3a63f91559adbb594d142de65741e7aa60665858 100644 --- a/core/lib/Drupal/Core/Render/Element/RenderElement.php +++ b/core/lib/Drupal/Core/Render/Element/RenderElement.php @@ -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; diff --git a/core/misc/ajax.js b/core/misc/ajax.js index ba10fe89393a78247d64fe88820f20fcf382e81d..4dba53e242d0c69260b7c62201f01cd36a45a00b 100644 --- a/core/misc/ajax.js +++ b/core/misc/ajax.js @@ -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); }; /** diff --git a/core/misc/drupal.js b/core/misc/drupal.js index 4014de689818bd5c12d5d832102b048ac76c080d..133321c96775aef139d31a36046a36396f97d1f8 100644 --- a/core/misc/drupal.js +++ b/core/misc/drupal.js @@ -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. * diff --git a/core/modules/simpletest/src/WebTestBase.php b/core/modules/simpletest/src/WebTestBase.php index b5e05ee9ca27acf6d346e9579742e9afd8bb5e3b..5e86826592695fead8fc5fc9d07d380eb6eb525d 100644 --- a/core/modules/simpletest/src/WebTestBase.php +++ b/core/modules/simpletest/src/WebTestBase.php @@ -70,6 +70,13 @@ abstract class WebTestBase extends TestBase { */ 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)) { diff --git a/core/modules/system/src/Tests/Ajax/FormValuesTest.php b/core/modules/system/src/Tests/Ajax/FormValuesTest.php index c8f164b46ed0178556a073bf1c77ee461a5b4e07..1010eefa58ac070e66215a781414859facc6d0cd 100644 --- a/core/modules/system/src/Tests/Ajax/FormValuesTest.php +++ b/core/modules/system/src/Tests/Ajax/FormValuesTest.php @@ -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');