CVE-2024-31985 (XML, published 2024-04-10T21:15Z)
Description: value: XWiki Platform is a generic wiki platform. Starting in version 3.1 and prior to versions 4.10.20, 15.5.4, and 15.10-rc-1, it is possible to schedule/trigger/unschedule existing jobs by having an admin visit the Job Scheduler page through a predictable URL, for example by embedding such an URL in any content as an image. The vulnerability has been fixed in XWiki 14.10.19, 15.5.5, and 15.9. As a workaround, manually apply the patch by modifying the `Scheduler.WebHome` page.
Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/xwiki/xwiki-platform
Files changed:
xwiki-platform-core/xwiki-platform-oldcore/src/main/resources/ApplicationResources.properties
xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-test/xwiki-platform-scheduler-test-docker/src/test/it/org/xwiki/scheduler/test/ui/SchedulerIT.java
xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-ui/pom.xml
xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-ui/src/main/resources/Scheduler/Translations.de.xml
xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-ui/src/main/resources/Scheduler/Translations.ru.xml
xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-ui/src/main/resources/Scheduler/Translations.uk.xml
xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-ui/src/main/resources/Scheduler/WebHome.xml
xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-ui/src/test/java/org/xwiki/scheduler/ui/SchedulerPageTest.java
Commit message: XWIKI-20851: Validate CSRF token before running job scheduling actions

* Add a mandatory CSRF token for job management
* Add a page test for Scheduler.WebHome that checks for the proper use of the CSRF token
* Refactor scheduling actions URL generation
* Refactor Scheduler.WebHome to improve escaping


Vulnerable code:
        setup.deletePage("Scheduler", "SchedulerTestJob");
        schedulerHomePage.setJobName("SchedulerTestJob");
        setup.gotoPage("Scheduler", "SchedulerTestJob", "view", "spaceRedirect=false");
  </xwikidoc>
  </xwikidoc>
  </xwikidoc>
<xwikidoc version="1.1">
  #if ($request.do == 'schedule')
  #set ($actions = {})
  #set ($ok = $!actions.put('trigger', $doc.getURL('view', "do=trigger&amp;which=${jobHolder.fullName}")))
    #set ($ok = $!actions.put('schedule', $doc.getURL('view', "do=schedule&amp;which=${jobHolder.fullName}")))
    #set ($ok = $!actions.put('pause', $doc.getURL('view', "do=pause&amp;which=${jobHolder.fullName}")))
    #set ($ok = $!actions.put('unschedule', $doc.getURL('view', "do=unschedule&amp;which=${jobHolder.fullName}")))
    #set ($ok = $!actions.put('resume', $doc.getURL('view', "do=resume&amp;which=${jobHolder.fullName}")))
    #set ($ok = $!actions.put('unschedule', $doc.getURL('view', "do=unschedule&amp;which=${jobHolder.fullName}")))
  #set ($ok = $!actions.put('delete', $doc.getURL('view', "do=delete&amp;which=${jobHolder.fullName}")))
|$job.get('jobName')|$status|$firetime|**$services.localization.render('xe.scheduler.jobs.actions.access')** [[$services.localization.render('xe.scheduler.jobs.actions.view')&gt;&gt;$jobHolder.fullName]]#if($jobHolder.hasAccessLevel('programming')) [[$services.localization.render('xe.scheduler.jobs.actions.edit')&gt;&gt;path:${jobHolder.getURL('edit')}]]#end **$services.localization.render('xe.scheduler.jobs.actions.manage')**#foreach($action in $actions.entrySet()) [[$services.localization.render("xe.scheduler.jobs.actions.${action.key}")&gt;&gt;path:${action.value}]]#end

Fixed code:
xe.scheduler.invalidToken=Invalid token, please try again by clicking on the desired action below.
        setup.deletePage("Scheduler", "Scheduler]]TestJob");
        schedulerHomePage.setJobName("Scheduler]]TestJob");
        setup.gotoPage("Scheduler", "Scheduler]]TestJob", "view", "spaceRedirect=false");
    <!-- Test dependencies. -->
    <dependency>
      <groupId>org.xwiki.platform</groupId>
      <artifactId>xwiki-platform-test-page</artifactId>
      <version>${project.version}</version>
      <scope>test</scope>
    </dependency>
    <!-- Provides the component list for RenderingScriptService. -->
    <dependency>
      <groupId>org.xwiki.platform</groupId>
      <artifactId>xwiki-platform-rendering-xwiki</artifactId>
      <version>${project.version}</version>
      <type>test-jar</type>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.xwiki.platform</groupId>
      <artifactId>xwiki-platform-rendering-configuration-default</artifactId>
      <version>${project.version}</version>
      <type>test-jar</type>
      <scope>test</scope>
    </dependency>
</xwikidoc>
</xwikidoc>
</xwikidoc>
<xwikidoc version="1.5" reference="Scheduler.WebHome" locale="">
  #if (!$services.csrf.isTokenValid($request.form_token))
     ##
     ## Check that the CSRF token matches the user before any operation
     ##
     {{error}}$services.localization.render('xe.scheduler.invalidToken'){{/error}}

  #elseif ($request.do == 'schedule')
  #set ($actions = ['trigger'])
    #set ($ok = $actions.add('schedule'))
    #set ($ok = $actions.addAll(['pause', 'unschedule']))
    #set ($ok = $actions.addAll(['resume', 'unschedule']))
  #set ($ok = $actions.add('delete'))
|$job.get('jobName')|$status|$firetime|**$services.localization.render('xe.scheduler.jobs.actions.access')** [[$services.localization.render('xe.scheduler.jobs.actions.view')&gt;&gt;$services.rendering.escape($jobHolder.fullName, 'xwiki/2.1')]]#if($jobHolder.hasAccessLevel('programming')) [[$services.localization.render('xe.scheduler.jobs.actions.edit')&gt;&gt;path:${jobHolder.getURL('edit')}]]#end **$services.localization.render('xe.scheduler.jobs.actions.manage')**#foreach($action in $actions) [[$services.localization.render("xe.scheduler.jobs.actions.$action")&gt;&gt;path:$doc.getURL('view', $escapetool.url({'do': $action, 'which': $jobHolder.fullName, 'form_token': $services.csrf.token}))]]#end
/*
 * 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.scheduler.ui;

import java.util.List;
import java.util.stream.Stream;

import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock;
import org.quartz.Trigger;
import org.xwiki.csrf.script.CSRFTokenScriptService;
import org.xwiki.model.reference.DocumentReference;
import org.xwiki.query.internal.ScriptQuery;
import org.xwiki.query.script.QueryManagerScriptService;
import org.xwiki.rendering.RenderingScriptServiceComponentList;
import org.xwiki.rendering.internal.configuration.DefaultRenderingConfigurationComponentList;
import org.xwiki.rendering.internal.macro.message.ErrorMessageMacro;
import org.xwiki.rendering.internal.macro.message.InfoMessageMacro;
import org.xwiki.rendering.internal.macro.message.WarningMessageMacro;
import org.xwiki.script.service.ScriptService;
import org.xwiki.test.annotation.ComponentList;
import org.xwiki.test.page.HTML50ComponentList;
import org.xwiki.test.page.PageTest;
import org.xwiki.test.page.TestNoScriptMacro;
import org.xwiki.test.page.XWikiSyntax21ComponentList;

import com.xpn.xwiki.XWikiContext;
import com.xpn.xwiki.api.Object;
import com.xpn.xwiki.doc.XWikiDocument;
import com.xpn.xwiki.objects.BaseObject;
import com.xpn.xwiki.plugin.scheduler.JobState;
import com.xpn.xwiki.plugin.scheduler.SchedulerPluginApi;
import com.xpn.xwiki.plugin.scheduler.internal.SchedulerJobClassDocumentInitializer;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
 * Page tests for {@code Scheduler.WebHome}.
 *
 * @version $Id$
 */
@ComponentList({
    InfoMessageMacro.class,
    ErrorMessageMacro.class,
    SchedulerJobClassDocumentInitializer.class,
    TestNoScriptMacro.class,
    WarningMessageMacro.class
})
@RenderingScriptServiceComponentList
@DefaultRenderingConfigurationComponentList
@HTML50ComponentList
@XWikiSyntax21ComponentList
class SchedulerPageTest extends PageTest
{
    private static final String WIKI_NAME = "xwiki";

    private static final String XWIKI_SPACE = "Scheduler";

    private static final DocumentReference SCHEDULER_WEB_HOME =
        new DocumentReference(WIKI_NAME, XWIKI_SPACE, "WebHome");

    private static final String CSRF_TOKEN = "a0a0a0a0";

    private QueryManagerScriptService queryService;

    private CSRFTokenScriptService tokenService;

    private SchedulerPluginApi schedulerPluginApi;

    @Mock
    private ScriptQuery query;

    private Object testJobObjectApi;

    @BeforeEach
    void setUp() throws Exception
    {
        // Mock the Query Service to return a job.
        this.queryService = this.oldcore.getMocker().registerMockComponent(ScriptService.class, "query",
            QueryManagerScriptService.class,
            true);
        when(this.queryService.xwql(anyString())).thenReturn(this.query);
        when(this.query.execute()).thenReturn(List.of("Scheduler.TestJob"));

        // Mock the Token Service to get a consistent CSRF token throughout the tests.
        this.tokenService = this.oldcore.getMocker().registerMockComponent(ScriptService.class, "csrf",
            CSRFTokenScriptService.class, true);
        when(this.tokenService.getToken()).thenReturn(CSRF_TOKEN);
        when(this.tokenService.isTokenValid(CSRF_TOKEN)).thenReturn(true);

        // Spy the Scheduler Plugin to obtain a mocked API.
        this.schedulerPluginApi = mock(SchedulerPluginApi.class);
        doReturn(this.schedulerPluginApi).when(this.oldcore.getSpyXWiki()).getPluginApi(eq("scheduler"),
            any(XWikiContext.class));

        this.xwiki.initializeMandatoryDocuments(this.context);

        // Create a new job and keep a reference to its API.
        XWikiDocument testJob = new XWikiDocument(new DocumentReference("xwiki", "Scheduler", "TestJob"));
        BaseObject testJobObject = testJob.newXObject(SchedulerJobClassDocumentInitializer.XWIKI_JOB_CLASSREFERENCE,
            this.context);
        this.xwiki.saveDocument(testJob, this.context);
        this.testJobObjectApi = new Object(testJobObject, this.context);

        // Fake programming access level to display the complete page.
        when(this.oldcore.getMockRightService().hasAccessLevel(eq("programming"), anyString(), anyString(),
            any(XWikiContext.class))).thenReturn(true);
    }

    /**
     * Verify that the trigger operation is not called in the Scheduler Plugin API when the CSRF token is invalid, and
     * that the corresponding error message is properly displayed.
     */
    @Test
    void checkInvalidCSRFToken() throws Exception
    {
        String wrongToken = "wrong token";

        this.request.put("do", "trigger");
        this.request.put("which", "Scheduler.TestJob");
        this.request.put("form_token", wrongToken);
        Document result = renderHTMLPage(SCHEDULER_WEB_HOME);

        verify(this.schedulerPluginApi, never()).triggerJob(any(Object.class));
        verify(this.tokenService).isTokenValid(wrongToken);
        assertEquals("xe.scheduler.invalidToken", result.getElementsByClass("errormessage").text());
    }

    /**
     * Verify that the trigger operation is correctly called in the Scheduler Plugin API when the CSRF token is valid,
     * and that no error displays.
     */
    @Test
    void checkValidCSRFToken() throws Exception
    {
        when(this.schedulerPluginApi.triggerJob(this.testJobObjectApi)).thenReturn(true);

        this.request.put("do", "trigger");
        this.request.put("which", "Scheduler.TestJob");
        this.request.put("form_token", CSRF_TOKEN);
        Document result = renderHTMLPage(SCHEDULER_WEB_HOME);

        verify(this.schedulerPluginApi).triggerJob(this.testJobObjectApi);
        verify(this.tokenService).isTokenValid(CSRF_TOKEN);
        assertTrue(result.getElementsByClass("errormessage").isEmpty());
    }

    /**
     * List every possible action that can be applied to a job depending on its current status.
     *
     * @return a {@link Stream} of {@link org.junit.jupiter.params.provider.Arguments} for every combination of job
     *     status and action
     */
    static Stream<Arguments> jobStatusAndActionProvider()
    {
        return Stream.of(
            Arguments.of(new JobState(Trigger.TriggerState.NONE), "trigger"),
            Arguments.of(new JobState(Trigger.TriggerState.NONE), "schedule"),
            Arguments.of(new JobState(Trigger.TriggerState.NORMAL), "pause"),
            Arguments.of(new JobState(Trigger.TriggerState.NORMAL), "unschedule"),
            Arguments.of(new JobState(Trigger.TriggerState.PAUSED), "resume"),
            Arguments.of(new JobState(Trigger.TriggerState.PAUSED), "unschedule"),
            Arguments.of(new JobState(Trigger.TriggerState.NONE), "delete"));
    }

    /**
     * Verify that each action URL on the page contains the right CSRF token.
     *
     * @param status the status of the job
     * @param action the action to verify
     */
    @ParameterizedTest
    @MethodSource("jobStatusAndActionProvider")
    void checkCSRFTokenPresenceInActionURL(JobState status, String action) throws Exception
    {
        // Set the status of the displayed job to control which action URLs will be rendered.
        when(this.schedulerPluginApi.getJobStatus(this.testJobObjectApi)).thenReturn(status);

        Document result = renderHTMLPage(SCHEDULER_WEB_HOME);
        verify(this.schedulerPluginApi).getJobStatus(this.testJobObjectApi);
        Element actionLink = result.selectFirst(String.format("td a:contains(actions.%s)", action));
        assertNotNull(actionLink);

        // Check the presence of the CSRF token for the given action.
        assertEquals(String.format("path:/xwiki/bin/view/Scheduler/?do=%s&which=Scheduler"
            + ".TestJob&form_token=%s", action, CSRF_TOKEN), actionLink.attr("href"));
    }

    /**
     * Verify that the names of jobs are properly escaped in each action URL.
     *
     * @param status the status of the job
     * @param action the action to verify
     */
    @ParameterizedTest
    @MethodSource("jobStatusAndActionProvider")
    void checkEscapingInJobNames(JobState status, String action) throws Exception
    {
        // Use the `noscript` macro to make sure that no code injection occurs.
        String jobName = "\">]]{{/html}}{{noscript /}}";
        String escapedJobName = "%22%3E%5D%5D%7B%7B%2Fhtml%7D%7D%7B%7Bnoscript%20%2F%7D%7D";

        // Create a new job with a name that needs escaping and get a reference to its API.
        XWikiDocument escapedJob = new XWikiDocument(new DocumentReference("xwiki", "Scheduler", jobName));
        BaseObject escapedJobObject =
            escapedJob.newXObject(SchedulerJobClassDocumentInitializer.XWIKI_JOB_CLASSREFERENCE, this.context);
        Object escapedJobObjectApi = new Object(escapedJobObject, this.context);
        this.xwiki.saveDocument(escapedJob, this.context);

        // Return the name of the new job through the Query Service.
        when(this.query.execute()).thenReturn(List.of("Scheduler." + jobName));

        // Set the status of the new job to control which action URLs will be rendered.
        when(this.schedulerPluginApi.getJobStatus(escapedJobObjectApi)).thenReturn(status);

        Document result = renderHTMLPage(SCHEDULER_WEB_HOME);
        Element actionLink = result.selectFirst(String.format("td a:contains(actions.%s)", action));
        assertNotNull(actionLink);

        // Check the proper escaping of the job name for the given action.
        assertEquals(String.format("path:/xwiki/bin/view/Scheduler/?do=%s&which=Scheduler"
            + ".%s&form_token=%s", action, escapedJobName, CSRF_TOKEN), actionLink.attr("href"));
    }
}