CVE-2024-31983 (published 2024-04-10T20:15Z)

XWiki Platform is a generic wiki platform. In multilingual wikis, translations can be edited by any user who has edit right, circumventing the rights that are normally required for authoring translations (script right for user-scope translations, wiki admin for translations on the wiki). Starting in version 4.3-milestone-2 and prior to versions 4.10.20, 15.5.4, and 15.10-rc-1, this can be exploited for remote code execution if the translation value is not properly escaped where it is used. This has been patched in XWiki 14.10.20, 15.5.4 and 15.10RC1. As a workaround, one may restrict edit rights on documents that contain translations.


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: pjeanjean
author_date: 2023-10-24 16:47:32+02:00
msg: XWIKI-21411: Improve check of translation document author rights

* Check both author and content author and improve JavaDoc
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: AbstractDocumentTranslationBundle.java
change_type: ModificationType.MODIFY
diff: @@ -138,19 +138,25 @@ protected void setReference(DocumentReference reference)
         setId(this.idPrefix + this.serializer.serialize(reference));
     }
 
-    protected LocalizedTranslationBundle loadDocumentLocaleBundle(Locale locale) throws Exception
+    /**
+     * Gets the document that defines the translation bundle for a given locale.
+     *
+     * @param locale the requested locale
+     * @return the document defining the translation bundle, or null if it could not be fetched yet and requires a retry
+     */
+    protected XWikiDocument getDocumentLocaleBundle(Locale locale) throws Exception
     {
         XWikiContext context = this.contextProvider.get();
 
         if (context == null) {
-            // No context for some reason, lets try later
+            // No context for some reason, let's try later.
             return null;
         }
 
         XWiki xwiki = context.getWiki();
 
         if (xwiki == null) {
-            // No XWiki instance ready, lets try later
+            // No XWiki instance ready, let's try later.
             return null;
         }
 
@@ -158,11 +164,23 @@ protected LocalizedTranslationBundle loadDocumentLocaleBundle(Locale locale) thr
 
         if (locale != null && !locale.equals(Locale.ROOT) && !locale.equals(document.getDefaultLocale())) {
             document = xwiki.getDocument(new DocumentReference(document.getDocumentReference(), locale), context);
+        }
 
-            if (document.isNew()) {
-                // No document found for this locale
-                return LocalizedTranslationBundle.EMPTY;
-            }
+        return document;
+    }
+
+    protected LocalizedTranslationBundle loadDocumentLocaleBundle(Locale locale) throws Exception
+    {
+        XWikiDocument document = getDocumentLocaleBundle(locale);
+
+        if (document == null) {
+            // Either no context or XWiki instance not ready, let's try later.
+            return null;
+        }
+
+        if (document.isNew()) {
+            // No document found for this locale.
+            return LocalizedTranslationBundle.EMPTY;
         }
 
         String content = document.getContent();
@@ -170,7 +188,7 @@ protected LocalizedTranslationBundle loadDocumentLocaleBundle(Locale locale) thr
         Properties properties = new Properties();
         properties.load(new StringReader(content));
 
-        // Convert to LocalBundle
+        // Convert to LocalBundle.
         DefaultLocalizedTranslationBundle localeBundle = new DefaultLocalizedTranslationBundle(this, locale);
 
         TranslationMessageParser parser = getTranslationMessageParser();

programming_language: Java
filename: ComponentDocumentTranslationBundle.java
change_type: ModificationType.MODIFY
diff: @@ -19,6 +19,8 @@
  */
 package org.xwiki.localization.wiki.internal;
 
+import java.util.Locale;
+
 import org.xwiki.bridge.event.WikiDeletedEvent;
 import org.xwiki.component.descriptor.ComponentDescriptor;
 import org.xwiki.component.manager.ComponentLookupException;
@@ -27,6 +29,11 @@
 import org.xwiki.localization.message.TranslationMessageParser;
 import org.xwiki.model.reference.DocumentReference;
 import org.xwiki.observation.event.Event;
+import org.xwiki.security.authorization.AccessDeniedException;
+
+import com.xpn.xwiki.XWiki;
+import com.xpn.xwiki.XWikiContext;
+import com.xpn.xwiki.doc.XWikiDocument;
 
 /**
  * Component wiki document based implementation of Bundle.
@@ -37,6 +44,8 @@
  */
 public class ComponentDocumentTranslationBundle extends AbstractDocumentTranslationBundle
 {
+    private DocumentTranslationBundleFactory factory;
+
     private ComponentDescriptor<TranslationBundle> descriptor;
 
     /**
@@ -45,17 +54,54 @@ public class ComponentDocumentTranslationBundle extends AbstractDocumentTranslat
      * @param componentManager used to lookup components needed to manipulate wiki documents
      * @param translationMessageParser the parser to use for each message
      * @param descriptor the component descriptor used to unregister the bundle
+     * @param factory the factory
      * @throws ComponentLookupException failed to lookup some required components
      */
     public ComponentDocumentTranslationBundle(String idPrefix, DocumentReference documentReference,
         ComponentManager componentManager, TranslationMessageParser translationMessageParser,
-        ComponentDescriptor<TranslationBundle> descriptor) throws ComponentLookupException
+        ComponentDescriptor<TranslationBundle> descriptor, DocumentTranslationBundleFactory factory)
+        throws ComponentLookupException
     {
         super(idPrefix, documentReference, componentManager, translationMessageParser);
 
+        this.factory = factory;
         this.descriptor = descriptor;
     }
 
+    /**
+     * {@inheritDoc}
+     * This overrides the default implementation to first check the author rights for the document.
+     *
+     * @param locale the requested locale
+     * @return the document defining the translation bundle if its author has the necessary rights, the default locale
+     *     otherwise, and fallback on the original implementation if the document doesn't exist or could not be fetched
+     */
+    @Override
+    protected XWikiDocument getDocumentLocaleBundle(Locale locale) throws Exception
+    {
+        XWikiDocument document = super.getDocumentLocaleBundle(locale);
+
+        if (document != null && !document.isNew()) {
+            XWikiContext context = this.contextProvider.get();
+            XWiki xwiki = context.getWiki();
+            XWikiDocument defaultLocaleDocument = xwiki.getDocument(this.documentReference, context);
+
+            if (defaultLocaleDocument != document) {
+                // We only need to check rights for non-default locales.
+                try {
+                    this.factory.checkRegistrationAuthorizationForDocumentLocaleBundle(document, defaultLocaleDocument);
+                } catch (AccessDeniedException e) {
+                    this.logger.warn("Failed to load and register the translation for locale [{}] from document [{}]. "
+                        + "Falling back to default locale.", locale, document.getDocumentReference());
+                    // We return the default translation bundle if the requested one has permission issues.
+                    return defaultLocaleDocument;
+                }
+            }
+        }
+
+        return document;
+    }
+
     @Override
     public void onEvent(Event event, Object source, Object data)
     {

programming_language: Java
filename: DocumentTranslationBundleFactory.java
change_type: ModificationType.MODIFY
diff: @@ -56,6 +56,7 @@
 import org.xwiki.localization.wiki.internal.TranslationDocumentModel.Scope;
 import org.xwiki.model.reference.DocumentReference;
 import org.xwiki.model.reference.DocumentReferenceResolver;
+import org.xwiki.model.reference.EntityReference;
 import org.xwiki.model.reference.EntityReferenceSerializer;
 import org.xwiki.model.reference.WikiReference;
 import org.xwiki.observation.EventListener;
@@ -324,7 +325,7 @@ private ComponentDocumentTranslationBundle createComponentDocumentBundle(XWikiDo
         try {
             documentBundle =
                 new ComponentDocumentTranslationBundle(ID_PREFIX, document.getDocumentReference(),
-                    this.componentManagerProvider.get(), this.translationParser, descriptor);
+                    this.componentManagerProvider.get(), this.translationParser, descriptor, this);
         } catch (ComponentLookupException e) {
             throw new TranslationBundleDoesNotExistsException("Failed to create document bundle", e);
         }
@@ -434,6 +435,23 @@ private void registerTranslationBundle(XWikiDocument document) throws Translatio
         }
     }
 
+    /**
+     * Checks that the author of a document defining a translation bundle has the necessary rights to make it
+     * available, based on the scope of the default locale translation bundle.
+     *
+     * @param document the document defining the translation bundle to check
+     * @param defaultLocaleDocument the document containing the default locale translation bundle
+     * @throws AccessDeniedException when the document author does not have enough rights for the defined scope
+     */
+    protected void checkRegistrationAuthorizationForDocumentLocaleBundle(XWikiDocument document,
+        XWikiDocument defaultLocaleDocument) throws AccessDeniedException
+    {
+        Scope scope = getScope(defaultLocaleDocument);
+        if (scope != null && scope != Scope.ON_DEMAND) {
+            checkRegistrationAuthorization(document, scope);
+        }
+    }
+
     /**
      * @param document the translation document
      * @param scope the scope
@@ -442,18 +460,24 @@ private void registerTranslationBundle(XWikiDocument document) throws Translatio
      */
     private void checkRegistrationAuthorization(XWikiDocument document, Scope scope) throws AccessDeniedException
     {
+        EntityReference entityReference;
         switch (scope) {
             case GLOBAL:
                 this.authorizationManager.checkAccess(Right.PROGRAM, document.getAuthorReference(), null);
+                this.authorizationManager.checkAccess(Right.PROGRAM, document.getContentAuthorReference(), null);
                 break;
             case WIKI:
-                this.authorizationManager.checkAccess(Right.ADMIN, document.getAuthorReference(), document
-                    .getDocumentReference().getWikiReference());
+                entityReference = document.getDocumentReference().getWikiReference();
+                this.authorizationManager.checkAccess(Right.ADMIN, document.getAuthorReference(), entityReference);
+                this.authorizationManager.checkAccess(Right.ADMIN, document.getContentAuthorReference(),
+                    entityReference);
                 break;
             case USER:
                 if (this.configuration.isRestrictUserTranslations()) {
-                    this.authorizationManager.checkAccess(Right.SCRIPT, document.getAuthorReference(),
-                        document.getDocumentReference());
+                    entityReference = document.getDocumentReference();
+                    this.authorizationManager.checkAccess(Right.SCRIPT, document.getAuthorReference(), entityReference);
+                    this.authorizationManager.checkAccess(Right.SCRIPT, document.getContentAuthorReference(),
+                        entityReference);
                 }
                 break;
             default:

programming_language: Java
filename: ComponentDocumentTranslationBundleTest.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,208 @@
+/*
+ * 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.localization.wiki.internal;
+
+import java.util.Collections;
+import java.util.Locale;
+
+import javax.inject.Inject;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.mockito.Mock;
+import org.xwiki.cache.Cache;
+import org.xwiki.cache.CacheManager;
+import org.xwiki.cache.config.CacheConfiguration;
+import org.xwiki.component.internal.multi.ComponentManagerManager;
+import org.xwiki.job.event.status.JobProgressManager;
+import org.xwiki.localization.LocalizationManager;
+import org.xwiki.localization.Translation;
+import org.xwiki.localization.TranslationBundleFactory;
+import org.xwiki.localization.internal.DefaultLocalizationManager;
+import org.xwiki.localization.internal.DefaultTranslationBundleContext;
+import org.xwiki.localization.messagetool.internal.MessageToolTranslationMessageParser;
+import org.xwiki.model.internal.DefaultModelContext;
+import org.xwiki.model.reference.DocumentReference;
+import org.xwiki.observation.internal.DefaultObservationManager;
+import org.xwiki.query.Query;
+import org.xwiki.query.QueryManager;
+import org.xwiki.rendering.internal.parser.plain.PlainTextBlockParser;
+import org.xwiki.rendering.internal.renderer.plain.PlainTextBlockRenderer;
+import org.xwiki.rendering.internal.renderer.plain.PlainTextRendererFactory;
+import org.xwiki.rendering.syntax.Syntax;
+import org.xwiki.security.authorization.AccessDeniedException;
+import org.xwiki.security.authorization.Right;
+import org.xwiki.test.LogLevel;
+import org.xwiki.test.annotation.BeforeComponent;
+import org.xwiki.test.annotation.ComponentList;
+import org.xwiki.test.junit5.LogCaptureExtension;
+import org.xwiki.test.junit5.mockito.MockComponent;
+
+import com.xpn.xwiki.doc.XWikiDocument;
+import com.xpn.xwiki.objects.BaseObject;
+import com.xpn.xwiki.test.MockitoOldcore;
+import com.xpn.xwiki.test.junit5.mockito.InjectMockitoOldcore;
+import com.xpn.xwiki.test.junit5.mockito.OldcoreTest;
+import com.xpn.xwiki.test.reference.ReferenceComponentList;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link ComponentDocumentTranslationBundle}.
+ *
+ * @version $Id$
+ */
+@OldcoreTest
+@ComponentList({
+    DocumentTranslationBundleFactory.class,
+    DefaultLocalizationManager.class,
+    DefaultTranslationBundleContext.class,
+    TranslationDocumentClassInitializer.class,
+    DefaultModelContext.class,
+    PlainTextBlockRenderer.class,
+    PlainTextRendererFactory.class,
+    DefaultObservationManager.class,
+    MessageToolTranslationMessageParser.class,
+    PlainTextBlockParser.class
+})
+@ReferenceComponentList
+class ComponentDocumentTranslationBundleTest
+{
+    private static final DocumentReference TRANSLATION_ROOT_REFERENCE = new DocumentReference("xwiki", "space",
+        "Translations");
+
+    private static final DocumentReference ADMIN_USER_REFERENCE = new DocumentReference("xwiki", "XWiki", "XWikiAdmin");
+
+    @InjectMockitoOldcore
+    private MockitoOldcore oldcore;
+
+    @MockComponent
+    private WikiTranslationConfiguration translationConfiguration;
+
+    @MockComponent
+    private QueryManager mockQueryManager;
+
+    @Mock
+    private Query mockQuery;
+
+    @MockComponent
+    private ComponentManagerManager componentManagerManager;
+
+    @MockComponent
+    private JobProgressManager jobProgressManager;
+
+    @Inject
+    private LocalizationManager localization;
+
+    private XWikiDocument translationFrDocument;
+
+    private DocumentReference adminUserReference;
+
+    /**
+     * Capture logs.
+     */
+    @RegisterExtension
+    private LogCaptureExtension logCapture = new LogCaptureExtension(LogLevel.WARN);
+
+    @MockComponent
+    private CacheManager cacheManager;
+
+    @BeforeComponent
+    void before() throws Exception
+    {
+        Cache<Object> cache = mock(Cache.class);
+        when(this.cacheManager.createNewCache(any(CacheConfiguration.class))).thenReturn(cache);
+    }
+
+    @BeforeEach
+    void setUp() throws Exception
+    {
+        this.oldcore.notifyDocumentCreatedEvent(true);
+
+        when(this.mockQueryManager.createQuery(anyString(), anyString())).thenReturn(this.mockQuery);
+        when(this.mockQuery.execute()).thenReturn(Collections.emptyList());
+
+        when(this.componentManagerManager.getComponentManager("wiki:xwiki", true)).thenReturn(this.oldcore.getMocker());
+        this.oldcore.getMocker().getInstance(TranslationBundleFactory.class, DocumentTranslationBundleFactory.ID);
+
+        XWikiDocument translationRootDocument = this.oldcore.getSpyXWiki().getDocument(TRANSLATION_ROOT_REFERENCE,
+            this.oldcore.getXWikiContext());
+
+        BaseObject translationObject = translationRootDocument.newXObject(
+            new DocumentReference("xwiki", "XWiki", "TranslationDocumentClass"),
+            this.oldcore.getXWikiContext());
+        translationObject.setStringValue(TranslationDocumentModel.TRANSLATIONCLASS_PROP_SCOPE,
+            TranslationDocumentModel.Scope.WIKI.toString());
+
+        translationRootDocument.setSyntax(Syntax.PLAIN_1_0);
+        translationRootDocument.setContent("xwiki.translation=root");
+        translationRootDocument.setAuthorReference(ADMIN_USER_REFERENCE);
+        this.oldcore.getSpyXWiki().saveDocument(translationRootDocument, this.oldcore.getXWikiContext());
+
+        this.translationFrDocument = translationRootDocument.getTranslatedDocument(Locale.FRENCH,
+            this.oldcore.getXWikiContext());
+        if (this.translationFrDocument == translationRootDocument) {
+            this.translationFrDocument =
+                new XWikiDocument(this.translationFrDocument.getDocumentReference(), Locale.FRENCH);
+            this.translationFrDocument.setDefaultLocale(this.translationFrDocument.getDefaultLocale());
+        }
+        this.translationFrDocument.setSyntax(Syntax.PLAIN_1_0);
+        this.translationFrDocument.setContent("xwiki.translation=fr");
+        this.oldcore.getSpyXWiki().saveDocument(this.translationFrDocument, this.oldcore.getXWikiContext());
+
+        doThrow(new AccessDeniedException(Right.SCRIPT, null, translationRootDocument.getDocumentReference()))
+            .when(this.oldcore.getMockAuthorizationManager()).checkAccess(Right.ADMIN, null,
+                TRANSLATION_ROOT_REFERENCE.getWikiReference());
+    }
+
+    @Test
+    void checkTranslationWithExpectedRights() throws Exception
+    {
+        this.translationFrDocument.setAuthorReference(ADMIN_USER_REFERENCE);
+        this.translationFrDocument.setContentAuthorReference(ADMIN_USER_REFERENCE);
+        this.oldcore.getSpyXWiki().saveDocument(this.translationFrDocument, this.oldcore.getXWikiContext());
+        Translation frTranslation = this.localization.getTranslation("xwiki.translation", Locale.FRENCH);
+        assertEquals("fr", frTranslation.getRawSource());
+        // Authorizations are checked twice because the mocked behavior is not actual locale bundle registration.
+        verify(this.oldcore.getMockAuthorizationManager(), times(4)).checkAccess(Right.ADMIN, ADMIN_USER_REFERENCE,
+            TRANSLATION_ROOT_REFERENCE.getWikiReference());
+    }
+
+    @Test
+    void checkTranslationWithoutExpectedRights() throws Exception
+    {
+        Translation frTranslation = this.localization.getTranslation("xwiki.translation", Locale.FRENCH);
+        assertEquals(
+            "Failed to load and register the translation for locale [fr] from document [xwiki:space.Translations]. "
+                + "Falling back to default locale.",
+            this.logCapture.getMessage(0));
+        assertEquals("root", frTranslation.getRawSource());
+        verify(this.oldcore.getMockAuthorizationManager()).checkAccess(Right.ADMIN, null,
+            TRANSLATION_ROOT_REFERENCE.getWikiReference());
+    }
+}

programming_language: Java