CVE-2024-31997 (published 2024-04-10T22:15Z)

XWiki Platform is a generic wiki platform. Prior to versions 4.10.19, 15.5.4, and 15.10-rc-1, parameters of UI extensions are always interpreted as Velocity code and executed with programming rights. Any user with edit right on any document like the user's own profile can create UI extensions. This allows remote code execution and thereby impacts the confidentiality, integrity and availability of the whole XWiki installation. This vulnerability has been patched in XWiki 14.10.19, 15.5.4 and 15.9-RC1. No known workarounds are available.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/xwiki/xwiki-platform
commit_details: repo_url: https://github.com/xwiki/xwiki-platform
author: Michael Hamann
author_date: 2023-10-13 16:28:52+02:00
msg: XWIKI-21335: Execute UI Extension parameters with the rights of their author

* Check script right for the UI extension's author
* Use AuthorExecutor to set the context author
repository: repo_url: https://github.com/xwiki/xwiki-platform
repo_name: xwiki/xwiki-platform
description: The XWiki platform
repo_language: Java
owner: xwiki
file_changes: filename: WikiUIExtensionComponentBuilder.java
change_type: ModificationType.MODIFY
diff: @@ -136,12 +136,9 @@ public List<WikiComponent> buildComponents(BaseObject baseObject) throws WikiCom
                 String.format("Failed to initialize Panel UI extension [%s]", baseObject.getReference()), e);
         }
 
-        String rawParameters = baseObject.getStringValue(PARAMETERS_PROPERTY);
-
         // It would be nice to have PER_LOOKUP components for UIX parameters but without constructor injection it's
         // safer to use a POJO and pass the Component Manager to it.
-        WikiUIExtensionParameters parameters =
-            new WikiUIExtensionParameters(id, rawParameters, this.wikiComponentManager);
+        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters(baseObject, this.wikiComponentManager);
         extension.setParameters(parameters);
         extension.setScope(scope);
 

programming_language: Java
filename: WikiUIExtensionParameters.java
change_type: ModificationType.MODIFY
diff: @@ -22,9 +22,10 @@
 import java.io.IOException;
 import java.io.StringReader;
 import java.io.StringWriter;
-import java.util.HashMap;
 import java.util.Map;
 import java.util.Properties;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.exception.ExceptionUtils;
@@ -38,11 +39,20 @@
 import org.xwiki.logging.LoggerConfiguration;
 import org.xwiki.model.EntityType;
 import org.xwiki.model.ModelContext;
+import org.xwiki.model.reference.DocumentReference;
+import org.xwiki.security.authorization.AuthorExecutor;
+import org.xwiki.security.authorization.AuthorizationManager;
+import org.xwiki.security.authorization.Right;
 import org.xwiki.velocity.VelocityEngine;
 import org.xwiki.velocity.VelocityManager;
 import org.xwiki.velocity.XWikiVelocityContext;
 import org.xwiki.velocity.XWikiVelocityException;
 
+import com.xpn.xwiki.objects.BaseObject;
+
+import static org.xwiki.uiextension.internal.WikiUIExtensionConstants.ID_PROPERTY;
+import static org.xwiki.uiextension.internal.WikiUIExtensionConstants.PARAMETERS_PROPERTY;
+
 /**
  * Wiki UI Extension parameter manager.
  *
@@ -97,25 +107,37 @@
      */
     private Execution execution;
 
+    private AuthorExecutor authorExecutor;
+
+    private final DocumentReference documentReference;
+
+    private final DocumentReference authorReference;
+
+    private final AuthorizationManager authorizationManager;
+
     /**
      * Default constructor.
      *
-     * @param id the unique identifier of this set of parameters, mostly used to isolate parameters value execution
-     * @param rawParameters raw parameters, their values can contain velocity directives
+     * @param baseObject the object from which the parameters shall be loaded
      * @param cm the XWiki component manager
      * @throws WikiComponentException if some required components can't be found in the Component Manager
      */
-    public WikiUIExtensionParameters(String id, String rawParameters, ComponentManager cm)
+    public WikiUIExtensionParameters(BaseObject baseObject, ComponentManager cm)
         throws WikiComponentException
     {
-        this.id = id;
-        this.parameters = parseParameters(rawParameters);
+        this.id = baseObject.getStringValue(ID_PROPERTY);
+        this.parameters = parseParameters(baseObject.getStringValue(PARAMETERS_PROPERTY));
+
+        this.documentReference = baseObject.getDocumentReference();
+        this.authorReference = baseObject.getOwnerDocument().getAuthorReference();
 
         try {
             this.execution = cm.getInstance(Execution.class);
             this.velocityManager = cm.getInstance(VelocityManager.class);
             this.modelContext = cm.getInstance(ModelContext.class);
             this.loggerConfiguration = cm.getInstance(LoggerConfiguration.class);
+            this.authorExecutor = cm.getInstance(AuthorExecutor.class);
+            this.authorizationManager = cm.getInstance(AuthorizationManager.class);
         } catch (ComponentLookupException e) {
             throw new WikiComponentException(
                 "Failed to get an instance for a component role required by Wiki Components.", e);
@@ -148,7 +170,7 @@ private Properties parseParameters(String rawParameters)
      */
     public Map<String, String> get()
     {
-        boolean isCacheValid = false;
+        Map<String, String> result;
 
         // Even though the parameters are dynamic, we cache a rendered version of them in order to improve performance.
         // This cache has a short lifespan, it gets discarded for each new request, or if the database has been switched
@@ -158,20 +180,21 @@ public Map<String, String> get()
         if (currentContextId == this.previousContextId
                 && currentWiki.equals(previousWiki) && this.evaluatedParameters != null)
         {
-            isCacheValid = true;
-        }
-
-        if (!isCacheValid) {
-            this.evaluatedParameters = new HashMap<>();
-
-            if (this.parameters.size() > 0) {
+            result = this.evaluatedParameters;
+        } else {
+            result = this.parameters.stringPropertyNames().stream()
+                .filter(StringUtils::isNotBlank)
+                .collect(Collectors.toMap(Function.identity(), this.parameters::getProperty));
+
+            if (!this.parameters.isEmpty()
+                && this.authorizationManager.hasAccess(Right.SCRIPT, this.authorReference, this.documentReference))
+            {
                 try {
-                    VelocityEngine velocityEngine = this.velocityManager.getVelocityEngine();
-                    VelocityContext velocityContext = this.velocityManager.getVelocityContext();
+                    this.authorExecutor.call(() -> {
+                        VelocityEngine velocityEngine = this.velocityManager.getVelocityEngine();
+                        VelocityContext velocityContext = this.velocityManager.getVelocityContext();
 
-                    for (String propertyKey : this.parameters.stringPropertyNames()) {
-                        if (!StringUtils.isBlank(propertyKey)) {
-                            String propertyValue = this.parameters.getProperty(propertyKey);
+                        result.replaceAll((propertyKey, propertyValue) -> {
                             StringWriter writer = new StringWriter();
                             try {
                                 String namespace = this.id + ':' + propertyKey;
@@ -179,22 +202,28 @@ public Map<String, String> get()
                                     new XWikiVelocityContext(velocityContext,
                                         this.loggerConfiguration.isDeprecatedLogEnabled()),
                                     writer, namespace, propertyValue);
-                                this.evaluatedParameters.put(propertyKey, writer.toString());
+                                return writer.toString();
                             } catch (XWikiVelocityException e) {
                                 LOGGER.warn(String.format(
                                     "Failed to evaluate UI extension data value, key [%s], value [%s]. Reason: [%s]",
                                     propertyKey, propertyValue, e.getMessage()));
                             }
-                        }
-                    }
-                } catch (XWikiVelocityException ex) {
+
+                            return propertyValue;
+                        });
+
+                        return null;
+                    }, this.authorReference, this.documentReference);
+                } catch (Exception ex) {
                     LOGGER.warn(String.format("Failed to get velocity engine. Reason: [%s]", ex.getMessage()));
                 }
-                this.previousContextId = currentContextId;
-                this.previousWiki = currentWiki;
             }
+
+            this.evaluatedParameters = result;
+            this.previousContextId = currentContextId;
+            this.previousWiki = currentWiki;
         }
 
-        return this.evaluatedParameters;
+        return result;
     }
 }

programming_language: Java
filename: WikiUIExtensionComponentBuilderTest.java
change_type: ModificationType.MODIFY
diff: @@ -23,6 +23,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.concurrent.Callable;
 
 import org.apache.velocity.VelocityContext;
 import org.junit.jupiter.api.Test;
@@ -43,6 +44,9 @@
 import org.xwiki.rendering.transformation.RenderingContext;
 import org.xwiki.rendering.transformation.Transformation;
 import org.xwiki.rendering.util.ErrorBlockGenerator;
+import org.xwiki.security.authorization.AuthorExecutor;
+import org.xwiki.security.authorization.AuthorizationManager;
+import org.xwiki.security.authorization.Right;
 import org.xwiki.test.annotation.BeforeComponent;
 import org.xwiki.test.annotation.ComponentList;
 import org.xwiki.test.junit5.mockito.InjectMockComponents;
@@ -93,6 +97,12 @@ class WikiUIExtensionComponentBuilderTest implements WikiUIExtensionConstants
     @MockComponent
     private LoggerConfiguration loggerConfiguration;
 
+    @MockComponent
+    private AuthorExecutor authorExecutor;
+
+    @MockComponent
+    private AuthorizationManager authorizationManager;
+
     @InjectMockComponents
     private WikiUIExtensionComponentBuilder builder;
 
@@ -128,6 +138,13 @@ public void configure(MockitoComponentManager componentManager, MockitoOldcore o
         componentManager.registerMockComponent(Transformation.class, "macro");
         componentManager.registerMockComponent(ContentParser.class);
 
+        when(this.authorExecutor.call(any(), any(), any())).thenAnswer(invocation -> {
+            Callable<?> callable = invocation.getArgument(0);
+            return callable.call();
+        });
+
+        when(this.authorizationManager.hasAccess(Right.SCRIPT, AUTHOR_REFERENCE, DOC_REF)).thenReturn(true);
+
         // The document holding the UI extension object.
         this.componentDoc = mock(XWikiDocument.class, "xwiki:XWiki.MyUIExtension");
         when(this.componentDoc.getDocumentReference()).thenReturn(DOC_REF);
@@ -197,6 +214,7 @@ private BaseObject createExtensionObject(String id, String extensionPointId, Str
         BaseObjectReference objectReference =
             new BaseObjectReference(new ObjectReference("XWiki.UIExtensionClass[0]", DOC_REF));
         when(extensionObject.getReference()).thenReturn(objectReference);
+        when(extensionObject.getDocumentReference()).thenReturn(DOC_REF);
 
         when(extensionObject.getOwnerDocument()).thenReturn(this.componentDoc);
 

programming_language: Java
filename: WikiUIExtensionParametersTest.java
change_type: ModificationType.MODIFY
diff: @@ -20,6 +20,7 @@
 package org.xwiki.uiextension;
 
 import java.io.StringWriter;
+import java.util.concurrent.Callable;
 
 import org.apache.commons.collections.MapUtils;
 import org.apache.velocity.VelocityContext;
@@ -31,25 +32,35 @@
 import org.xwiki.context.ExecutionContext;
 import org.xwiki.logging.LoggerConfiguration;
 import org.xwiki.model.ModelContext;
+import org.xwiki.model.reference.DocumentReference;
 import org.xwiki.model.reference.WikiReference;
+import org.xwiki.security.authorization.AuthorExecutor;
+import org.xwiki.security.authorization.AuthorizationManager;
+import org.xwiki.security.authorization.Right;
 import org.xwiki.test.LogLevel;
 import org.xwiki.test.junit5.LogCaptureExtension;
 import org.xwiki.test.junit5.mockito.ComponentTest;
 import org.xwiki.test.junit5.mockito.InjectComponentManager;
 import org.xwiki.test.junit5.mockito.MockComponent;
 import org.xwiki.test.mockito.MockitoComponentManager;
+import org.xwiki.uiextension.internal.WikiUIExtensionConstants;
 import org.xwiki.uiextension.internal.WikiUIExtensionParameters;
 import org.xwiki.velocity.VelocityEngine;
 import org.xwiki.velocity.VelocityManager;
 import org.xwiki.velocity.XWikiVelocityException;
 
+import com.xpn.xwiki.doc.XWikiDocument;
+import com.xpn.xwiki.objects.BaseObject;
+
 import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 /**
@@ -61,6 +72,10 @@
 @ComponentTest
 class WikiUIExtensionParametersTest
 {
+    private static final DocumentReference AUTHOR_REFERENCE = new DocumentReference("xwiki", "XWiki", "XWikiAdmin");
+
+    private static final DocumentReference DOCUMENT_REFERENCE = new DocumentReference("xwiki", "Space", "UIX");
+
     @Mock
     private VelocityEngine velocityEngine;
 
@@ -76,6 +91,12 @@
     @MockComponent
     private LoggerConfiguration loggerConfiguration;
 
+    @MockComponent
+    private AuthorExecutor authorExecutor;
+
+    @MockComponent
+    private AuthorizationManager authorizationManager;
+
     @RegisterExtension
     private final LogCaptureExtension logCapture = new LogCaptureExtension(LogLevel.WARN);
 
@@ -91,24 +112,33 @@ public void setUp() throws Exception
         when(this.execution.getContext()).thenReturn(executionContext);
         when(this.velocityManager.getVelocityContext()).thenReturn(velocityContext);
         when(this.velocityManager.getVelocityEngine()).thenReturn(this.velocityEngine);
+
+        when(this.authorExecutor.call(any(), any(), any())).thenAnswer(invocation -> {
+            Callable<?> callable = invocation.getArgument(0);
+            return callable.call();
+        });
+
+        when(this.authorizationManager.hasAccess(Right.SCRIPT, AUTHOR_REFERENCE, DOCUMENT_REFERENCE)).thenReturn(true);
+
+        when(this.modelContext.getCurrentEntityReference()).thenReturn(new WikiReference("xwiki"));
     }
 
     @Test
     void getParametersWithAnEmptyParametersProperty() throws Exception
     {
-        when(this.modelContext.getCurrentEntityReference()).thenReturn(new WikiReference("xwiki"));
-        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters("id", "", this.componentManager);
+        BaseObject mockUIX = constructMockUIXObject("");
+        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters(mockUIX, this.componentManager);
         assertEquals(MapUtils.EMPTY_MAP, parameters.get());
     }
 
     @Test
     void getParametersWithAnEqualSignInAValue() throws Exception
     {
-        when(this.modelContext.getCurrentEntityReference()).thenReturn(new WikiReference("xwiki"));
         when(this.velocityEngine
             .evaluate(any(VelocityContext.class), any(StringWriter.class), eq("id:key"), eq("value")))
             .thenReturn(true);
-        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters("id", "key=value", this.componentManager);
+        BaseObject mockUIX = constructMockUIXObject("key=value");
+        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters(mockUIX, this.componentManager);
 
         // Since the StringWriter is created within the method, the value is "" and not "value".
         assertEquals("", parameters.get().get("key"));
@@ -117,15 +147,13 @@ void getParametersWithAnEqualSignInAValue() throws Exception
     @Test
     void getParametersWithCommentAloneOnLine() throws Exception
     {
-        when(this.modelContext.getCurrentEntityReference()).thenReturn(new WikiReference("xwiki"));
-
         String paramsStr = "# a = 1\n"
             + "x=1\n"
             + "y=2\n"
             + "# ...\n"
             + "z=3";
-        WikiUIExtensionParameters parameters =
-            new WikiUIExtensionParameters("id", paramsStr, this.componentManager);
+        BaseObject mockUIX = constructMockUIXObject(paramsStr);
+        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters(mockUIX, this.componentManager);
         parameters.get();
 
         verify(this.velocityEngine).evaluate(any(), any(), eq("id:x"), eq("1"));
@@ -136,13 +164,11 @@ void getParametersWithCommentAloneOnLine() throws Exception
     @Test
     void getParametersWithCommentEndOfLine() throws Exception
     {
-        when(this.modelContext.getCurrentEntityReference()).thenReturn(new WikiReference("xwiki"));
-
         String paramsStr = "x=1##b\n"
             + "y=2####x\n"
             + "z=3 ## xyz\n";
-        WikiUIExtensionParameters parameters =
-            new WikiUIExtensionParameters("id", paramsStr, this.componentManager);
+        BaseObject mockUIX = constructMockUIXObject(paramsStr);
+        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters(mockUIX, this.componentManager);
         parameters.get();
 
         verify(this.velocityEngine).evaluate(any(), any(), eq("id:x"), eq("1##b"));
@@ -153,14 +179,14 @@ void getParametersWithCommentEndOfLine() throws Exception
     @Test
     void getParametersWhenVelocityFails() throws Exception
     {
-        when(this.modelContext.getCurrentEntityReference()).thenReturn(new WikiReference("xwiki"));
         when(this.velocityEngine
             .evaluate(any(VelocityContext.class), any(StringWriter.class), eq("id:key"), eq("value")))
             .thenThrow(new XWikiVelocityException(""));
-        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters("id", "key=value", this.componentManager);
+        BaseObject mockUIX = constructMockUIXObject("key=value");
+        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters(mockUIX, this.componentManager);
 
-        // It should fail and put a warn in the logs
-        assertNull(parameters.get().get("key"));
+        // It put a warning in the logs and return the not-evaluated value.
+        assertEquals("value", parameters.get().get("key"));
         assertEquals("Failed to evaluate UI extension data value, key [key], value [value]. Reason: []",
             this.logCapture.getMessage(0));
     }
@@ -168,11 +194,11 @@ void getParametersWhenVelocityFails() throws Exception
     @Test
     void getParametersFromTheSameRequestAndForTheSameWiki() throws Exception
     {
-        when(this.modelContext.getCurrentEntityReference()).thenReturn(new WikiReference("xwiki"));
         when(this.velocityEngine
             .evaluate(any(VelocityContext.class), any(StringWriter.class), eq("id:key"), eq("value")))
             .thenReturn(true);
-        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters("id", "key=value", this.componentManager);
+        BaseObject mockUIX = constructMockUIXObject("key=value");
+        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters(mockUIX, this.componentManager);
 
         // It should fail silently
         assertEquals("", parameters.get().get("key"));
@@ -191,7 +217,8 @@ void getParametersFromTheSameRequestButForDifferentWikis() throws Exception
         when(this.velocityEngine
             .evaluate(any(VelocityContext.class), any(StringWriter.class), eq("id:key"), eq("value")))
             .thenReturn(true);
-        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters("id", "key=value", this.componentManager);
+        BaseObject mockUIX = constructMockUIXObject("key=value");
+        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters(mockUIX, this.componentManager);
 
         // It should fail silently
         assertEquals("", parameters.get().get("key"));
@@ -205,11 +232,11 @@ void getParametersFromTheSameRequestButForDifferentWikis() throws Exception
     @Test
     void getParametersFromDifferentRequests() throws Exception
     {
-        when(this.modelContext.getCurrentEntityReference()).thenReturn(new WikiReference("wiki1"));
         when(this.velocityEngine
             .evaluate(any(VelocityContext.class), any(StringWriter.class), eq("id:key"), eq("value")))
             .thenReturn(true);
-        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters("id", "key=value", this.componentManager);
+        BaseObject mockUIX = constructMockUIXObject("key=value");
+        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters(mockUIX, this.componentManager);
 
         ExecutionContext ec1 = mock(ExecutionContext.class, "ec1");
         ExecutionContext ec2 = mock(ExecutionContext.class, "ec2");
@@ -223,4 +250,51 @@ void getParametersFromDifferentRequests() throws Exception
         verify(this.velocityEngine, times(2))
             .evaluate(any(VelocityContext.class), any(StringWriter.class), eq("id:key"), eq("value"));
     }
+
+    @Test
+    void getParametersWithoutScriptRight() throws Exception
+    {
+        when(this.authorizationManager.hasAccess(Right.SCRIPT, AUTHOR_REFERENCE, DOCUMENT_REFERENCE)).thenReturn(false);
+        BaseObject mockUIX = constructMockUIXObject("key=value");
+        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters(mockUIX, this.componentManager);
+
+        assertEquals("value", parameters.get().get("key"));
+        verifyNoInteractions(this.velocityEngine);
+    }
+
+    @Test
+    void getParametersWithAuthorExecutor() throws Exception
+    {
+        // Do not call the callable to check that the call to the Velocity engine is inside the author executor.
+        doReturn(null).when(this.authorExecutor).call(any(), any(), any());
+
+        BaseObject mockUIX = constructMockUIXObject("key=value");
+        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters(mockUIX, this.componentManager);
+
+        assertEquals("value", parameters.get().get("key"));
+        verify(this.authorExecutor).call(any(), eq(AUTHOR_REFERENCE), eq(DOCUMENT_REFERENCE));
+        verifyNoInteractions(this.velocityEngine);
+    }
+
+    @Test
+    void getParametersWithEmptyKey() throws Exception
+    {
+        BaseObject mockUIX = constructMockUIXObject("=value");
+        WikiUIExtensionParameters parameters = new WikiUIExtensionParameters(mockUIX, this.componentManager);
+
+        assertTrue(parameters.get().isEmpty());
+    }
+
+    private BaseObject constructMockUIXObject(String parameters)
+    {
+        BaseObject result = mock();
+
+        when(result.getStringValue(WikiUIExtensionConstants.ID_PROPERTY)).thenReturn("id");
+        when(result.getStringValue(WikiUIExtensionConstants.PARAMETERS_PROPERTY)).thenReturn(parameters);
+        when(result.getOwnerDocument()).thenReturn(mock(XWikiDocument.class));
+        when(result.getOwnerDocument().getAuthorReference()).thenReturn(WikiUIExtensionParametersTest.AUTHOR_REFERENCE);
+        when(result.getDocumentReference()).thenReturn(WikiUIExtensionParametersTest.DOCUMENT_REFERENCE);
+
+        return result;
+    }
 }

programming_language: Java