Skip to content

Commit f16ca4e

Browse files
pjeanjeanmichitux
authored andcommitted
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
1 parent 70eac0d commit f16ca4e

File tree

8 files changed

+300
-17
lines changed

8 files changed

+300
-17
lines changed

xwiki-platform-core/xwiki-platform-oldcore/src/main/resources/ApplicationResources.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2889,6 +2889,7 @@ xe.scheduler.job.scriptexplanation=The script is the code that will be executed
28892889
xe.scheduler.job.backtolist=Back to the job list
28902890
xe.scheduler.job.object=This sheet must be applied to a page that holds a scheduler job object.
28912891
xe.scheduler.updateJobClassComment=Created/Updated Scheduler Job Class definition
2892+
xe.scheduler.invalidToken=Invalid token, please try again by clicking on the desired action below.
28922893

28932894
### Statistics application
28942895
xe.statistics.activity=Activity Statistics

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,11 @@ void verifyScheduler(TestUtils setup)
7070
// because we want to remain on the same page in case of a test failure so that our TestDebugger rule can
7171
// collect accurate information about the failure. It's not a problem if the job remains scheduled because it
7272
// does nothing. Other tests should not rely on the number of scheduler jobs though.
73-
setup.deletePage("Scheduler", "SchedulerTestJob");
73+
setup.deletePage("Scheduler", "Scheduler]]TestJob");
7474

7575
// Create Job
7676
SchedulerHomePage schedulerHomePage = SchedulerHomePage.gotoPage();
77-
schedulerHomePage.setJobName("SchedulerTestJob");
77+
schedulerHomePage.setJobName("Scheduler]]TestJob");
7878
SchedulerEditPage schedulerEdit = schedulerHomePage.clickAdd();
7979

8080
String jobName = "Tester problem";
@@ -107,7 +107,7 @@ void verifyScheduler(TestUtils setup)
107107
assertFalse(setup.getDriver().hasElementWithoutWaiting(By.linkText(jobName)));
108108
// Note: since the page doesn't exist, we need to disable the space redirect feature so that we end up on the
109109
// terminal page that was removed.
110-
setup.gotoPage("Scheduler", "SchedulerTestJob", "view", "spaceRedirect=false");
110+
setup.gotoPage("Scheduler", "Scheduler]]TestJob", "view", "spaceRedirect=false");
111111
setup.getDriver().findElement(By.linkText("Restore")).click();
112112
schedulerPage = new SchedulerPage();
113113
schedulerPage.backToHome();

xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-ui/pom.xml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,27 @@
7575
<version>${project.version}</version>
7676
<scope>runtime</scope>
7777
</dependency>
78+
<!-- Test dependencies. -->
79+
<dependency>
80+
<groupId>org.xwiki.platform</groupId>
81+
<artifactId>xwiki-platform-test-page</artifactId>
82+
<version>${project.version}</version>
83+
<scope>test</scope>
84+
</dependency>
85+
<!-- Provides the component list for RenderingScriptService. -->
86+
<dependency>
87+
<groupId>org.xwiki.platform</groupId>
88+
<artifactId>xwiki-platform-rendering-xwiki</artifactId>
89+
<version>${project.version}</version>
90+
<type>test-jar</type>
91+
<scope>test</scope>
92+
</dependency>
93+
<dependency>
94+
<groupId>org.xwiki.platform</groupId>
95+
<artifactId>xwiki-platform-rendering-configuration-default</artifactId>
96+
<version>${project.version}</version>
97+
<type>test-jar</type>
98+
<scope>test</scope>
99+
</dependency>
78100
</dependencies>
79101
</project>

xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-ui/src/main/resources/Scheduler/Translations.de.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@
3838
<hidden>true</hidden>
3939
<content>scheduler.applicationsPanelEntryLabel=Planer
4040
</content>
41-
</xwikidoc>
41+
</xwikidoc>

xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-ui/src/main/resources/Scheduler/Translations.ru.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@
3838
<hidden>true</hidden>
3939
<content>scheduler.applicationsPanelEntryLabel=Планировщик
4040
</content>
41-
</xwikidoc>
41+
</xwikidoc>

xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-ui/src/main/resources/Scheduler/Translations.uk.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@
3838
<hidden>true</hidden>
3939
<content>scheduler.applicationsPanelEntryLabel=Планувальник
4040
</content>
41-
</xwikidoc>
41+
</xwikidoc>

xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-ui/src/main/resources/Scheduler/WebHome.xml

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
2121
-->
2222

23-
<xwikidoc version="1.1">
23+
<xwikidoc version="1.5" reference="Scheduler.WebHome" locale="">
2424
<web>Scheduler</web>
2525
<name>WebHome</name>
2626
<language/>
@@ -51,7 +51,13 @@
5151
#set ($tJobHolder = $request.which)
5252
#set ($jobDoc = $xwiki.getDocument($tJobHolder))
5353
#set ($jobObj = $jobDoc.getObject('XWiki.SchedulerJobClass'))
54-
#if ($request.do == 'schedule')
54+
#if (!$services.csrf.isTokenValid($request.form_token))
55+
##
56+
## Check that the CSRF token matches the user before any operation
57+
##
58+
{{error}}$services.localization.render('xe.scheduler.invalidToken'){{/error}}
59+
60+
#elseif ($request.do == 'schedule')
5561
##
5662
## Schedule a job
5763
##
@@ -143,7 +149,6 @@ $services.localization.render('xe.scheduler.welcome')
143149
##
144150
|=(%scope="col"%)$services.localization.render('xe.scheduler.jobs.name')|=(%scope="col"%)$services.localization.render('xe.scheduler.jobs.status')|=(%scope="col"%)$services.localization.render('xe.scheduler.jobs.next')|=(%scope="col"%)$services.localization.render('xe.scheduler.jobs.actions')
145151
#foreach ($docName in $services.query.xwql('from doc.object(XWiki.SchedulerJobClass) as jobs where doc.fullName &lt;&gt; ''XWiki.SchedulerJobTemplate''').execute())
146-
#set ($actions = {})
147152
#set ($jobHolder = $xwiki.getDocument($docName))
148153
#set ($job = $jobHolder.getObject('XWiki.SchedulerJobClass'))
149154
#set ($status = $scheduler.getJobStatus($job).value)
@@ -156,18 +161,16 @@ $services.localization.render('xe.scheduler.welcome')
156161
#else
157162
#set ($firetime = $services.localization.render('xe.scheduler.jobs.next.undefined'))
158163
#end
159-
#set ($ok = $!actions.put('trigger', $doc.getURL('view', "do=trigger&amp;which=${jobHolder.fullName}")))
164+
#set ($actions = ['trigger'])
160165
#if ($status == 'None')
161-
#set ($ok = $!actions.put('schedule', $doc.getURL('view', "do=schedule&amp;which=${jobHolder.fullName}")))
166+
#set ($ok = $actions.add('schedule'))
162167
#elseif($status == 'Normal')
163-
#set ($ok = $!actions.put('pause', $doc.getURL('view', "do=pause&amp;which=${jobHolder.fullName}")))
164-
#set ($ok = $!actions.put('unschedule', $doc.getURL('view', "do=unschedule&amp;which=${jobHolder.fullName}")))
168+
#set ($ok = $actions.addAll(['pause', 'unschedule']))
165169
#elseif ($status == 'Paused')
166-
#set ($ok = $!actions.put('resume', $doc.getURL('view', "do=resume&amp;which=${jobHolder.fullName}")))
167-
#set ($ok = $!actions.put('unschedule', $doc.getURL('view', "do=unschedule&amp;which=${jobHolder.fullName}")))
170+
#set ($ok = $actions.addAll(['resume', 'unschedule']))
168171
#end
169-
#set ($ok = $!actions.put('delete', $doc.getURL('view', "do=delete&amp;which=${jobHolder.fullName}")))
170-
|$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
172+
#set ($ok = $actions.add('delete'))
173+
|$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
171174

172175
#end
173176
#if ($doc.hasAccessLevel('programming'))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
/*
2+
* See the NOTICE file distributed with this work for additional
3+
* information regarding copyright ownership.
4+
*
5+
* This is free software; you can redistribute it and/or modify it
6+
* under the terms of the GNU Lesser General Public License as
7+
* published by the Free Software Foundation; either version 2.1 of
8+
* the License, or (at your option) any later version.
9+
*
10+
* This software is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13+
* Lesser General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Lesser General Public
16+
* License along with this software; if not, write to the Free
17+
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
18+
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
19+
*/
20+
package org.xwiki.scheduler.ui;
21+
22+
import java.util.List;
23+
import java.util.stream.Stream;
24+
25+
import org.jsoup.nodes.Document;
26+
import org.jsoup.nodes.Element;
27+
import org.junit.jupiter.api.BeforeEach;
28+
import org.junit.jupiter.api.Test;
29+
import org.junit.jupiter.params.ParameterizedTest;
30+
import org.junit.jupiter.params.provider.Arguments;
31+
import org.junit.jupiter.params.provider.MethodSource;
32+
import org.mockito.Mock;
33+
import org.quartz.Trigger;
34+
import org.xwiki.csrf.script.CSRFTokenScriptService;
35+
import org.xwiki.model.reference.DocumentReference;
36+
import org.xwiki.query.internal.ScriptQuery;
37+
import org.xwiki.query.script.QueryManagerScriptService;
38+
import org.xwiki.rendering.RenderingScriptServiceComponentList;
39+
import org.xwiki.rendering.internal.configuration.DefaultRenderingConfigurationComponentList;
40+
import org.xwiki.rendering.internal.macro.message.ErrorMessageMacro;
41+
import org.xwiki.rendering.internal.macro.message.InfoMessageMacro;
42+
import org.xwiki.rendering.internal.macro.message.WarningMessageMacro;
43+
import org.xwiki.script.service.ScriptService;
44+
import org.xwiki.test.annotation.ComponentList;
45+
import org.xwiki.test.page.HTML50ComponentList;
46+
import org.xwiki.test.page.PageTest;
47+
import org.xwiki.test.page.TestNoScriptMacro;
48+
import org.xwiki.test.page.XWikiSyntax21ComponentList;
49+
50+
import com.xpn.xwiki.XWikiContext;
51+
import com.xpn.xwiki.api.Object;
52+
import com.xpn.xwiki.doc.XWikiDocument;
53+
import com.xpn.xwiki.objects.BaseObject;
54+
import com.xpn.xwiki.plugin.scheduler.JobState;
55+
import com.xpn.xwiki.plugin.scheduler.SchedulerPluginApi;
56+
import com.xpn.xwiki.plugin.scheduler.internal.SchedulerJobClassDocumentInitializer;
57+
58+
import static org.junit.jupiter.api.Assertions.assertEquals;
59+
import static org.junit.jupiter.api.Assertions.assertNotNull;
60+
import static org.junit.jupiter.api.Assertions.assertTrue;
61+
import static org.mockito.ArgumentMatchers.any;
62+
import static org.mockito.ArgumentMatchers.anyString;
63+
import static org.mockito.ArgumentMatchers.eq;
64+
import static org.mockito.Mockito.doReturn;
65+
import static org.mockito.Mockito.mock;
66+
import static org.mockito.Mockito.never;
67+
import static org.mockito.Mockito.verify;
68+
import static org.mockito.Mockito.when;
69+
70+
/**
71+
* Page tests for {@code Scheduler.WebHome}.
72+
*
73+
* @version $Id$
74+
*/
75+
@ComponentList({
76+
InfoMessageMacro.class,
77+
ErrorMessageMacro.class,
78+
SchedulerJobClassDocumentInitializer.class,
79+
TestNoScriptMacro.class,
80+
WarningMessageMacro.class
81+
})
82+
@RenderingScriptServiceComponentList
83+
@DefaultRenderingConfigurationComponentList
84+
@HTML50ComponentList
85+
@XWikiSyntax21ComponentList
86+
class SchedulerPageTest extends PageTest
87+
{
88+
private static final String WIKI_NAME = "xwiki";
89+
90+
private static final String XWIKI_SPACE = "Scheduler";
91+
92+
private static final DocumentReference SCHEDULER_WEB_HOME =
93+
new DocumentReference(WIKI_NAME, XWIKI_SPACE, "WebHome");
94+
95+
private static final String CSRF_TOKEN = "a0a0a0a0";
96+
97+
private QueryManagerScriptService queryService;
98+
99+
private CSRFTokenScriptService tokenService;
100+
101+
private SchedulerPluginApi schedulerPluginApi;
102+
103+
@Mock
104+
private ScriptQuery query;
105+
106+
private Object testJobObjectApi;
107+
108+
@BeforeEach
109+
void setUp() throws Exception
110+
{
111+
// Mock the Query Service to return a job.
112+
this.queryService = this.oldcore.getMocker().registerMockComponent(ScriptService.class, "query",
113+
QueryManagerScriptService.class,
114+
true);
115+
when(this.queryService.xwql(anyString())).thenReturn(this.query);
116+
when(this.query.execute()).thenReturn(List.of("Scheduler.TestJob"));
117+
118+
// Mock the Token Service to get a consistent CSRF token throughout the tests.
119+
this.tokenService = this.oldcore.getMocker().registerMockComponent(ScriptService.class, "csrf",
120+
CSRFTokenScriptService.class, true);
121+
when(this.tokenService.getToken()).thenReturn(CSRF_TOKEN);
122+
when(this.tokenService.isTokenValid(CSRF_TOKEN)).thenReturn(true);
123+
124+
// Spy the Scheduler Plugin to obtain a mocked API.
125+
this.schedulerPluginApi = mock(SchedulerPluginApi.class);
126+
doReturn(this.schedulerPluginApi).when(this.oldcore.getSpyXWiki()).getPluginApi(eq("scheduler"),
127+
any(XWikiContext.class));
128+
129+
this.xwiki.initializeMandatoryDocuments(this.context);
130+
131+
// Create a new job and keep a reference to its API.
132+
XWikiDocument testJob = new XWikiDocument(new DocumentReference("xwiki", "Scheduler", "TestJob"));
133+
BaseObject testJobObject = testJob.newXObject(SchedulerJobClassDocumentInitializer.XWIKI_JOB_CLASSREFERENCE,
134+
this.context);
135+
this.xwiki.saveDocument(testJob, this.context);
136+
this.testJobObjectApi = new Object(testJobObject, this.context);
137+
138+
// Fake programming access level to display the complete page.
139+
when(this.oldcore.getMockRightService().hasAccessLevel(eq("programming"), anyString(), anyString(),
140+
any(XWikiContext.class))).thenReturn(true);
141+
}
142+
143+
/**
144+
* Verify that the trigger operation is not called in the Scheduler Plugin API when the CSRF token is invalid, and
145+
* that the corresponding error message is properly displayed.
146+
*/
147+
@Test
148+
void checkInvalidCSRFToken() throws Exception
149+
{
150+
String wrongToken = "wrong token";
151+
152+
this.request.put("do", "trigger");
153+
this.request.put("which", "Scheduler.TestJob");
154+
this.request.put("form_token", wrongToken);
155+
Document result = renderHTMLPage(SCHEDULER_WEB_HOME);
156+
157+
verify(this.schedulerPluginApi, never()).triggerJob(any(Object.class));
158+
verify(this.tokenService).isTokenValid(wrongToken);
159+
assertEquals("xe.scheduler.invalidToken", result.getElementsByClass("errormessage").text());
160+
}
161+
162+
/**
163+
* Verify that the trigger operation is correctly called in the Scheduler Plugin API when the CSRF token is valid,
164+
* and that no error displays.
165+
*/
166+
@Test
167+
void checkValidCSRFToken() throws Exception
168+
{
169+
when(this.schedulerPluginApi.triggerJob(this.testJobObjectApi)).thenReturn(true);
170+
171+
this.request.put("do", "trigger");
172+
this.request.put("which", "Scheduler.TestJob");
173+
this.request.put("form_token", CSRF_TOKEN);
174+
Document result = renderHTMLPage(SCHEDULER_WEB_HOME);
175+
176+
verify(this.schedulerPluginApi).triggerJob(this.testJobObjectApi);
177+
verify(this.tokenService).isTokenValid(CSRF_TOKEN);
178+
assertTrue(result.getElementsByClass("errormessage").isEmpty());
179+
}
180+
181+
/**
182+
* List every possible action that can be applied to a job depending on its current status.
183+
*
184+
* @return a {@link Stream} of {@link org.junit.jupiter.params.provider.Arguments} for every combination of job
185+
* status and action
186+
*/
187+
static Stream<Arguments> jobStatusAndActionProvider()
188+
{
189+
return Stream.of(
190+
Arguments.of(new JobState(Trigger.TriggerState.NONE), "trigger"),
191+
Arguments.of(new JobState(Trigger.TriggerState.NONE), "schedule"),
192+
Arguments.of(new JobState(Trigger.TriggerState.NORMAL), "pause"),
193+
Arguments.of(new JobState(Trigger.TriggerState.NORMAL), "unschedule"),
194+
Arguments.of(new JobState(Trigger.TriggerState.PAUSED), "resume"),
195+
Arguments.of(new JobState(Trigger.TriggerState.PAUSED), "unschedule"),
196+
Arguments.of(new JobState(Trigger.TriggerState.NONE), "delete"));
197+
}
198+
199+
/**
200+
* Verify that each action URL on the page contains the right CSRF token.
201+
*
202+
* @param status the status of the job
203+
* @param action the action to verify
204+
*/
205+
@ParameterizedTest
206+
@MethodSource("jobStatusAndActionProvider")
207+
void checkCSRFTokenPresenceInActionURL(JobState status, String action) throws Exception
208+
{
209+
// Set the status of the displayed job to control which action URLs will be rendered.
210+
when(this.schedulerPluginApi.getJobStatus(this.testJobObjectApi)).thenReturn(status);
211+
212+
Document result = renderHTMLPage(SCHEDULER_WEB_HOME);
213+
verify(this.schedulerPluginApi).getJobStatus(this.testJobObjectApi);
214+
Element actionLink = result.selectFirst(String.format("td a:contains(actions.%s)", action));
215+
assertNotNull(actionLink);
216+
217+
// Check the presence of the CSRF token for the given action.
218+
assertEquals(String.format("path:/xwiki/bin/view/Scheduler/?do=%s&which=Scheduler"
219+
+ ".TestJob&form_token=%s", action, CSRF_TOKEN), actionLink.attr("href"));
220+
}
221+
222+
/**
223+
* Verify that the names of jobs are properly escaped in each action URL.
224+
*
225+
* @param status the status of the job
226+
* @param action the action to verify
227+
*/
228+
@ParameterizedTest
229+
@MethodSource("jobStatusAndActionProvider")
230+
void checkEscapingInJobNames(JobState status, String action) throws Exception
231+
{
232+
// Use the `noscript` macro to make sure that no code injection occurs.
233+
String jobName = "\">]]{{/html}}{{noscript /}}";
234+
String escapedJobName = "%22%3E%5D%5D%7B%7B%2Fhtml%7D%7D%7B%7Bnoscript%20%2F%7D%7D";
235+
236+
// Create a new job with a name that needs escaping and get a reference to its API.
237+
XWikiDocument escapedJob = new XWikiDocument(new DocumentReference("xwiki", "Scheduler", jobName));
238+
BaseObject escapedJobObject =
239+
escapedJob.newXObject(SchedulerJobClassDocumentInitializer.XWIKI_JOB_CLASSREFERENCE, this.context);
240+
Object escapedJobObjectApi = new Object(escapedJobObject, this.context);
241+
this.xwiki.saveDocument(escapedJob, this.context);
242+
243+
// Return the name of the new job through the Query Service.
244+
when(this.query.execute()).thenReturn(List.of("Scheduler." + jobName));
245+
246+
// Set the status of the new job to control which action URLs will be rendered.
247+
when(this.schedulerPluginApi.getJobStatus(escapedJobObjectApi)).thenReturn(status);
248+
249+
Document result = renderHTMLPage(SCHEDULER_WEB_HOME);
250+
Element actionLink = result.selectFirst(String.format("td a:contains(actions.%s)", action));
251+
assertNotNull(actionLink);
252+
253+
// Check the proper escaping of the job name for the given action.
254+
assertEquals(String.format("path:/xwiki/bin/view/Scheduler/?do=%s&which=Scheduler"
255+
+ ".%s&form_token=%s", action, escapedJobName, CSRF_TOKEN), actionLink.attr("href"));
256+
}
257+
}

0 commit comments

Comments
 (0)