XWiki's scheduler in subwiki allows scheduling operations for any main wiki user
Description
XWiki Platform is a generic wiki platform. Starting in version 1.2-milestone-2 and prior to versions 15.10.9 and 16.3.0, any user with an account on the main wiki could run scheduling operations on subwikis. To reproduce, as a user on the main wiki without any special right, view the document Scheduler.WebHome in a subwiki. Then, click on any operation (*e.g.,* Trigger) on any job. If the operation is successful, then the instance is vulnerable. This has been patched in XWiki 15.10.9 and 16.3.0. As a workaround, those who have subwikis where the Job Scheduler is enabled can edit the objects on Scheduler.WebPreferences to match the patch.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.xwiki.platform:xwiki-platform-scheduler-uiMaven | >= 1.2-milestone-2, < 15.10.9 | 15.10.9 |
org.xwiki.platform:xwiki-platform-scheduler-uiMaven | >= 16.0.0-rc-1, < 16.3.0 | 16.3.0 |
Affected products
1- Range: >= 1.2-milestone-2, < 15.10.9
Patches
154bcc5a7a2e4XWIKI-21663: Improve Scheduler access
5 files changed · +82 −190
xwiki-platform-core/xwiki-platform-oldcore/src/main/resources/ApplicationResources.properties+1 −0 modified@@ -2896,6 +2896,7 @@ xe.scheduler.job.backtolist=Back to the job list xe.scheduler.job.object=This sheet must be applied to a page that holds a scheduler job object. xe.scheduler.updateJobClassComment=Created/Updated Scheduler Job Class definition xe.scheduler.invalidToken=Invalid token, please try again by clicking on the desired action below. +xe.scheduler.missingProgrammingRights=This action requires programming rights. ### Statistics application xe.statistics.activity=Activity Statistics
xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-ui/pom.xml+21 −0 modified@@ -97,5 +97,26 @@ <type>test-jar</type> <scope>test</scope> </dependency> + <!-- Provides the component list for SecurityAuthorizationScriptService. --> + <dependency> + <groupId>org.xwiki.platform</groupId> + <artifactId>xwiki-platform-security-authorization-script</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.xwiki.platform</groupId> + <artifactId>xwiki-platform-security-authorization-script</artifactId> + <version>${project.version}</version> + <type>test-jar</type> + <scope>test</scope> + </dependency> + <!-- Provides TranslationMacro --> + <dependency> + <groupId>org.xwiki.platform</groupId> + <artifactId>xwiki-platform-localization-macro</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> </dependencies> </project>
xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-ui/src/main/resources/Scheduler/WebHome.xml+30 −23 modified@@ -41,7 +41,8 @@ ## #set ($scheduler = $xwiki.scheduler) ## -## If the sheet is called with an action ($request.do), let us first process this action +## If the sheet is called with an action ($request.do), let us first process this action after checking the user has +## programming rights. ## Possible values are : "schedule", "pause", "resume", "unschedule", "delete" ## #if ("$!request.do" != '' && "$!request.which" != '') @@ -51,23 +52,29 @@ #set ($tJobHolder = $request.which) #set ($jobDoc = $xwiki.getDocument($tJobHolder)) #set ($jobObj = $jobDoc.getObject('XWiki.SchedulerJobClass')) - #if (!$services.csrf.isTokenValid($request.form_token)) + #if (!$services.security.authorization.hasAccess('programming', $xcontext.userReference, $doc.documentReference)) + ## + ## Check that the user has programming rights + ## + {{error}}{{translation key='xe.scheduler.missingProgrammingRights'/}}{{/error}} + + #elseif (!$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}} + {{error}}{{translation key='xe.scheduler.invalidToken'/}}{{/error}} #elseif ($request.do == 'schedule') ## ## Schedule a job ## #set ($ok = $scheduler.scheduleJob($jobObj)) #if (!$ok) - {{error}}$xcontext.get('error'){{/error}} + {{error}}$escapetool.xml($xcontext.get('error')){{/error}} #else #set ($jobName = "$jobObj.get('jobName')") - {{info}}$services.localization.render('xe.scheduler.jobscheduled', [$jobName, $scheduler.getNextFireTime($jobObj)]){{/info}} + {{info}}$escapetool.xml($services.localization.render('xe.scheduler.jobscheduled', [$jobName, $scheduler.getNextFireTime($jobObj)])){{/info}} #end #elseif ($request.do == 'pause') @@ -76,10 +83,10 @@ ## #set ($ok = $scheduler.pauseJob($jobObj)) #if (!$ok) - {{error}}$xcontext.get('error'){{/error}} + {{error}}$escapetool.xml($xcontext.get('error')){{/error}} #else - {{info}}$services.localization.render('xe.scheduler.paused', [$jobObj.get('jobName')]){{/info}} + {{info}}$escapetool.xml($services.localization.render('xe.scheduler.paused', [$jobObj.get('jobName')])){{/info}} #end #elseif ($request.do == 'resume') @@ -88,10 +95,10 @@ ## #set ($ok = $scheduler.resumeJob($jobObj)) #if (!$ok) - {{error}}$xcontext.get('error'){{/error}} + {{error}}$escapetool.xml($xcontext.get('error')){{/error}} #else - {{info}}$services.localization.render('xe.scheduler.resumed', [$jobObj.get('jobName'), $scheduler.getNextFireTime($jobObj)]){{/info}} + {{info}}$escapetool.xml($services.localization.render('xe.scheduler.resumed', [$jobObj.get('jobName'), $scheduler.getNextFireTime($jobObj)])){{/info}} #end #elseif ($request.do == 'unschedule') @@ -100,10 +107,10 @@ ## #set ($ok = $scheduler.unscheduleJob($jobObj)) #if (!$ok) - {{error}}$xcontext.get('error'){{/error}} + {{error}}$escapetool.xml($xcontext.get('error')){{/error}} #else - {{info}}$services.localization.render('xe.scheduler.unscheduled', [$jobObj.get('jobName')]){{/info}} + {{info}}$escapetool.xml($services.localization.render('xe.scheduler.unscheduled', [$jobObj.get('jobName')])){{/info}} #end #elseif ($request.do == 'delete') @@ -118,7 +125,7 @@ #set ($deleteRedirect = $xwiki.getURL($jobObj.getName(), 'delete')) $response.sendRedirect($deleteRedirect) #else - {{error}}$xcontext.get('error'){{/error}} + {{error}}$escapetool.xml($xcontext.get('error')){{/error}} #end #else @@ -131,23 +138,23 @@ ## #set ($ok = $scheduler.triggerJob($jobObj)) #if (!$ok) - {{error}}$xcontext.get('error'){{/error}} + {{error}}$escapetool.xml($xcontext.get('error')){{/error}} #else - {{info}}$services.localization.render('xe.scheduler.triggered', [$jobObj.get('jobName')]){{/info}} + {{info}}$escapetool.xml($services.localization.render('xe.scheduler.triggered', [$jobObj.get('jobName')])){{/info}} #end #end #end -$services.localization.render('xe.scheduler.welcome') +{{translation key='xe.scheduler.welcome'/}} -= $services.localization.render('xe.scheduler.jobs.list') = += {{translation key='xe.scheduler.jobs.list'/}} = ## ## Retrieve all scheduler jobs ## Display their name, status, possible next fire time, and available actions ## -|=(%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') +|=(%scope="col"%){{translation key='xe.scheduler.jobs.name'/}}|=(%scope="col"%){{translation key='xe.scheduler.jobs.status'/}}|=(%scope="col"%){{translation key='xe.scheduler.jobs.next'/}}|=(%scope="col"%){{translation key='xe.scheduler.jobs.actions'/}} #foreach ($docName in $services.query.xwql('from doc.object(XWiki.SchedulerJobClass) as jobs where doc.fullName <> ''XWiki.SchedulerJobTemplate''').execute()) #set ($jobHolder = $xwiki.getDocument($docName)) #set ($job = $jobHolder.getObject('XWiki.SchedulerJobClass')) @@ -159,7 +166,7 @@ $services.localization.render('xe.scheduler.welcome') #if ($status != 'None') #set ($firetime = $scheduler.getNextFireTime($job)) #else - #set ($firetime = $services.localization.render('xe.scheduler.jobs.next.undefined')) + #set ($firetime = "{{translation key='xe.scheduler.jobs.next.undefined'/}}") #end #set ($actions = ['trigger']) #if ($status == 'None') @@ -170,7 +177,7 @@ $services.localization.render('xe.scheduler.welcome') #set ($ok = $actions.addAll(['resume', 'unschedule'])) #end #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')>>$services.rendering.escape($jobHolder.fullName, 'xwiki/2.1')]]#if($jobHolder.hasAccessLevel('programming')) [[$services.localization.render('xe.scheduler.jobs.actions.edit')>>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")>>path:$doc.getURL('view', $escapetool.url({'do': $action, 'which': $jobHolder.fullName, 'form_token': $services.csrf.token}))]]#end +|$job.get('jobName')|$status|$firetime|**{{translation key='xe.scheduler.jobs.actions.access'/}}** [[{{translation key='xe.scheduler.jobs.actions.view'/}}>>$services.rendering.escape($jobHolder.fullName, 'xwiki/2.1')]]#if($jobHolder.hasAccessLevel('programming')) [[{{translation key='xe.scheduler.jobs.actions.edit'/}}>>path:${jobHolder.getURL('edit')}]]#end **{{translation key='xe.scheduler.jobs.actions.manage'/}}**#foreach($action in $actions) [[{{translation key='xe.scheduler.jobs.actions.$action'/}}>>path:$doc.getURL('view', $escapetool.url({'do': $action, 'which': $jobHolder.fullName, 'form_token': $services.csrf.token}))]]#end #end #if ($doc.hasAccessLevel('programming')) @@ -181,12 +188,12 @@ $services.localization.render('xe.scheduler.welcome') ## schedule, pause, etc. ## -= $services.localization.render('xe.scheduler.jobs.create') = += {{translation key='xe.scheduler.jobs.create'/}} = ## ## Form to create a new Job ## -{{info}}$services.localization.render('xe.scheduler.jobs.explaincreate'){{/info}} +{{info}}{{translation key='xe.scheduler.jobs.explaincreate'/}}{{/info}} {{html}} <form action="$doc.getURL('create')" id="newdoc" class="form-inline"><div> @@ -195,7 +202,7 @@ $services.localization.render('xe.scheduler.welcome') <input type="hidden" name="template" value="XWiki.SchedulerJobTemplate" /> <input type="hidden" name="sheet" value="1" /> <input type="hidden" name="space" value="Scheduler"/> - <label class="sr-only" for="page">$services.localization.render('xe.scheduler.jobs.create.nameTip')</label> + <label class="sr-only" for="page">$escapetool.xml($services.localization.render('xe.scheduler.jobs.create.nameTip'))</label> <input id="page" name="page" size="30" type="text" placeholder="$escapetool.xml($services.localization.render('xe.scheduler.jobs.create.nameTip'))" /> <span class="buttonwrapper"> @@ -207,7 +214,7 @@ $services.localization.render('xe.scheduler.welcome') #else - {{warning}}$services.localization.render('xe.scheduler.jobs.warning'){{/warning}} + {{warning}}{{translation key='xe.scheduler.jobs.warning'/}}{{/warning}} #end {{/velocity}}</content>
xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-ui/src/main/resources/Scheduler/WebPreferences.xml+1 −167 modified@@ -114,176 +114,10 @@ <groups>XWiki.XWikiAdminGroup</groups> </property> <property> - <levels>admin,edit</levels> + <levels>view,edit,comment,delete,admin</levels> </property> <property> <users/> </property> </object> - <object> - <name>Scheduler.WebPreferences</name> - <number>1</number> - <className>XWiki.XWikiGlobalRights</className> - <guid>0b2c7da3-315a-4a6d-b782-5de0b20b6497</guid> - <class> - <name>XWiki.XWikiGlobalRights</name> - <customClass/> - <customMapping/> - <defaultViewSheet/> - <defaultEditSheet/> - <defaultWeb/> - <nameField/> - <validationScript/> - <allow> - <defaultValue>1</defaultValue> - <disabled>0</disabled> - <displayFormType>select</displayFormType> - <displayType>allow</displayType> - <name>allow</name> - <number>4</number> - <prettyName>Allow/Deny</prettyName> - <unmodifiable>0</unmodifiable> - <classType>com.xpn.xwiki.objects.classes.BooleanClass</classType> - </allow> - <groups> - <cache>0</cache> - <disabled>0</disabled> - <displayType>input</displayType> - <multiSelect>1</multiSelect> - <name>groups</name> - <number>1</number> - <picker>1</picker> - <prettyName>Groups</prettyName> - <relationalStorage>0</relationalStorage> - <separator> </separator> - <size>5</size> - <unmodifiable>0</unmodifiable> - <classType>com.xpn.xwiki.objects.classes.GroupsClass</classType> - </groups> - <levels> - <cache>0</cache> - <disabled>0</disabled> - <displayType>select</displayType> - <multiSelect>1</multiSelect> - <name>levels</name> - <number>2</number> - <prettyName>Levels</prettyName> - <relationalStorage>0</relationalStorage> - <separator> </separator> - <size>3</size> - <unmodifiable>0</unmodifiable> - <classType>com.xpn.xwiki.objects.classes.LevelsClass</classType> - </levels> - <users> - <cache>0</cache> - <disabled>0</disabled> - <displayType>input</displayType> - <multiSelect>1</multiSelect> - <name>users</name> - <number>3</number> - <picker>1</picker> - <prettyName>Users</prettyName> - <relationalStorage>0</relationalStorage> - <separator> </separator> - <size>5</size> - <unmodifiable>0</unmodifiable> - <classType>com.xpn.xwiki.objects.classes.UsersClass</classType> - </users> - </class> - <property> - <allow>0</allow> - </property> - <property> - <groups>XWiki.XWikiAllGroup</groups> - </property> - <property> - <levels>view</levels> - </property> - <property> - <users/> - </property> - </object> - <object> - <name>Scheduler.WebPreferences</name> - <number>2</number> - <className>XWiki.XWikiGlobalRights</className> - <guid>fef3f075-e0ed-46d1-991f-680326129a9d</guid> - <class> - <name>XWiki.XWikiGlobalRights</name> - <customClass/> - <customMapping/> - <defaultViewSheet/> - <defaultEditSheet/> - <defaultWeb/> - <nameField/> - <validationScript/> - <allow> - <defaultValue>1</defaultValue> - <disabled>0</disabled> - <displayFormType>select</displayFormType> - <displayType>allow</displayType> - <name>allow</name> - <number>4</number> - <prettyName>Allow/Deny</prettyName> - <unmodifiable>0</unmodifiable> - <classType>com.xpn.xwiki.objects.classes.BooleanClass</classType> - </allow> - <groups> - <cache>0</cache> - <disabled>0</disabled> - <displayType>input</displayType> - <multiSelect>1</multiSelect> - <name>groups</name> - <number>1</number> - <picker>1</picker> - <prettyName>Groups</prettyName> - <relationalStorage>0</relationalStorage> - <separator> </separator> - <size>5</size> - <unmodifiable>0</unmodifiable> - <classType>com.xpn.xwiki.objects.classes.GroupsClass</classType> - </groups> - <levels> - <cache>0</cache> - <disabled>0</disabled> - <displayType>select</displayType> - <multiSelect>1</multiSelect> - <name>levels</name> - <number>2</number> - <prettyName>Levels</prettyName> - <relationalStorage>0</relationalStorage> - <separator> </separator> - <size>3</size> - <unmodifiable>0</unmodifiable> - <classType>com.xpn.xwiki.objects.classes.LevelsClass</classType> - </levels> - <users> - <cache>0</cache> - <disabled>0</disabled> - <displayType>input</displayType> - <multiSelect>1</multiSelect> - <name>users</name> - <number>3</number> - <picker>1</picker> - <prettyName>Users</prettyName> - <relationalStorage>0</relationalStorage> - <separator> </separator> - <size>5</size> - <unmodifiable>0</unmodifiable> - <classType>com.xpn.xwiki.objects.classes.UsersClass</classType> - </users> - </class> - <property> - <allow>0</allow> - </property> - <property> - <groups/> - </property> - <property> - <levels>view</levels> - </property> - <property> - <users>XWiki.XWikiGuest</users> - </property> - </object> </xwikidoc>
xwiki-platform-core/xwiki-platform-scheduler/xwiki-platform-scheduler-ui/src/test/java/org/xwiki/scheduler/ui/SchedulerPageTest.java+29 −0 modified@@ -32,6 +32,7 @@ import org.mockito.Mock; import org.quartz.Trigger; import org.xwiki.csrf.script.CSRFTokenScriptService; +import org.xwiki.localization.macro.internal.TranslationMacro; import org.xwiki.model.reference.DocumentReference; import org.xwiki.query.internal.ScriptQuery; import org.xwiki.query.script.QueryManagerScriptService; @@ -41,6 +42,10 @@ 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.security.authorization.Right; +import org.xwiki.security.authorization.script.SecurityAuthorizationScriptService; +import org.xwiki.security.script.SecurityScriptService; +import org.xwiki.security.script.SecurityScriptServiceComponentList; import org.xwiki.test.annotation.ComponentList; import org.xwiki.test.page.HTML50ComponentList; import org.xwiki.test.page.PageTest; @@ -77,8 +82,10 @@ ErrorMessageMacro.class, SchedulerJobClassDocumentInitializer.class, TestNoScriptMacro.class, + TranslationMacro.class, WarningMessageMacro.class }) +@SecurityScriptServiceComponentList @RenderingScriptServiceComponentList @DefaultRenderingConfigurationComponentList @HTML50ComponentList @@ -98,6 +105,8 @@ class SchedulerPageTest extends PageTest private CSRFTokenScriptService tokenService; + private SecurityAuthorizationScriptService authorizationScriptService; + private SchedulerPluginApi schedulerPluginApi; @Mock @@ -138,6 +147,9 @@ void setUp() throws Exception // Fake programming access level to display the complete page. when(this.oldcore.getMockRightService().hasAccessLevel(eq("programming"), anyString(), anyString(), any(XWikiContext.class))).thenReturn(true); + this.authorizationScriptService = this.oldcore.getMocker().getInstance(ScriptService.class, + SecurityScriptService.ROLEHINT + "." + SecurityAuthorizationScriptService.ID); + when(this.authorizationScriptService.hasAccess(eq(Right.PROGRAM), any(), any())).thenReturn(true); } /** @@ -178,6 +190,23 @@ void checkValidCSRFToken() throws Exception assertTrue(result.getElementsByClass("errormessage").isEmpty()); } + /** + * Check that the trigger operation fails if the user is missing programming rights. + */ + @Test + void checkMissingProgrammingRights() throws Exception + { + when(this.authorizationScriptService.hasAccess(eq(Right.PROGRAM), any(), any())).thenReturn(false); + when(this.schedulerPluginApi.triggerJob(this.testJobObjectApi)).thenReturn(true); + + this.request.put("do", "trigger"); + this.request.put("which", "Scheduler.TestJob"); + Document result = renderHTMLPage(SCHEDULER_WEB_HOME); + + verify(this.schedulerPluginApi, never()).triggerJob(any(Object.class)); + assertEquals("xe.scheduler.missingProgrammingRights", result.getElementsByClass("errormessage").text()); + } + /** * List every possible action that can be applied to a job depending on its current status. *
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-cwq6-mjmx-47p6ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-55876ghsaADVISORY
- github.com/xwiki/xwiki-platform/commit/54bcc5a7a2e440cc591b91eece9c13dc0c487331ghsax_refsource_MISCWEB
- github.com/xwiki/xwiki-platform/security/advisories/GHSA-cwq6-mjmx-47p6ghsax_refsource_CONFIRMWEB
- jira.xwiki.org/browse/XWIKI-21663ghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.