diff --git a/core/lib/Drupal/Core/EventSubscriber/AjaxResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/AjaxResponseSubscriber.php
index f35b852..c5081df 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 fe6d104..8777347 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']['urlIsAjaxTrusted'][$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 54463b6..f794685 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']['urlIsAjaxTrusted'][$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 ba10fe8..f095043 100644
--- a/core/misc/ajax.js
+++ b/core/misc/ajax.js
@@ -15,6 +15,8 @@
 
   "use strict";
 
+  drupalSettings.urlIsAjaxTrusted = drupalSettings.urlIsAjaxTrusted || {};
+
   /**
    * Attaches the Ajax behavior to each Ajax form element.
    *
@@ -95,8 +97,10 @@ function loadAjaxBehavior(base) {
    *   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 +144,14 @@ function loadAjaxBehavior(base) {
     // 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 +344,13 @@ else if (this.element && element.form) {
     // 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 old_url = 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.urlIsAjaxTrusted[old_url]) {
+      drupalSettings.urlIsAjaxTrusted[this.url] = true;
+    }
 
     // Set the options for the ajaxSubmit function.
     // The 'this' variable will not persist inside of the options object.
@@ -377,18 +389,36 @@ else if (this.element && element.form) {
         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
+        // ajax_set_verification_header() 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.urlIsAjaxTrusted[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 +441,9 @@ else if (this.element && element.form) {
 
     // Bind the ajaxSubmit function to the element event.
     $(ajax.element).on(element_settings.event, function (event) {
+      if (!drupalSettings.urlIsAjaxTrusted[ajax.url] && !Drupal.urlIsLocal(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 +793,11 @@ else if (type === 'fade') {
   /**
    * 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 +811,10 @@ else if (type === 'fade') {
     $(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 4014de6..da65745 100644
--- a/core/misc/drupal.js
+++ b/core/misc/drupal.js
@@ -453,6 +453,72 @@ else if (args['@count'] !== 1) {
   };
 
   /**
+   * 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.absoluteUrl = 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) {}
+
+    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.urlIsLocal = 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.absoluteUrl(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) {}
+    try {
+      baseUrl = decodeURIComponent(baseUrl);
+    } catch (e) {}
+
+    // 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;
+  };
+
+  /**
    * Encodes a Drupal path for use in a URL.
    *
    * For aesthetic reasons slashes are not escaped.
diff --git a/core/modules/simpletest/src/WebTestBase.php b/core/modules/simpletest/src/WebTestBase.php
index cfccbcf..52e0a50 100644
--- a/core/modules/simpletest/src/WebTestBase.php
+++ b/core/modules/simpletest/src/WebTestBase.php
@@ -1875,6 +1875,7 @@ 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));
+    $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)) {
