CVE-2023-37277 (Java, published 2023-07-10T17:15Z)
Description: value: XWiki Platform is a generic wiki platform offering runtime services for applications built on top of it. The REST API allows executing all actions via POST requests and accepts `text/plain`, `multipart/form-data` or `application/www-form-urlencoded` as content types which can be sent via regular HTML forms, thus allowing cross-site request forgery. With the interaction of a user with programming rights, this allows remote code execution through script macros and thus impacts the integrity, availability and confidentiality of the whole XWiki installation. For regular cookie-based authentication, the vulnerability is mitigated by SameSite cookie restrictions but as of March 2023, these are not enabled by default in Firefox and Safari. The vulnerability has been patched in XWiki 14.10.8 and 15.2 by requiring a CSRF token header for certain request types that are susceptible to CSRF attacks.


Severity: nan (CVSS v3 base score: 9.6)
Weakness: CWE-352 \u2014 Cross-Site Request Forgery (CSRF)
The web application does not, or can not, sufficiently verify whether a well-formed, valid, consistent request was intentionally provided by the user who submitted the request.
Repository: https://github.com/xwiki/xwiki-platform
Files changed:
xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/src/test/it/org/xwiki/flamingo/test/docker/AllITs.java
xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/src/test/it/org/xwiki/flamingo/test/docker/FormTokenInjectionIT.java
xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/src/test/resources/FormTokenInjectionIT/testCode.js
xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-server/src/main/java/org/xwiki/rest/internal/XWikiFilter.java
xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/AttachmentsResourceIT.java
xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/CommentsResourceIT.java
xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/ObjectsResourceIT.java
xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/PageResourceIT.java
xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/framework/AbstractHttpIT.java
xwiki-platform-core/xwiki-platform-web/xwiki-platform-web-war/src/main/webapp/resources/js/xwiki/xwiki.js
Commit message: XWIKI-20135: Require a CSRF token for some request types in the REST API

* Require a CSRF token in the XWiki-Form-Token header in content types
  allowed in simple requests.
* Add integration tests to check that the check is indeed working.
* Automatically add the CSRF token header in same-origin requests
  initiated from JavaScript.
* Add an integration test to check that the form token is correctly
  added and fetch requests are still working.

Co-authored-by: Marius Dumitru Florea <marius@xwiki.com>


Vulnerable code:
        return CONTINUE;
   * Overwrite the XMLHttpRequest#open() method in order to add our load listener on all requests made from this page.
      return originalOpen.apply(this, arguments);
   * Overwrite the fetch function in order to add our own response callback on all fetch requests made from this page.
        return originalFetch.apply(this, arguments).then(function(response) {

Fixed code:

    @Nested
    @DisplayName("Form Token injection Tests")
    class NestedFormTokenInjectionIT extends FormTokenInjectionIT
    {
    }
/*
 * See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */
package org.xwiki.flamingo.test.docker;

import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.stream.Stream;

import org.apache.commons.io.IOUtils;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.xwiki.test.docker.junit5.TestReference;
import org.xwiki.test.docker.junit5.UITest;
import org.xwiki.test.ui.TestUtils;
import org.xwiki.test.ui.po.ViewPage;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.junit.jupiter.api.Assertions.assertAll;

/**
 * Integration test to verify that the form token is correctly injected in JavaScript requests.
 *
 * @version $Id$
 */
@UITest
class FormTokenInjectionIT
{
    @Test
    @Order(1)
    void simpleRESTPost(TestUtils setup, TestReference reference) throws Exception
    {
        setup.loginAsSuperAdmin();
        setup.deletePage(reference);

        String content = "{{html clean=\"false\"}}"
            + "<div id='results'></div>"
            + "<script>"
            + IOUtils.toString(
            Objects.requireNonNull(getClass().getResourceAsStream("/FormTokenInjectionIT/testCode.js")),
            StandardCharsets.UTF_8)
            + "</script>"
            + "{{/html}}";
        setup.createPage(reference, content);

        ViewPage viewPage = setup.gotoPage(reference);
        String pageContent = viewPage.getContent();

        assertAll(
            Stream.of(
                "Simple POST: 201",
                "Only Request: 201",
                "Request with init: 201",
                "Simple with array headers: 201",
                "Request with init body",
                "Request Body",
                "Simple with array headers body"
            ).map(expected -> (() -> assertThat(pageContent, containsString(expected))))
        );
    }
}
/*
 * See the NOTICE file distributed with this work for additional
 * information regarding copyright ownership.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */
require(['xwiki-meta'], function (xm) {
  const postURL = xm.restURL + '/comments';
  const resultDiv = document.getElementById('results');

  async function addComments() {
    // Wait after each request as the REST API is not thread-safe (object ids aren't properly incremented and integrity
    // constraints are violated, see also https://jira.xwiki.org/browse/XWIKI-13473).
    await fetch(postURL, {
      method: "POST",
      body: "Simple POST"
    }).then(response => resultDiv.textContent += 'Simple POST: ' + response.status);

    await fetch(new Request(postURL, {
      method: "POST",
      body: "Request Body",
      headers: {"Accept": "application/json"}
    })).then(async response => {
      resultDiv.textContent += 'Only Request: ' + response.status;
      return response.json();
    }).then(comment => resultDiv.textContent += comment.text);

    await fetch(new Request(postURL), {
      method: "POST",
      body: "Request with init body",
      headers: {"Accept": "application/json"}
    }).then(response => {
      resultDiv.textContent += 'Request with init: ' + response.status;
      return response.json();
    }).then(comment => resultDiv.textContent += comment.text);

    await fetch(postURL, {
      method: "POST",
      body: "Simple with array headers body",
      headers: [["Accept", "application/json"]]
    }).then(response => {
      resultDiv.textContent += 'Simple with array headers: ' + response.status;
      return response.json();
    }).then(comment => resultDiv.textContent += comment.text);
  }

  addComments();
});
import java.util.List;

import javax.servlet.http.HttpServletRequest;

import org.apache.commons.lang3.StringUtils;
import org.restlet.data.MediaType;
import org.restlet.data.Status;
import org.restlet.ext.servlet.ServletUtils;
import org.xwiki.csrf.CSRFToken;
    /**
     * Content-types that are allowed in a
     * <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests">simple request</a> that don't
     * trigger a CORS preflight request in browsers.
     */
    private static final List<String> SIMPLE_CONTENT_TYPES = List.of(
        "application/x-www-form-urlencoded", "multipart/form-data", "text/plain"
    );

    private static final String FORM_TOKEN_HEADER = "XWiki-Form-Token";

        CSRFToken csrfToken = null;

        try {
            csrfToken = componentManager.getInstance(CSRFToken.class);
        } catch (ComponentLookupException e) {
            getLogger().warning("Failed to lookup CSRF token validator: " + ExceptionUtils.getRootCauseMessage(e));
        }

            if (csrfToken != null) {
                responseHeaders.add(FORM_TOKEN_HEADER, csrfToken.getToken());
            }
        int result = CONTINUE;

        HttpServletRequest servletRequest = ServletUtils.getRequest(Request.getCurrent());

        // Require a CSRF token for requests that browsers allow through HTML forms and across origins.
        // See https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS for more information.
        // Compare to the method from the servlet request to avoid the automatic conversion from POST to PUT request.
        // Check for a prefix match to make sure it matches regardless of the supplied parameters (like charset).
        if ("POST".equals(servletRequest.getMethod()) && SIMPLE_CONTENT_TYPES.stream().anyMatch(expectedType ->
            StringUtils.startsWith(StringUtils.lowerCase(servletRequest.getContentType()), expectedType)))
        {
            Series<Header> requestHeaders = request.getHeaders();
            String formToken = requestHeaders.getFirstValue(FORM_TOKEN_HEADER);

            // Skip the main request handler but allow cleanup if either the CSRF validator failed or the token is
            // invalid.
            if (csrfToken == null) {
                response.setStatus(Status.SERVER_ERROR_INTERNAL);
                response.setEntity("Failed to lookup the CSRF token validator.", MediaType.TEXT_PLAIN);
                result = SKIP;
            } else if (!csrfToken.isTokenValid(formToken)) {
                response.setStatus(Status.CLIENT_ERROR_FORBIDDEN);
                response.setEntity("Invalid or missing form token.", MediaType.TEXT_PLAIN);
                result = SKIP;
            }
        }

        return result;
        postMethod.setRequestHeader("XWiki-Form-Token", getFormToken(TestUtils.SUPER_ADMIN_CREDENTIALS.getUserName(),
            TestUtils.SUPER_ADMIN_CREDENTIALS.getPassword()));
    @Test
    public void testPOSTCommentWithTextPlainNoCSRF() throws Exception
    {
        String commentsUri = buildURI(CommentsResource.class, getWiki(), this.spaces, this.pageName).toString();

        GetMethod getMethod = executeGet(commentsUri);
        Assert.assertEquals(getHttpMethodInfo(getMethod), HttpStatus.SC_OK, getMethod.getStatusCode());

        Comments comments = (Comments) unmarshaller.unmarshal(getMethod.getResponseBodyAsStream());

        int numberOfComments = comments.getComments().size();

        PostMethod postMethod = executePost(commentsUri, "Comment", MediaType.TEXT_PLAIN,
            TestUtils.SUPER_ADMIN_CREDENTIALS.getUserName(), TestUtils.SUPER_ADMIN_CREDENTIALS.getPassword(), null);
        Assert.assertEquals(getHttpMethodInfo(postMethod), HttpStatus.SC_FORBIDDEN, postMethod.getStatusCode());
        Assert.assertEquals("Invalid or missing form token.", postMethod.getResponseBodyAsString());

        getMethod = executeGet(commentsUri);
        Assert.assertEquals(getHttpMethodInfo(getMethod), HttpStatus.SC_OK, getMethod.getStatusCode());

        comments = (Comments) unmarshaller.unmarshal(getMethod.getResponseBodyAsStream());

        Assert.assertEquals(numberOfComments, comments.getComments().size());
    }

    @Test
    public void testPOSTObjectFormUrlEncodedNoCSRF() throws Exception
    {
        final String tagValue = "TAG";
        NameValuePair[] nameValuePairs = new NameValuePair[2];
        String className = "XWiki.TagClass";
        nameValuePairs[0] = new NameValuePair("className", className);
        nameValuePairs[1] = new NameValuePair("property#tags", tagValue);

        String objectGetURI = buildURI(ObjectsResource.class, getWiki(), this.spaces, this.pageName, className);

        // Count objects before to ensure nothing is added on the failed request.
        GetMethod getMethod = executeGet(objectGetURI);
        Assert.assertEquals(getHttpMethodInfo(getMethod), HttpStatus.SC_OK, getMethod.getStatusCode());
        Objects objects = (Objects) unmarshaller.unmarshal(getMethod.getResponseBodyAsStream());
        int numObjects = objects.getObjectSummaries().size();

        PostMethod postMethod = executePostForm(
            buildURI(ObjectsResource.class, getWiki(), this.spaces, this.pageName), nameValuePairs,
            TestUtils.SUPER_ADMIN_CREDENTIALS.getUserName(), TestUtils.SUPER_ADMIN_CREDENTIALS.getPassword(), null);
        Assert.assertEquals(getHttpMethodInfo(postMethod), HttpStatus.SC_FORBIDDEN, postMethod.getStatusCode());
        Assert.assertEquals("Invalid or missing form token.", postMethod.getResponseBodyAsString());

        getMethod = executeGet(objectGetURI);
        Assert.assertEquals(getHttpMethodInfo(getMethod), HttpStatus.SC_OK, getMethod.getStatusCode());

        objects = (Objects) unmarshaller.unmarshal(getMethod.getResponseBodyAsStream());
        Assert.assertEquals(numObjects, objects.getObjectSummaries().size());
    }


    @Test
    public void testPOSTPageFormUrlEncodedNoCSRF() throws Exception
    {
        final String CONTENT = String.format("This is a content (%d)", System.currentTimeMillis());
        final String TITLE = String.format("Title (%s)", UUID.randomUUID().toString());

        Page originalPage = getFirstPage();

        Link link = getFirstLinkByRelation(originalPage, Relations.SELF);
        Assert.assertNotNull(link);

        NameValuePair[] nameValuePairs = new NameValuePair[2];
        nameValuePairs[0] = new NameValuePair("title", TITLE);
        nameValuePairs[1] = new NameValuePair("content", CONTENT);

        PostMethod postMethod = executePostForm(String.format("%s?method=PUT", link.getHref()), nameValuePairs,
            TestUtils.SUPER_ADMIN_CREDENTIALS.getUserName(), TestUtils.SUPER_ADMIN_CREDENTIALS.getPassword(), null);
        Assert.assertEquals(getHttpMethodInfo(postMethod), HttpStatus.SC_FORBIDDEN, postMethod.getStatusCode());
        Assert.assertEquals("Invalid or missing form token.", postMethod.getResponseBodyAsString());

        // Assert that the page hasn't been modified.
        GetMethod getMethod = executeGet(link.getHref());
        Assert.assertEquals(getHttpMethodInfo(getMethod), HttpStatus.SC_OK, getMethod.getStatusCode());

        Page modifiedPage = (Page) this.unmarshaller.unmarshal(getMethod.getResponseBodyAsStream());

        Assert.assertEquals(originalPage.getContent(), modifiedPage.getContent());
        Assert.assertEquals(originalPage.getTitle(), modifiedPage.getTitle());
    }

    protected String getFormToken(String userName, String password) throws Exception
    {
        GetMethod getMethod = executeGet(getFullUri(WikisResource.class), userName, password);
        Assert.assertEquals(getHttpMethodInfo(getMethod), HttpStatus.SC_OK, getMethod.getStatusCode());
        return getMethod.getResponseHeader("XWiki-Form-Token").getValue();
    }

    {
        return executePost(uri, string, mediaType, userName, password, getFormToken(userName, password));
    }

    protected PostMethod executePost(String uri, String string, String mediaType, String userName, String password,
        String formToken) throws Exception
        if (formToken != null) {
            postMethod.addRequestHeader("XWiki-Form-Token", formToken);
        }
    {
        return executePostForm(uri, nameValuePairs, userName, password, getFormToken(userName, password));
    }

    protected PostMethod executePostForm(String uri, NameValuePair[] nameValuePairs, String userName, String password,
        String formToken) throws Exception
        if (formToken != null) {
            postMethod.addRequestHeader("XWiki-Form-Token", formToken);
        }
   * Overwrite the XMLHttpRequest#open() method in order to inject the form token and to add our load listener on all
   * requests made from this page.
      const result = originalOpen.apply(this, arguments);
      // Send the form token on same-origin requests only to prevent leaking the token to third-parties.
      if (arguments.length >= 2 && window.location.origin === (new URL(arguments[1], window.location.href)).origin) {
        // Make sure this is really safe in case this should be called in some unexpected situation.
        const formToken = document?.documentElement?.dataset?.xwikiFormToken;
        if (formToken) {
          this.setRequestHeader("XWiki-Form-Token", formToken);
        }
      }
      return result;
   * Overwrite the fetch function in order to inject the form token and add our own response callback on all fetch
   * requests made from this page.
        // Inject the form token.
        let modifiedArguments = arguments;
        // Make sure this is really safe in case this should be called in some unexpected situation.
        const formToken = document?.documentElement?.dataset?.xwikiFormToken;
        if (formToken) {
          let request = null;
          // Convert the arguments to a request, as Request expects the same arguments as fetch() but provides
          // convenient ways to modify the headers (and fetch accepts a request as parameter).
          if (arguments.length === 1 && arguments[0] instanceof Request) {
            request = arguments[0];
          } else if (arguments.length) {
            request = new Request(...arguments);
          }
          // Only handle expected cases and same-origin requests to prevent leaking the token to third-parties,
          // leave the arguments alone otherwise.
          if (request !== null && window.location.origin === (new URL(request.url, window.location.href).origin)) {
            request.headers.append("XWiki-Form-Token", formToken);
            modifiedArguments = [request];
          }
        }
        return originalFetch.apply(this, modifiedArguments).then(function(response) {