Code injection from view right on XWiki.ClassSheet in xwiki-platform
Description
XWiki Platform is a generic wiki platform offering runtime services for applications built on top of it. Any user with view rights can execute arbitrary script macros including Groovy and Python macros that allow remote code execution including unrestricted read and write access to all wiki contents. The attack works by opening a non-existing page with a name crafted to contain a dangerous payload. This issue has been patched in XWiki 14.4.8, 14.10.3 and 15.0RC1. Users are advised to upgrade. There are no known workarounds for this vulnerability.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.xwiki.platform:xwiki-platform-xclass-uiMaven | >= 7.0-rc-1, < 14.4.8 | 14.4.8 |
org.xwiki.platform:xwiki-platform-xclass-uiMaven | >= 14.5, < 14.10.3 | 14.10.3 |
Affected products
1- Range: < 14.4.8
Patches
1d7e561853766XWIKI-20456: Improved escaping of XWiki.ClassSheet
4 files changed · +621 −59
xwiki-platform-core/xwiki-platform-web/xwiki-platform-web-templates/src/main/resources/templates/locationPicker_macros.vm+25 −17 modified@@ -40,12 +40,14 @@ #set ($escapedValue = $escapetool.xml($value)) #if ($titleField.label) <dt> - <label for="$!{options.id}Title">$services.localization.render($titleField.label)</label> - <span class="xHint">$!services.localization.render($titleField.hint)</span> + <label for="$escapetool.xml($!{options.id})Title">## + $escapetool.xml($services.localization.render($titleField.label))## + </label> + <span class="xHint">$!escapetool.xml($services.localization.render($titleField.hint))</span> </dt> <dd> - <input type="text" id="$!{options.id}Title" name="$titleField.name" value="$!escapedValue" - class="location-title-field" placeholder="$!services.localization.render($titleField.placeholder)" /> + <input type="text" id="$escapetool.xml($!{options.id})Title" name="$escapetool.xml($titleField.name)" value="$!escapedValue" + class="location-title-field" placeholder="$escapetool.xml($!services.localization.render($titleField.placeholder))" /> </dd> #elseif ($titleField) <dt class="hidden"></dt> @@ -60,8 +62,8 @@ ## --------------------------------------------------------------------------------------------------------- ## <dt> - <label>$services.localization.render($options.preview.label)</label> - <span class="xHint">$services.localization.render($options.preview.hint)</span> + <label>$escapetool.xml($services.localization.render($options.preview.label))</label> + <span class="xHint">$escapetool.xml($services.localization.render($options.preview.hint))</span> </dt> <dd> #if ($isDocumentTreeAvailable) @@ -113,8 +115,10 @@ #set ($escapedValue = $escapetool.xml($value)) #if ($wikiField.label && $displayWikiFields) <dt> - <label for="$!{options.id}Wiki">$services.localization.render($wikiField.label)</label> - <span class="xHint">$!services.localization.render($wikiField.hint)</span> + <label for="$escapetool.xml($!{options.id})Wiki">## + $escapetool.xml($services.localization.render($wikiField.label))## + </label> + <span class="xHint">$!escapetool.xml($services.localization.render($wikiField.hint))</span> </dt> <dd> <select id="$!{options.id}Wiki" name="$wikiField.name" class="location-wiki-field"> @@ -156,13 +160,15 @@ #end #set ($escapedValue = $escapetool.xml($value)) <dt> - <label for="$!{options.id}ParentReference">$services.localization.render($parentField.label)</label> - <span class="xHint">$!services.localization.render($parentField.hint)</span> + <label for="$escapetool.xml($!{options.id})ParentReference">## + $escapetool.xml($services.localization.render($parentField.label))## + </label> + <span class="xHint">$!escapetool.xml($services.localization.render($parentField.hint))</span> </dt> <dd> - <input type="text" id="$!{options.id}ParentReference" class="location-parent-field suggestSpaces" - name="$parentField.name" value="$!escapedValue" - placeholder="$!services.localization.render($parentField.placeholder)" /> + <input type="text" id="$escapetool.xml($!{options.id})ParentReference" class="location-parent-field suggestSpaces" + name="$escapetool.xml($parentField.name)" value="$!escapedValue" + placeholder="$!escapetool.xml($services.localization.render($parentField.placeholder))" /> </dd> ## ## --------------------------------------------------------------------------------------------------------- @@ -177,13 +183,15 @@ #set ($escapedValue = $escapetool.xml($value)) #if ($nameField.label) <dt> - <label for="$!{options.id}Name">$services.localization.render($nameField.label)</label> - <span class="xHint">$!services.localization.render($nameField.hint)</span> + <label for="$escapetool.xml($!{options.id})Name">## + $escapetool.xml($services.localization.render($nameField.label))## + </label> + <span class="xHint">$escapetool.xml($services.localization.render($nameField.hint))</span> </dt> <dd> - <input type="text" id="$!{options.id}Name" name="$nameField.name" class="location-name-field" + <input type="text" id="$escapetool.xml($!{options.id})Name" name="$escapetool.xml($nameField.name)" class="location-name-field" value="$!escapedValue" - placeholder="$!services.localization.render($nameField.placeholder)" /> + placeholder="$escapetool.xml($!services.localization.render($nameField.placeholder))" /> </dd> #elseif ($nameField) <dt class="hidden"></dt>
xwiki-platform-core/xwiki-platform-xclass/xwiki-platform-xclass-ui/pom.xml+40 −0 modified@@ -85,5 +85,45 @@ <version>${project.version}</version> <scope>runtime</scope> </dependency> + <dependency> + <groupId>org.xwiki.platform</groupId> + <artifactId>xwiki-platform-rendering-xwiki</artifactId> + <version>${project.version}</version> + <scope>runtime</scope> + </dependency> + <!-- Test dependencies. --> + <dependency> + <groupId>org.xwiki.platform</groupId> + <artifactId>xwiki-platform-test-page</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.xwiki.platform</groupId> + <artifactId>xwiki-platform-web-templates</artifactId> + <version>${project.version}</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.xwiki.platform</groupId> + <artifactId>xwiki-platform-livedata-macro</artifactId> + <version>${project.version}</version> + <scope>test</scope> + <type>test-jar</type> + </dependency> + <dependency> + <groupId>org.xwiki.platform</groupId> + <artifactId>xwiki-platform-rendering-xwiki</artifactId> + <version>${project.version}</version> + <scope>test</scope> + <type>test-jar</type> + </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> </dependencies> </project>
xwiki-platform-core/xwiki-platform-xclass/xwiki-platform-xclass-ui/src/main/resources/XWiki/ClassSheet.xml+96 −42 modified@@ -129,31 +129,45 @@ #set ($classEditorURL = $doc.getURL('edit', 'editor=class')) #if($doc.getxWikiClass().properties.size() == 0) - {{warning}}$services.localization.render('platform.xclass.defaultClassSheet.properties.empty', [ - "{{html}}<a href='$classEditorURL'>", - '</a>{{/html}}' - ]){{/warning}} + #set ($openLink = "<a href='$escapetool.xml($classEditorURL)'>") + #set ($closeLink = '</a>') + {{warning}} + {{html}} + ## First escape the content of the translation, then replace the placeholders with content that would otherwise be + ## escaped during the first escaping. + #set ($warningMessage = $services.localization.render('platform.xclass.defaultClassSheet.properties.empty', + ['__OPEN_LINK__', '__CLOSE_LINK__'])) + $escapetool.xml($warningMessage).replace('__OPEN_LINK__', $openLink).replace('__CLOSE_LINK__', $closeLink) + {{/html}} + {{/warning}} #else (% id="HClassProperties" %) = {{translation key="platform.xclass.defaultClassSheet.properties.heading"/}} = #foreach($property in $doc.getxWikiClass().properties) * $services.rendering.escape("$property.prettyName ($property.name: $xwiki.metaclass.get($property.classType).prettyName)", $xwiki.currentContentSyntaxId) #end - * //$services.localization.render('platform.xclass.defaultClassSheet.properties.edit', [ - "{{html}}<a href='$classEditorURL'>", - '</a>{{/html}}' - ])// + #set ($openLink = "<a href='$escapetool.xml($classEditorURL)'>") + #set ($closeLink = '</a>') + #set ($warningMessage = $escapetool.xml($services.localization.render('platform.xclass.defaultClassSheet.properties.edit', ['__OPEN_LINK__', '__CLOSE_LINK__']))) + ## First escape the content of the translation, then replace the placeholders with content that would otherwise be + ## escaped during the first escaping. + * //{{html}}$warningMessage.replace('__OPEN_LINK__', $openLink).replace('__CLOSE_LINK__', $closeLink){{/html}}// #end #if ($hasClassSheets && $hasClassTemplate) (% id="HCreatePage" %) = {{translation key="platform.xclass.defaultClassSheet.createPage.heading"/}} = #if("$!targetDocRef" != '' && $xwiki.exists($targetDocRef)) - - {{warning}}$services.localization.render('platform.xclass.defaultClassSheet.createPage.pageAlreadyExists', [ - '[[', - ">>$services.model.serialize($targetDocRef)]]" - ]){{/warning}} + {{warning}} + {{html}} + #set ($targetDocLink = $xwiki.getURL($targetDocRef)) + #set ($openLink = "<a href='$escapetool.xml($targetDocLink)'>") + #set ($message = $escapetool.xml($services.localization.render('platform.xclass.defaultClassSheet.createPage.pageAlreadyExists', ['__OPEN_LINK__', '__CLOSE_LINK__']))) + ## First escape the content of the translation, then replace the placeholders with content that would + ## otherwise be escaped during the first escaping. + $message.replace('__OPEN_LINK__', $openLink).replace('__CLOSE_LINK__', '</a>') + {{/html}} + {{/warning}} #elseif("$!targetDocRef" != '') {{warning}}{{translation key="platform.xclass.defaultClassSheet.createPage.denied"/}}{{/warning}} @@ -164,8 +178,8 @@ <fieldset> <div class="hidden"> <input type="hidden" name="form_token" value="$!{services.csrf.getToken()}" /> - <input type="hidden" name="parent" value="${defaultParent}"/> - <input type="hidden" name="template" value="${classTemplateDoc}"/> + <input type="hidden" name="parent" value="$escapetool.xml(${defaultParent})"/> + <input type="hidden" name="template" value="$escapetool.xml(${classTemplateDoc})"/> <input type="hidden" name="sheet" value="1"/> </div> #locationPicker({ @@ -219,8 +233,8 @@ id="classEntries" properties="doc.title,doc.location,doc.date,doc.author,doc.objectCount,_actions" source="liveTable" - className="${doc.fullName}" - sourceParameters="${escapetool.url($options)}" + className="$services.rendering.escape(${doc.fullName}, 'xwiki/2.1')" + sourceParameters="$services.rendering.escape($escapetool.url($options), 'xwiki/2.1')" }} { "meta": { @@ -247,15 +261,21 @@ {{translation key="platform.xclass.defaultClassSheet.sheets.missing"/}} #end - {{info}}$services.localization.render('platform.xclass.defaultClassSheet.sheets.description', ['//', '//']){{/info}} + {{info}} + #set ($message = $services.localization.render('platform.xclass.defaultClassSheet.sheets.description', ['__START_EM__', '__END_EM__'])) + #set ($message = $escapetool.xml($message)) + ## First escape the content of the translation, then replace the placeholders with content that would + ## otherwise be escaped during the first escaping. + {{html}}$message.replace('__START_EM__', '<em>').replace('__END_EM__', '</em>'){{/html}} + {{/info}} #if(!$hasClassSheets) {{html}} <form action="$xwiki.getURL($defaultClassSheetReference, 'save', 'editor=wiki')" method="post"> <div> <input type="hidden" name="form_token" value="$!{services.csrf.getToken()}" /> - <input type="hidden" name="parent" value="${doc.fullName}"/> - <input type="hidden" name="xredirect" value="${doc.URL}"/> + <input type="hidden" name="parent" value="$escapetool.xml(${doc.fullName})"/> + <input type="hidden" name="xredirect" value="$escapetool.xml(${doc.URL})"/> #set ($sheetContent = $xwiki.getDocument('XWiki.ObjectSheet').getContent().replace('XWiki.MyClass', $doc.fullName)) ## We have to encode the new line characters in order to preserve them, otherwise they are replace with a @@ -280,7 +300,9 @@ {{translation key="platform.xclass.defaultClassSheet.sheets.notBound"/}} ## #if ($hasEdit) {{html}} - <a href="$bindURL">$services.localization.render('platform.xclass.defaultClassSheet.sheets.bind') »</a>. + <a href="$escapetool.xml($bindURL)">## + $escapetool.xml($services.localization.render('platform.xclass.defaultClassSheet.sheets.bind')) »## + </a>. {{/html}} #end {{/warning}} @@ -292,7 +314,12 @@ #set($classSheetDoc = $xwiki.getDocument($classSheetReferences.get(0))) #end #set ($sheetPath = "#hierarchy($classSheetDoc.documentReference, {'plain': true, 'local': true, 'limit': 4})") - [[$services.localization.render('platform.xclass.defaultClassSheet.sheets.view', [$sheetPath.trim()]) »>>${classSheetDoc.fullName}]] + #set ($classSheetLink = "$services.localization.render('platform.xclass.defaultClassSheet.sheets.view', [$sheetPath.trim()]) »") + #set ($classSheetLink = $services.rendering.escape($classSheetLink, 'xwiki/2.1')) + #set ($classSheetLink = $services.rendering.escape($classSheetLink, 'xwiki/2.1')) + #set ($classSheetText = ${classSheetDoc.fullName}) + #set ($classSheetText = $services.rendering.escape($classSheetText, 'xwiki/2.1')) + [[$classSheetLink>>$classSheetText]] #else {{translation key="platform.xclass.defaultClassSheet.sheets.list"/}} @@ -305,17 +332,22 @@ (% id="HClassTemplate" %) = {{translation key="platform.xclass.defaultClassSheet.template.heading"/}} = - {{info}}$services.localization.render('platform.xclass.defaultClassSheet.template.description', - ['//', '//']){{/info}} + {{info}} + #set ($message = $services.localization.render('platform.xclass.defaultClassSheet.template.description', ['__START_EM__', '__END_EM__'])) + #set ($message = $escapetool.xml($message)) + ## First escape the content of the translation, then replace the placeholders with content that would + ## otherwise be escaped during the first escaping. + {{html}}$message.replace('__START_EM__', '<em>').replace('__END_EM__', '</em>'){{/html}} + {{/info}} #if (!$hasClassTemplate) {{html}} - <form action="$classTemplateDoc.getURL('save', 'editor=wiki')" method="post"> + <form action="$escapetool.xml($classTemplateDoc.getURL('save', 'editor=wiki'))" method="post"> <div> <input type="hidden" name="form_token" value="$!{services.csrf.getToken()}" /> - <input type="hidden" name="parent" value="${doc.fullName}"/> - <input type="hidden" name="xredirect" value="${doc.URL}"/> - <input type="hidden" name="title" value="$className Template"/> + <input type="hidden" name="parent" value="$escapetool.xml(${doc.fullName})"/> + <input type="hidden" name="xredirect" value="$escapetool.xml(${doc.URL})"/> + <input type="hidden" name="title" value="$escapetool.xml($className) Template"/> <span class="buttonwrapper"><input type="submit" class="button" value="$escapetool.xml( $services.localization.render('platform.xclass.defaultClassSheet.template.create'))"/></span> </div> @@ -324,25 +356,44 @@ #else #if(!$classTemplateDoc.getObject(${doc.fullName})) #set($xredirect = $xwiki.relativeRequestURL) - #set($createUrl = $classTemplateDoc.getURL('objectadd', "classname=${escapetool.url($doc.fullName)}&amp;xredirect=${escapetool.url($xredirect)}&amp;form_token=$!{services.csrf.getToken()}")) + #set($createUrl = $classTemplateDoc.getURL('objectadd', "classname=${escapetool.url($doc.fullName)}&xredirect=${escapetool.url($xredirect)}&form_token=$!{services.csrf.getToken()}")) {{warning}} - $services.localization.render('platform.xclass.defaultClassSheet.template.missingObject', ["//$className//"]) ## - {{html}}<a href="$createUrl">$escapetool.xml($services.localization.render( - 'platform.xclass.defaultClassSheet.template.addObject', [$className])) »</a>.{{/html}} + #set ($message = $services.localization.render('platform.xclass.defaultClassSheet.template.missingObject', ['__CLASS_NAME__'])) + #set ($message = $escapetool.xml($message)) + {{html}} + ## First escape the content of the translation, then replace the placeholders with content that would + ## otherwise be escaped during the first escaping. + $message.replace('__CLASS_NAME__', "<em>$escapetool.xml($className)</em>") + <a href="$escapetool.xml($createUrl)">## + $escapetool.xml($services.localization.render('platform.xclass.defaultClassSheet.template.addObject', [$className])) »## + </a>. + {{/html}} {{/warning}} #end #set ($templatePath = "#hierarchy($classTemplateDoc.documentReference, {'plain': true, 'local': true, 'limit': 4})") - [[$services.localization.render('platform.xclass.defaultClassSheet.template.view', - [$templatePath.trim()]) »>>${classTemplateDoc.fullName}]] + #set ($templateDocLink = "$services.localization.render('platform.xclass.defaultClassSheet.template.view', [$templatePath.trim()]) »") + #set ($templateDocLink = $services.rendering.escape($templateDocLink, 'xwiki/2.1')) + #set ($templateDocLink = $services.rendering.escape($templateDocLink, 'xwiki/2.1')) + #set ($templateDocText = "${classTemplateDoc.fullName}") + ## First escape the xwiki/2.1 syntax of the translation, then replace the placeholders with content that would + ## otherwise be escaped during the first escaping. + #set ($templateDocText = $services.rendering.escape($templateDocText, 'xwiki/2.1')) + [[$templateDocLink>>$templateDocText]] #end ## Create a template provider only if a template for the current class exists. #if ($classTemplateDoc.getObject(${doc.fullName})) (% id="HClassTemplateProvider" %) = {{translation key="platform.xclass.defaultClassSheet.templateProvider.heading"/}} = - {{info}}$services.localization.render('platform.xclass.defaultClassSheet.templateProvider.description', - ['//']){{/info}} + {{info}} + #set ($message = $services.localization.render('platform.xclass.defaultClassSheet.templateProvider.description', ['__EM__'])) + #set ($message = $services.rendering.escape($message, 'xwiki/2.1')) + ## First escape the xwiki/2.1 syntax of the translation, then replace the placeholders with content that would + ## otherwise be escaped during the first escaping. + ## The replacement key is itself escaped, and it's escaped form needs to be used for the replacement. + $message.replace('~_~_~E~M~_~_', '//') + {{/info}} #if (!$hasClassTemplateProvider) #set ($templateProviderClassName = 'XWiki.TemplateProviderClass') @@ -362,21 +413,24 @@ "${templateProviderClassName}_visibilityRestrictions": $restrictionSpace})) #set ($createUrl = $classTemplateProviderDoc.getURL('objectadd', $createUrlQueryString)) {{html}} - <form action="$classTemplateProviderDoc.getURL('save', 'editor=wiki')" method="post"> + <form action="$escapetool.xml($classTemplateProviderDoc.getURL('save', 'editor=wiki'))" method="post"> <div> <input type="hidden" name="form_token" value="$!{services.csrf.getToken()}" /> - <input type="hidden" name="parent" value="${doc.fullName}"/> - <input type="hidden" name="xredirect" value="$createUrl"/> - <input type="hidden" name="title" value="$className Template Provider"/> + <input type="hidden" name="parent" value="$escapetool.xml(${doc.fullName})"/> + <input type="hidden" name="xredirect" value="$escapetool.xml($createUrl)"/> + <input type="hidden" name="title" value="$escapetool.xml($className) Template Provider"/> <span class="buttonwrapper"><input type="submit" class="button" value="$escapetool.xml( $services.localization.render('platform.xclass.defaultClassSheet.templateProvider.create'))"/></span> </div> </form> {{/html}} #else #set ($templateProviderPath = "#hierarchy($classTemplateProviderDoc.documentReference, {'plain': true, 'local': true, 'limit': 4})") - [[$services.localization.render('platform.xclass.defaultClassSheet.templateProvider.view', - [$templateProviderPath.trim()]) »>>${classTemplateProviderDoc.fullName}]] + #set ($linkTarget = "$services.localization.render('platform.xclass.defaultClassSheet.templateProvider.view', [$templateProviderPath.trim()]) »") + #set ($linkTarget = $services.rendering.escape($linkTarget, 'xwiki/2.1')) + #set ($linkTarget = $services.rendering.escape($linkTarget, 'xwiki/2.1')) + #set ($linkLabel = $services.rendering.escape(${classTemplateProviderDoc.fullName}, 'xwiki/2.1')) + [[$linkTarget>>$linkLabel]] #end #end
xwiki-platform-core/xwiki-platform-xclass/xwiki-platform-xclass-ui/src/test/java/org/xwiki/xclass/ClassSheetPageTest.java+460 −0 added@@ -0,0 +1,460 @@ +/* + * 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.xclass; + +import java.util.HashMap; + +import javax.script.ScriptContext; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.xwiki.icon.IconManager; +import org.xwiki.livedata.internal.macro.LiveDataMacroComponentList; +import org.xwiki.localization.macro.internal.TranslationMacro; +import org.xwiki.model.reference.DocumentReference; +import org.xwiki.model.script.ModelScriptService; +import org.xwiki.rendering.RenderingScriptServiceComponentList; +import org.xwiki.rendering.internal.configuration.DefaultRenderingConfigurationComponentList; +import org.xwiki.rendering.internal.macro.message.InfoMessageMacro; +import org.xwiki.rendering.internal.macro.message.WarningMessageMacro; +import org.xwiki.script.ScriptContextManager; +import org.xwiki.template.internal.macro.TemplateMacro; +import org.xwiki.template.script.TemplateScriptService; +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.XWikiSyntax20ComponentList; +import org.xwiki.test.page.XWikiSyntax21ComponentList; +import org.xwiki.velocity.tools.EscapeTool; + +import com.xpn.xwiki.XWikiException; +import com.xpn.xwiki.doc.XWikiDocument; +import com.xpn.xwiki.objects.BaseObject; +import com.xpn.xwiki.objects.classes.BaseClass; +import com.xpn.xwiki.plugin.skinx.SkinExtensionPluginApi; +import com.xpn.xwiki.script.sheet.SheetScriptService; + +import static javax.script.ScriptContext.GLOBAL_SCOPE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Page Test of {@code XWiki.ClassSheet}. + * + * @version $Id$ + * @since 15.0RC1 + * @since 14.10.3 + * @since 14.4.8 + */ +@HTML50ComponentList +@XWikiSyntax21ComponentList +@XWikiSyntax20ComponentList +@LiveDataMacroComponentList +@RenderingScriptServiceComponentList +@DefaultRenderingConfigurationComponentList +@ComponentList({ + TemplateMacro.class, + TranslationMacro.class, + WarningMessageMacro.class, + InfoMessageMacro.class, + TemplateScriptService.class, + TestNoScriptMacro.class, + ModelScriptService.class, + SheetScriptService.class +}) +class ClassSheetPageTest extends PageTest +{ + private static final DocumentReference CLASS_SHEET_BINDING_DOCUMENT_REFERENCE = + new DocumentReference("xwiki", "XWiki", "ClassSheetBinding"); + + private static final DocumentReference XWIKI_CLASS_SHEET = new DocumentReference("xwiki", "XWiki", "ClassSheet"); + + private ScriptContext scriptContext; + + @BeforeEach + void setUp() throws Exception + { + // Spy the jsfx plugin used during the macro rendering to return a mock of its API when required. + when(this.oldcore.getSpyXWiki().getPluginApi("jsfx", this.context)) + .thenReturn(mock(SkinExtensionPluginApi.class)); + + // Return minimal icons metadata since this is not what we want to test in this test suite. + IconManager iconManager = this.componentManager.registerMockComponent(IconManager.class); + doReturn(new HashMap<>()).when(iconManager).getMetaData(anyString()); + + initializeClassSheetBinding(); + + this.scriptContext = this.oldcore.getMocker().<ScriptContextManager>getInstance(ScriptContextManager.class) + .getCurrentScriptContext(); + } + + @Test + void notASheetNoProperties() throws Exception + { + XWikiDocument xwikiDocument = + this.xwiki.getDocument(new DocumentReference("xwiki", "Space", "/}}{{/html}}{{noscript/}}"), this.context); + XWikiDocument doc = loadPage(XWIKI_CLASS_SHEET); + + // Set up the current doc in the context so that $doc is bound in scripts + this.context.setDoc(xwikiDocument); + + Document document = render(doc); + + Elements forms = document.select("form"); + + Element warningMessage = document.selectFirst(".warningmessage"); + assertEquals("platform.xclass.defaultClassSheet.properties.empty [, ]", warningMessage.text()); + assertEquals("/xwiki/bin/edit/Space/%2F%7D%7D%7B%7B%2Fhtml%7D%7D%7B%7Bnoscript%2F%7D%7D?editor=class", + warningMessage.selectFirst("a").attr("href")); + Element classSheetForm = forms.get(0); + assertEquals("Space./}}{{/html}}{{noscript/}}", classSheetForm.selectFirst("[name='parent']").val()); + assertEquals("/xwiki/bin/view/Space/%2F%7D%7D%7B%7B%2Fhtml%7D%7D%7B%7Bnoscript%2F%7D%7D", + classSheetForm.selectFirst("[name='xredirect']").val()); + assertEquals("#if($doc.documentReference.name == '/}}{{/html}}{{noscript/}}Sheet')/}}{{/html}}" + + "{{noscript/}} Sheet#{else}$services.display.title($doc, {'displayerHint': 'default', " + + "'outputSyntaxId': 'plain/1.0'})#end", classSheetForm.selectFirst("[name='title']").val()); + Element classTemplateForm = forms.get(1); + assertEquals("Space./}}{{/html}}{{noscript/}}", classTemplateForm.selectFirst("[name='parent']").val()); + assertEquals("/xwiki/bin/view/Space/%2F%7D%7D%7B%7B%2Fhtml%7D%7D%7B%7Bnoscript%2F%7D%7D", + classTemplateForm.selectFirst("[name='xredirect']").val()); + assertEquals("/}}{{/html}}{{noscript/}} Template", classTemplateForm.selectFirst("[name='title']").val()); + Elements infomessages = document.select(".infomessage"); + Element infomessage1 = infomessages.get(0); + assertEquals("platform.xclass.defaultClassSheet.sheets.description [, ]", infomessage1.text()); + assertNotNull(infomessage1.selectFirst("em")); + Element infomessage2 = infomessages.get(1); + assertEquals("platform.xclass.defaultClassSheet.template.description [, ]", infomessage2.text()); + assertNotNull(infomessage2.selectFirst("em")); + } + + @Test + void notASheetHasProperties() throws Exception + { + XWikiDocument xwikiDocument = + this.xwiki.getDocument(new DocumentReference("xwiki", "Space", "/}}{{/html}}{{noscript/}}"), this.context); + xwikiDocument.getXClass().addTextField("testField", "Test Field", 10); + XWikiDocument doc = loadPage(XWIKI_CLASS_SHEET); + + // Set up the current doc in the context so that $doc is bound in scripts + this.context.setDoc(xwikiDocument); + + Document document = render(doc); + Elements forms = document.select("form"); + + Element ul = document.selectFirst("ul"); + Elements lis = ul.select("li"); + assertEquals("Test Field (testField: String)", lis.get(0).text()); + assertEquals("platform.xclass.defaultClassSheet.properties.edit [, ]", lis.get(1).text()); + assertEquals("/xwiki/bin/edit/Space/%2F%7D%7D%7B%7B%2Fhtml%7D%7D%7B%7Bnoscript%2F%7D%7D?editor=class", + lis.get(1).selectFirst("a").attr("href")); + Element classSheetForm = forms.get(0); + assertEquals("Space./}}{{/html}}{{noscript/}}", classSheetForm.selectFirst("[name='parent']").val()); + assertEquals("/xwiki/bin/view/Space/%2F%7D%7D%7B%7B%2Fhtml%7D%7D%7B%7Bnoscript%2F%7D%7D", + classSheetForm.selectFirst("[name='xredirect']").val()); + assertEquals("#if($doc.documentReference.name == '/}}{{/html}}{{noscript/}}Sheet')/}}{{/html}}" + + "{{noscript/}} Sheet#{else}$services.display.title($doc, {'displayerHint': 'default', " + + "'outputSyntaxId': 'plain/1.0'})#end", classSheetForm.selectFirst("[name='title']").val()); + Element classTemplateForm = forms.get(1); + assertEquals("Space./}}{{/html}}{{noscript/}}", classTemplateForm.selectFirst("[name='parent']").val()); + assertEquals("/xwiki/bin/view/Space/%2F%7D%7D%7B%7B%2Fhtml%7D%7D%7B%7Bnoscript%2F%7D%7D", + classTemplateForm.selectFirst("[name='xredirect']").val()); + assertEquals("/}}{{/html}}{{noscript/}} Template", classTemplateForm.selectFirst("[name='title']").val()); + } + + @Test + void notASheetHasSheetAlreadyExists() throws Exception + { + String alreadyExistsPageName = "AlreadyExists/}}{{noscript/}}"; + XWikiDocument alreadyExistsDoc = + this.xwiki.getDocument(new DocumentReference("xwiki", "Space", alreadyExistsPageName), this.context); + this.xwiki.saveDocument(alreadyExistsDoc, this.context); + + XWikiDocument xwikiDocument = + this.xwiki.getDocument(new DocumentReference("xwiki", "Space", "/}}{{/html}}{{noscript/}}"), this.context); + xwikiDocument.getXClass().addTextField("testField", "Test Field", 10); + BaseObject baseObject = xwikiDocument.newXObject(CLASS_SHEET_BINDING_DOCUMENT_REFERENCE, this.context); + baseObject.set("sheet", alreadyExistsPageName, this.context); + this.xwiki.saveDocument(xwikiDocument, this.context); + XWikiDocument doc = loadPage(XWIKI_CLASS_SHEET); + + // Set up the current doc in the context so that $doc is bound in scripts + this.context.setDoc(xwikiDocument); + + Document document = render(doc); + + Elements h1s = document.select("h1"); + assertEquals("platform.xclass.defaultClassSheet.properties.heading", h1s.get(0).text()); + assertEquals("platform.xclass.defaultClassSheet.pages.heading", h1s.get(1).text()); + + Element ul = document.selectFirst("ul"); + Elements lis = ul.select("li"); + assertEquals("Test Field (testField: String)", lis.get(0).text()); + assertEquals("platform.xclass.defaultClassSheet.properties.edit [, ]", lis.get(1).text()); + assertEquals("/xwiki/bin/edit/Space/%2F%7D%7D%7B%7B%2Fhtml%7D%7D%7B%7Bnoscript%2F%7D%7D?editor=class", + lis.get(1).selectFirst("a").attr("href")); + Element classSheetForm = document.selectFirst("form"); + assertEquals("Space./}}{{/html}}{{noscript/}}", classSheetForm.selectFirst("[name='parent']").val()); + assertEquals("/xwiki/bin/view/Space/%2F%7D%7D%7B%7B%2Fhtml%7D%7D%7B%7Bnoscript%2F%7D%7D", + classSheetForm.selectFirst("[name='xredirect']").val()); + assertEquals("/}}{{/html}}{{noscript/}} Template", classSheetForm.selectFirst("[name='title']").val()); + + assertEquals(String.format("platform.xclass.defaultClassSheet.sheets.view [Space / %s] »", + new EscapeTool().xml(alreadyExistsPageName)), + document.selectFirst(String.format("[href='Space.%s']", alreadyExistsPageName)).text()); + } + + @Test + void notASheetHasSheetAlreadyExistsClassTemplateExists() throws Exception + { + String alreadyExistsPageName = "AlreadyExists/}}{{noscript/}}"; + String pageName = "/}}{{/html}}{{noscript/}}"; + XWikiDocument alreadyExistsDoc = + this.xwiki.getDocument(new DocumentReference("xwiki", "Space", alreadyExistsPageName), this.context); + this.xwiki.saveDocument(alreadyExistsDoc, this.context); + + XWikiDocument alreadyExistsDocTemplate = + this.xwiki.getDocument(new DocumentReference("xwiki", "Space", pageName + "Template"), this.context); + this.xwiki.saveDocument(alreadyExistsDocTemplate, this.context); + + XWikiDocument xwikiDocument = + this.xwiki.getDocument(new DocumentReference("xwiki", "Space", pageName), this.context); + xwikiDocument.getXClass().addTextField("testField", "Test Field", 10); + BaseObject baseObject = xwikiDocument.newXObject(CLASS_SHEET_BINDING_DOCUMENT_REFERENCE, this.context); + baseObject.set("sheet", alreadyExistsPageName, this.context); + this.xwiki.saveDocument(xwikiDocument, this.context); + XWikiDocument doc = loadPage(XWIKI_CLASS_SHEET); + + // Set up the current doc in the context so that $doc is bound in scripts + this.context.setDoc(xwikiDocument); + + Document document = render(doc); + + Elements h1s = document.select("h1"); + assertEquals("platform.xclass.defaultClassSheet.properties.heading", h1s.get(0).text()); + assertEquals("platform.xclass.defaultClassSheet.createPage.heading", h1s.get(1).text()); + + Element ul = document.selectFirst("ul"); + Elements lis = ul.select("li"); + assertEquals("Test Field (testField: String)", lis.get(0).text()); + assertEquals(String.format("%s [, ]", "platform.xclass.defaultClassSheet.properties.edit"), lis.get(1).text()); + assertEquals("/xwiki/bin/edit/Space/%2F%7D%7D%7B%7B%2Fhtml%7D%7D%7B%7Bnoscript%2F%7D%7D?editor=class", + lis.get(1).selectFirst("a").attr("href")); + Element classSheetForm = document.selectFirst("form"); + assertEquals("Space./}}{{/html}}{{noscript/}}", classSheetForm.selectFirst("[name='parent']").val()); + assertEquals("Space./}}{{/html}}{{noscript/}}Template", classSheetForm.selectFirst("[name='template']").val()); + assertEquals("1", classSheetForm.selectFirst("[name='sheet']").val()); + + assertEquals(String.format("platform.xclass.defaultClassSheet.sheets.view [Space / %s] »", + new EscapeTool().xml(alreadyExistsPageName)), + document.selectFirst(String.format("[href='Space.%s']", alreadyExistsPageName)).text()); + } + + @Test + void hasClassSheetsAndHasClassTemplate() throws Exception + { + String pageName = "Page"; + String sheetPage = "MySheet"; + DocumentReference mainPageReference = new DocumentReference("xwiki", "Space", pageName); + XWikiDocument xwikiDocument = this.xwiki.getDocument(mainPageReference, this.context); + xwikiDocument.getXClass().addTextField("testField", "Test Field", 10); + BaseObject classSheetBindingObject = + xwikiDocument.newXObject(CLASS_SHEET_BINDING_DOCUMENT_REFERENCE, this.context); + classSheetBindingObject.set("sheet", sheetPage, this.context); + + this.xwiki.saveDocument(xwikiDocument, this.context); + XWikiDocument doc = loadPage(XWIKI_CLASS_SHEET); + + // Class Template Creation. + XWikiDocument templateProvider = this.xwiki.getDocument( + new DocumentReference(pageName + "Template", mainPageReference.getLastSpaceReference()), + this.context); + this.xwiki.saveDocument(templateProvider, this.context); + + // Class docRef + XWikiDocument docRef = this.xwiki.getDocument( + new DocumentReference("DOC_NAME", mainPageReference.getLastSpaceReference()), + this.context); + this.xwiki.saveDocument(docRef, this.context); + + this.request.put("docName", "DOC_NAME"); + + // Set up the current doc in the context so that $doc is bound in scripts + this.context.setDoc(xwikiDocument); + + Document document = render(doc); + + Elements warnings = document.select(".warningmessage"); + Element warning1 = warnings.get(0); + assertEquals("platform.xclass.defaultClassSheet.createPage.pageAlreadyExists [, ]", warning1.text()); + assertEquals("/xwiki/bin/view/Space/DOC_NAME", warning1.selectFirst("a").attr("href")); + Element warning2 = warnings.get(1); + assertEquals("platform.xclass.defaultClassSheet.template.missingObject [Page] " + + "platform.xclass.defaultClassSheet.template.addObject [Page] ».", warning2.text()); + assertEquals("/xwiki/bin/objectadd/Space/PageTemplate" + + "?classname=Space.Page&xredirect=%2Fxwiki%2Fbin%2FMain%2FWebHome&form_token=", + warning2.selectFirst("a").attr("href")); + } + + @Test + void hasEditAndDefaultClassSheetReference() throws Exception + { + this.scriptContext.setAttribute("hasEdit", true, GLOBAL_SCOPE); + + String pageName = "Page"; + DocumentReference mainPageReference = new DocumentReference("xwiki", "Space", pageName); + XWikiDocument xwikiDocument = this.xwiki.getDocument(mainPageReference, this.context); + xwikiDocument.getXClass().addTextField("testField", "Test Field", 10); + this.xwiki.saveDocument(xwikiDocument, this.context); + + // Create defaultClassSheetReference + XWikiDocument defaultClassSheet = + this.xwiki.getDocument(new DocumentReference("xwiki", "Space", "PageSheet"), this.context); + this.xwiki.saveDocument(defaultClassSheet, this.context); + + XWikiDocument doc = loadPage(XWIKI_CLASS_SHEET); + + this.request.put("docName", "DOC_NAME"); + + // Set up the current doc in the context so that $doc is bound in scripts + this.context.setDoc(xwikiDocument); + + Document document = render(doc); + + Element warning = document.selectFirst(".warningmessage"); + assertEquals("platform.xclass.defaultClassSheet.sheets.notBound " + + "platform.xclass.defaultClassSheet.sheets.bind ».", warning.text()); + assertEquals("/xwiki/bin/view/Space/Page?bindSheet=xwiki%3ASpace.PageSheet" + + "&xredirect=%2Fxwiki%2Fbin%2FMain%2FWebHome&form_token=", warning.selectFirst("a").attr("href")); + } + + @Test + void hasEditAndDefaultClassSheetReferenceTemplateHasDoc() throws Exception + { + this.scriptContext.setAttribute("hasEdit", true, GLOBAL_SCOPE); + + String pageName = "Page"; + DocumentReference mainPageReference = new DocumentReference("xwiki", "Space", pageName); + XWikiDocument xwikiDocument = this.xwiki.getDocument(mainPageReference, this.context); + xwikiDocument.getXClass().addTextField("testField", "Test Field", 10); + this.xwiki.saveDocument(xwikiDocument, this.context); + + // Create defaultClassSheetReference + XWikiDocument defaultClassSheet = + this.xwiki.getDocument(new DocumentReference("xwiki", "Space", "PageSheet"), this.context); + this.xwiki.saveDocument(defaultClassSheet, this.context); + + XWikiDocument pageTemplate = + this.xwiki.getDocument(new DocumentReference("xwiki", "Space", "PageTemplate"), this.context); + pageTemplate.newXObject(mainPageReference, this.context); + this.xwiki.saveDocument(pageTemplate, this.context); + + XWikiDocument doc = loadPage(XWIKI_CLASS_SHEET); + + this.request.put("docName", "DOC_NAME"); + + // Set up the current doc in the context so that $doc is bound in scripts + this.context.setDoc(xwikiDocument); + + Document document = render(doc); + + Elements infomessages = document.select(".infomessage"); + assertEquals("platform.xclass.defaultClassSheet.sheets.description [, ]", infomessages.get(0).text()); + assertEquals("platform.xclass.defaultClassSheet.template.description [, ]", infomessages.get(1).text()); + assertEquals("platform.xclass.defaultClassSheet.templateProvider.description []", infomessages.get(2).text()); + assertNotNull(infomessages.get(2).selectFirst("em")); + } + + @Test + void hasEditAndDefaultClassSheetReferenceTemplateHasDocHasClassTemplateProvider() throws Exception + { + this.scriptContext.setAttribute("hasEdit", true, GLOBAL_SCOPE); + + DocumentReference mainPageReference = new DocumentReference("xwiki", "Space", "Page"); + XWikiDocument xwikiDocument = this.xwiki.getDocument(mainPageReference, this.context); + xwikiDocument.getXClass().addTextField("testField", "Test Field", 10); + this.xwiki.saveDocument(xwikiDocument, this.context); + + // Create defaultClassSheetReference + XWikiDocument defaultClassSheet = + this.xwiki.getDocument(new DocumentReference("xwiki", "Space", "PageSheet"), this.context); + this.xwiki.saveDocument(defaultClassSheet, this.context); + + XWikiDocument pageTemplate = + this.xwiki.getDocument(new DocumentReference("xwiki", "Space", "PageTemplate"), this.context); + pageTemplate.newXObject(mainPageReference, this.context); + this.xwiki.saveDocument(pageTemplate, this.context); + + XWikiDocument pageTemplateProvider = + this.xwiki.getDocument(new DocumentReference("xwiki", "Space", "PageTemplateProvider"), this.context); + this.xwiki.saveDocument(pageTemplateProvider, this.context); + + XWikiDocument doc = loadPage(XWIKI_CLASS_SHEET); + + this.request.put("docName", "DOC_NAME"); + + // Set up the current doc in the context so that $doc is bound in scripts + this.context.setDoc(xwikiDocument); + + Document document = render(doc); + + Elements infomessages = document.select(".infomessage"); + assertEquals("platform.xclass.defaultClassSheet.sheets.description [, ]", infomessages.get(0).text()); + assertEquals("platform.xclass.defaultClassSheet.template.description [, ]", infomessages.get(1).text()); + assertEquals("platform.xclass.defaultClassSheet.templateProvider.description []", infomessages.get(2).text()); + assertNotNull(infomessages.get(2).selectFirst("em")); + Elements links = document.select("a"); + Element lastLink = links.get(links.size() - 1); + assertEquals("platform.xclass.defaultClassSheet.templateProvider.view [Space / PageTemplateProvider] »", + lastLink.text()); + assertEquals("Space.PageTemplateProvider", lastLink.attr("href")); + } + + private Document render(XWikiDocument doc) throws XWikiException + { + return Jsoup.parse(doc.getRenderedContent(this.context)); + } + + /** + * TODO: Create the class because it is required but we currently do not support loading documents from other + * modules. An option would be to move ClassSheetBinding to a mandatory document initializer. + */ + private void initializeClassSheetBinding() throws Exception + { + XWikiDocument document = this.xwiki.getDocument(CLASS_SHEET_BINDING_DOCUMENT_REFERENCE, this.context); + + if (!document.isNew()) { + return; + } + + BaseClass xClass = document.getXClass(); + xClass.addPageField("sheet", "Sheet", 30, false, false, ""); + + this.xwiki.saveDocument(document, this.context); + } +}
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-mjw9-3f9f-jq2wghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-29522ghsaADVISORY
- github.com/xwiki/xwiki-platform/commit/d7e56185376641ee5d66477c6b2791ca8e85cfeeghsax_refsource_MISCWEB
- github.com/xwiki/xwiki-platform/security/advisories/GHSA-mjw9-3f9f-jq2wghsax_refsource_CONFIRMWEB
- jira.xwiki.org/browse/XWIKI-20456ghsax_refsource_MISCWEB
News mentions
0No linked articles in our index yet.