CVE-2023-37277: Cross-Site Request Forgery (CSRF) (CWE-352)
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.


CWE description: 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.
CVSS v3 base score: 9.6
Published: 2023-07-10T17:15Z
Repository: https://github.com/xwiki/xwiki-platform
Language: Java
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 date: 2023-03-22 12:08:06 +0100
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>

Diff: diff --git a/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 b/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
index 1abe35589a166..2601f66328025 100644
--- a/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
+++ b/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
@@ -186,6 +186,12 @@ class NestedSheetIT extends SheetIT
     @Nested
     @DisplayName("Script author Tests")
     class NestedScriptAuthorIT extends ScriptAuthorIT
     {
     }
+
+    @Nested
+    @DisplayName("Form Token injection Tests")
+    class NestedFormTokenInjectionIT extends FormTokenInjectionIT
+    {
+    }
 }
diff --git a/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 b/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
new file mode 100644
index 0000000000000..f566b84c96403
--- /dev/null
+++ b/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
@@ -0,0 +1,78 @@
+/*
+ * 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))))
+        );
+    }
+}
diff --git a/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 b/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
new file mode 100644
index 0000000000000..30d65755d870d
--- /dev/null
+++ b/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
@@ -0,0 +1,61 @@
+/*
+ * 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();
+});
\ No newline at end of file
diff --git a/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-server/src/main/java/org/xwiki/rest/internal/XWikiFilter.java b/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-server/src/main/java/org/xwiki/rest/internal/XWikiFilter.java
index 51fd7baba398d..f55f309d2bb12 100644
--- a/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-server/src/main/java/org/xwiki/rest/internal/XWikiFilter.java
+++ b/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-server/src/main/java/org/xwiki/rest/internal/XWikiFilter.java
@@ -17,20 +17,29 @@
  * 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.rest.internal;
 
+import java.util.List;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.exception.ExceptionUtils;
 import org.restlet.Context;
 import org.restlet.Request;
 import org.restlet.Response;
 import org.restlet.data.Header;
+import org.restlet.data.MediaType;
+import org.restlet.data.Status;
 import org.restlet.engine.header.HeaderConstants;
+import org.restlet.ext.servlet.ServletUtils;
 import org.restlet.routing.Filter;
 import org.restlet.util.Series;
 import org.xwiki.component.manager.ComponentLookupException;
 import org.xwiki.component.manager.ComponentManager;
+import org.xwiki.csrf.CSRFToken;
 import org.xwiki.model.reference.EntityReferenceSerializer;
 
 import com.xpn.xwiki.XWikiContext;
 
 /**
@@ -40,10 +49,21 @@
  * @version $Id$
  * @since 13.4RC1
  */
 public class XWikiFilter extends Filter
 {
+    /**
+     * 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";
+
     /**
      * Constructor.
      * 
      * @param context The context.
      */
@@ -56,10 +76,17 @@ public XWikiFilter(Context context) throws IllegalArgumentException
     protected int beforeHandle(Request request, Response response)
     {
         ComponentManager componentManager =
             (ComponentManager) getContext().getAttributes().get(Constants.XWIKI_COMPONENT_MANAGER);
         XWikiContext xwikiContext = Utils.getXWikiContext(componentManager);
+        CSRFToken csrfToken = null;
+
+        try {
+            csrfToken = componentManager.getInstance(CSRFToken.class);
+        } catch (ComponentLookupException e) {
+            getLogger().warning("Failed to lookup CSRF token validator: " + ExceptionUtils.getRootCauseMessage(e));
+        }
 
         try {
             EntityReferenceSerializer<String> serializer =
                 componentManager.getInstance(EntityReferenceSerializer.TYPE_STRING);
 
@@ -73,13 +100,44 @@ protected int beforeHandle(Request request, Response response)
                 responseHeaders = new Series<>(Header.class);
                 response.getAttributes().put(HeaderConstants.ATTRIBUTE_HEADERS, responseHeaders);
             }
             responseHeaders.add("XWiki-User", serializer.serialize(xwikiContext.getUserReference()));
             responseHeaders.add("XWiki-Version", xwikiContext.getWiki().getVersion());
+
+            if (csrfToken != null) {
+                responseHeaders.add(FORM_TOKEN_HEADER, csrfToken.getToken());
+            }
         } catch (ComponentLookupException e) {
             getLogger()
                 .warning("Failed to lookup the entity reference serializer: " + ExceptionUtils.getRootCauseMessage(e));
         }
 
-        return CONTINUE;
+        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;
     }
 }
diff --git a/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/AttachmentsResourceIT.java b/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/AttachmentsResourceIT.java
index 18c81dc676c44..2ccbc142fc852 100644
--- a/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/AttachmentsResourceIT.java
+++ b/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/AttachmentsResourceIT.java
@@ -317,10 +317,12 @@ public void testPOSTAttachment() throws Exception
         parts[0] = new FilePart(attachmentName, baps);
 
         PostMethod postMethod = new PostMethod(attachmentsUri);
         MultipartRequestEntity mpre = new MultipartRequestEntity(parts, postMethod.getParams());
         postMethod.setRequestEntity(mpre);
+        postMethod.setRequestHeader("XWiki-Form-Token", getFormToken(TestUtils.SUPER_ADMIN_CREDENTIALS.getUserName(),
+            TestUtils.SUPER_ADMIN_CREDENTIALS.getPassword()));
         httpClient.executeMethod(postMethod);
         Assert.assertEquals(getHttpMethodInfo(postMethod), HttpStatus.SC_CREATED, postMethod.getStatusCode());
 
         this.unmarshaller.unmarshal(postMethod.getResponseBodyAsStream());
 
diff --git a/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/CommentsResourceIT.java b/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/CommentsResourceIT.java
index 9052a146e2f5f..dc372a4908aa5 100644
--- a/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/CommentsResourceIT.java
+++ b/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/CommentsResourceIT.java
@@ -126,10 +126,35 @@ public void testPOSTCommentWithTextPlain() throws Exception
         comments = (Comments) unmarshaller.unmarshal(getMethod.getResponseBodyAsStream());
 
         Assert.assertEquals(numberOfComments + 1, comments.getComments().size());
     }
 
+    @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 testGETComment() throws Exception
     {
         String commentsUri = buildURI(CommentsResource.class, getWiki(), this.spaces, this.pageName).toString();
 
diff --git a/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/ObjectsResourceIT.java b/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/ObjectsResourceIT.java
index 186a164f61eb4..694803efb459b 100644
--- a/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/ObjectsResourceIT.java
+++ b/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/ObjectsResourceIT.java
@@ -518,10 +518,41 @@ public void testPOSTObjectFormUrlEncoded() throws Exception
         object = (Object) unmarshaller.unmarshal(getMethod.getResponseBodyAsStream());
 
         Assert.assertEquals(TAG_VALUE, getProperty(object, "tags").getValue());
     }
 
+    @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 testPUTPropertyFormUrlEncoded() throws Exception
     {
         final String TAG_VALUE = UUID.randomUUID().toString();
 
diff --git a/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/PageResourceIT.java b/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/PageResourceIT.java
index 7d5e0aedaa442..845afb28ece4f 100644
--- a/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/PageResourceIT.java
+++ b/xwiki-platform-core/xwiki-platform-rest/xwiki-platform-rest-test/xwiki-platform-rest-test-tests/src/test/it/org/xwiki/rest/test/PageResourceIT.java
@@ -574,10 +574,40 @@ public void testPOSTPageFormUrlEncoded() throws Exception
 
         Assert.assertEquals(CONTENT, modifiedPage.getContent());
         Assert.assertEquals(TITLE, modifiedPage.getTitle());
     }
 
+    @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());
+    }
+
     @Test
     public void testPUTPageSyntax() throws Exception
     {
         Page originalPage = getFirstPage();
 
diff --git a/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 b/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
index 03552bff82a38..9d21bdfd52c66 100644
--- a/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
+++ b/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
@@ -279,19 +279,35 @@ protected PostMethod executePost(String uri, InputStream is, String userName, St
         httpClient.executeMethod(postMethod);
 
         return postMethod;
     }
 
+    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();
+    }
+
     protected PostMethod executePost(String uri, String string, String mediaType, String userName, String password)
         throws Exception
+    {
+        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
     {
         HttpClient httpClient = new HttpClient();
         httpClient.getState().setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(userName, password));
         httpClient.getParams().setAuthenticationPreemptive(true);
 
         PostMethod postMethod = new PostMethod(uri);
         postMethod.addRequestHeader("Accept", MediaType.APPLICATION_XML.toString());
+        if (formToken != null) {
+            postMethod.addRequestHeader("XWiki-Form-Token", formToken);
+        }
 
         RequestEntity entity = new StringRequestEntity(string, mediaType, "UTF-8");
         postMethod.setRequestEntity(entity);
 
         httpClient.executeMethod(postMethod);
@@ -299,18 +315,27 @@ protected PostMethod executePost(String uri, String string, String mediaType, St
         return postMethod;
     }
 
     protected PostMethod executePostForm(String uri, NameValuePair[] nameValuePairs, String userName, String password)
         throws Exception
+    {
+        return executePostForm(uri, nameValuePairs, userName, password, getFormToken(userName, password));
+    }
+
+    protected PostMethod executePostForm(String uri, NameValuePair[] nameValuePairs, String userName, String password,
+        String formToken) throws Exception
     {
         HttpClient httpClient = new HttpClient();
         httpClient.getState().setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(userName, password));
         httpClient.getParams().setAuthenticationPreemptive(true);
 
         PostMethod postMethod = new PostMethod(uri);
         postMethod.addRequestHeader("Accept", MediaType.APPLICATION_XML.toString());
         postMethod.addRequestHeader("Content-type", MediaType.APPLICATION_WWW_FORM.toString());
+        if (formToken != null) {
+            postMethod.addRequestHeader("XWiki-Form-Token", formToken);
+        }
 
         postMethod.setRequestBody(nameValuePairs);
 
         httpClient.executeMethod(postMethod);
 
diff --git a/xwiki-platform-core/xwiki-platform-web/xwiki-platform-web-war/src/main/webapp/resources/js/xwiki/xwiki.js b/xwiki-platform-core/xwiki-platform-web/xwiki-platform-web-war/src/main/webapp/resources/js/xwiki/xwiki.js
index b87075e5a88f8..8840d2e61e63a 100644
--- a/xwiki-platform-core/xwiki-platform-web/xwiki-platform-web-war/src/main/webapp/resources/js/xwiki/xwiki.js
+++ b/xwiki-platform-core/xwiki-platform-web/xwiki-platform-web-war/src/main/webapp/resources/js/xwiki/xwiki.js
@@ -1880,30 +1880,61 @@ document.observe("xwiki:dom:loaded", function() {
       extendPageHead(getHeader('X-XWIKI-HTML-SCRIPTS'));
     }, 0);
   };
 
   /**
-   * Overwrite the XMLHttpRequest#open() method in order to add our load listener on all requests made from this page.
+   * 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.
    */
   var interceptXMLHttpRequest = function() {
     var originalOpen = window.XMLHttpRequest.prototype.open;
     window.XMLHttpRequest.prototype.open = function() {
       this.addEventListener('load', function() {
         handleResponseHeaders(this.getResponseHeader.bind(this));
       });
-      return originalOpen.apply(this, arguments);
+      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 add our own response callback on all fetch requests made from this page.
+   * 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.
    */
   var interceptFetch = function() {
     var originalFetch = window.fetch;
     if (originalFetch) {
       window.fetch = function() {
-        return originalFetch.apply(this, arguments).then(function(response) {
+        // 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) {
           handleResponseHeaders(response.headers.get.bind(response.headers));
           return response;
         });
       };
     }

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) {