CVE-2023-48240 (published 2023-11-20T18:15Z)

XWiki Platform is a generic wiki platform. The rendered diff in XWiki embeds images to be able to compare the contents and not display a difference for an actually unchanged image. For this, XWiki requests all embedded images on the server side. These requests are also sent for images from other domains and include all cookies that were sent in the original request to ensure that images with restricted view right can be compared. Starting in version 11.10.1 and prior to versions 14.10.15, 15.5.1, and 15.6, this allows an attacker to steal login and session cookies that allow impersonating the current user who views the diff. The attack can be triggered with an image that references the rendered diff, thus making it easy to trigger. Apart from stealing login cookies, this also allows server-side request forgery (the result of any successful request is returned in the image's source) and viewing protected content as once a resource is cached, it is returned for all users. As only successful requests are cached, the cache will be filled by the first user who is allowed to access the resource. This has been patched in XWiki 14.10.15, 15.5.1 and 15.6. The rendered diff now only downloads images from trusted domains. Further, cookies are only sent when the image's domain is the same the requested domain. The cache has been changed to be specific for each user. As a workaround, the image embedding feature can be disabled by deleting `xwiki-platform-diff-xml-<version>.jar` in `WEB-INF/lib/`.

CVSS v3 base score: 8.8, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-918
cwe_name: Server-Side Request Forgery (SSRF)
description: The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
extended_description: By providing URLs to unexpected hosts or ports, attackers can make it appear that the server is sending the request, possibly bypassing access controls such as firewalls that prevent the attackers from accessing the URLs directly. The server can be used as a proxy to conduct port scanning of hosts in internal networks, use other URLs such as that can access documents on the system (using file://), or use other protocols such as gopher:// or tftp://, which may provide greater control over the contents of requests.
         

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-04-25 15:42:05+02:00
msg: XWIKI-20818: Improve data URI converter

* Cache failures
* Properly dispose the caches
* Only send requests to trusted domains
* Only embed actual images
* Limit responses to 1MB
* Introduce configuration options for timeout, maximum size and if the
  feature is enabled at all
* Add a UI test that checks that attachment embedding is working in
  general
* Move to httpclient5
* Expose the cookie domains configuration in AuthenticationConfiguration
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: pom.xml
change_type: ModificationType.MODIFY
diff: @@ -31,9 +31,13 @@
   <name>XWiki Platform - Diff - XML</name>
   <description>XWiki Platform - Diff - XML</description>
   <properties>
-    <xwiki.jacoco.instructionRatio>0.00</xwiki.jacoco.instructionRatio>
+    <xwiki.jacoco.instructionRatio>0.77</xwiki.jacoco.instructionRatio>
   </properties>
   <dependencies>
+    <dependency>
+      <groupId>org.apache.httpcomponents.client5</groupId>
+      <artifactId>httpclient5</artifactId>
+    </dependency>
     <dependency>
       <groupId>org.xwiki.commons</groupId>
       <artifactId>xwiki-commons-diff-xml</artifactId>
@@ -44,9 +48,27 @@
       <artifactId>xwiki-platform-oldcore</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.xwiki.platform</groupId>
+      <artifactId>xwiki-platform-security-authentication-api</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>javax.servlet</groupId>
       <artifactId>javax.servlet-api</artifactId>
     </dependency>
+    <dependency>
+      <groupId>org.xwiki.commons</groupId>
+      <artifactId>xwiki-commons-tool-test-component</artifactId>
+      <version>${commons.version}</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.xwiki.platform</groupId>
+      <artifactId>xwiki-platform-oldcore</artifactId>
+      <version>${project.version}</version>
+      <scope>test</scope>
+      <type>test-jar</type>
+    </dependency>
   </dependencies>
 </project>
\ No newline at end of file

programming_language: HTML
filename: XMLDiffDataURIConverterConfiguration.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,51 @@
+/*
+ * 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.diff.xml;
+
+import org.xwiki.component.annotation.Role;
+import org.xwiki.stability.Unstable;
+
+/**
+ * Configuration for the data URI converter in the XML diff module.
+ *
+ * @since 14.10.15
+ * @since 15.5.1
+ * @since 15.6
+ * @version $Id$
+ */
+@Unstable
+@Role
+public interface XMLDiffDataURIConverterConfiguration
+{
+    /**
+     * @return the timeout to use when fetching data from the web to embed as data URI
+     */
+    int getHTTPTimeout();
+
+    /**
+     * @return the maximum size of the data to embed as data URI
+     */
+    long getMaximumContentSize();
+
+    /**
+     * @return true if the data URI converter is enabled
+     */
+    boolean isEnabled();
+}

programming_language: Java
filename: DefaultDataURIConverter.java
change_type: ModificationType.MODIFY
diff: @@ -21,7 +21,6 @@
 
 import java.io.IOException;
 import java.net.MalformedURLException;
-import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
 import java.util.Base64;
@@ -30,121 +29,196 @@
 import javax.inject.Provider;
 import javax.inject.Singleton;
 
-import org.apache.commons.io.IOUtils;
-import org.apache.commons.lang3.StringUtils;
-import org.apache.http.HttpEntity;
-import org.apache.http.HttpStatus;
-import org.apache.http.StatusLine;
-import org.apache.http.client.methods.CloseableHttpResponse;
-import org.apache.http.client.methods.HttpGet;
-import org.apache.http.impl.client.CloseableHttpClient;
-import org.apache.http.impl.client.HttpClientBuilder;
 import org.xwiki.cache.Cache;
 import org.xwiki.cache.CacheManager;
 import org.xwiki.cache.config.CacheConfiguration;
+import org.xwiki.cache.eviction.EntryEvictionConfiguration;
 import org.xwiki.cache.eviction.LRUEvictionConfiguration;
 import org.xwiki.component.annotation.Component;
+import org.xwiki.component.phase.Disposable;
 import org.xwiki.component.phase.Initializable;
 import org.xwiki.component.phase.InitializationException;
 import org.xwiki.diff.DiffException;
+import org.xwiki.diff.xml.XMLDiffDataURIConverterConfiguration;
+import org.xwiki.url.URLSecurityManager;
+import org.xwiki.user.CurrentUserReference;
+import org.xwiki.user.UserReferenceSerializer;
 
+import com.xpn.xwiki.XWiki;
 import com.xpn.xwiki.XWikiContext;
-import com.xpn.xwiki.web.XWikiRequest;
+import com.xpn.xwiki.XWikiException;
 
 /**
- * Default implementation of {@link DataURIConverter}.
- * 
+ * Default Implementation of {@link DataURIConverter} that uses an HTTP client to embed images.
+ *
  * @version $Id$
  * @since 11.10.1
  * @since 12.0RC1
  */
 @Component
 @Singleton
-public class DefaultDataURIConverter implements DataURIConverter, Initializable
+public class DefaultDataURIConverter implements Initializable, Disposable, DataURIConverter
 {
-    private static final String HEADER_COOKIE = "Cookie";
-
     @Inject
     private Provider<XWikiContext> xcontextProvider;
 
     @Inject
     private CacheManager cacheManager;
 
+    @Inject
+    private URLSecurityManager urlSecurityManager;
+
+    @Inject
+    private UserReferenceSerializer<String> userReferenceSerializer;
+
+    @Inject
+    private ImageDownloader imageDownloader;
+
+    @Inject
+    private XMLDiffDataURIConverterConfiguration configuration;
+
     private Cache<String> cache;
 
+    private Cache<DiffException> failureCache;
+
     @Override
     public void initialize() throws InitializationException
     {
+        if (!this.configuration.isEnabled()) {
+            return;
+        }
+
         CacheConfiguration cacheConfig = new CacheConfiguration();
         cacheConfig.setConfigurationId("diff.html.dataURI");
         LRUEvictionConfiguration lru = new LRUEvictionConfiguration();
         lru.setMaxEntries(100);
-        cacheConfig.put(LRUEvictionConfiguration.CONFIGURATIONID, lru);
+        cacheConfig.put(EntryEvictionConfiguration.CONFIGURATIONID, lru);
+
+        CacheConfiguration failureCacheConfiguration = new CacheConfiguration();
+        failureCacheConfiguration.setConfigurationId("diff.html.dataURIFailureCache");
+        LRUEvictionConfiguration failureLRU = new LRUEvictionConfiguration();
+        failureLRU.setMaxEntries(1000);
+        // Cache failures for an hour. This is to avoid hammering the server with requests for images that don't
+        // exist or are inaccessible or too large.
+        failureLRU.setLifespan(3600);
+        failureCacheConfiguration.put(EntryEvictionConfiguration.CONFIGURATIONID, failureLRU);
+
         try {
             this.cache = this.cacheManager.createNewCache(cacheConfig);
+            this.failureCache = this.cacheManager.createNewCache(failureCacheConfiguration);
         } catch (Exception e) {
+            // Dispose the cache if it has been created.
+            if (this.cache != null) {
+                this.cache.dispose();
+            }
             throw new InitializationException("Failed to create the Data URI cache.", e);
         }
     }
 
     @Override
-    public String convert(String url) throws DiffException
+    public void dispose()
     {
-        if (url.startsWith("data:")) {
-            // Already data URI.
-            return url;
+        if (this.cache != null) {
+            this.cache.dispose();
+        }
+        if (this.failureCache != null) {
+            this.failureCache.dispose();
         }
+    }
 
-        String cachedDataURI = this.cache.get(url);
-        if (cachedDataURI == null) {
-            try {
-                cachedDataURI = convert(getAbsoluteURI(url));
-                this.cache.set(url, cachedDataURI);
-            } catch (IOException | URISyntaxException e) {
-                throw new DiffException("Failed to convert [" + url + "] to data URI.", e);
+    /**
+     * Convert the given URL to an absolute URL using the request URL from the given context.
+     *
+     * @param url the URL to convert
+     * @param xcontext the XWiki context
+     * @return the absolute URL
+     * @throws DiffException if the URL cannot be converted due to being malformed
+     */
+    protected URL getAbsoluteURL(String url, XWikiContext xcontext) throws DiffException
+    {
+        URL absoluteURL;
+        try {
+            if (xcontext.getRequest() != null) {
+                URL requestURL = XWiki.getRequestURL(xcontext.getRequest());
+                absoluteURL = new URL(requestURL, url);
+            } else {
+                absoluteURL = new URL(url);
             }
+        } catch (MalformedURLException | XWikiException e) {
+            throw new DiffException(String.format("Failed to resolve [%s] to an absolute URL.", url), e);
         }
-
-        return cachedDataURI;
+        return absoluteURL;
     }
 
-    private URL getAbsoluteURI(String relativeURL) throws MalformedURLException
+    /**
+     * Get a data URI for the given content and content type.
+     *
+     * @param contentType the content type
+     * @param content the content
+     * @return the data URI
+     */
+    protected static String getDataURI(String contentType, byte[] content)
     {
-        XWikiContext xcontext = this.xcontextProvider.get();
-        URL baseURL = xcontext.getURLFactory().getServerURL(xcontext);
-        return new URL(baseURL, relativeURL);
+        return String.format("data:%s;base64,%s", contentType, Base64.getEncoder().encodeToString(content));
     }
 
-    private String convert(URL url) throws IOException, URISyntaxException
+    /**
+     * Compute a cache key based on the current user and the URL.
+     *
+     * @param url the url
+     * @return the cache key
+     */
+    private String getCacheKey(URL url)
     {
-        HttpEntity entity = fetch(url.toURI());
-        // Remove the content type parameters, such as the charset, so they don't influence the diff.
-        String contentType = StringUtils.substringBefore(entity.getContentType().getValue(), ";");
-        byte[] content = IOUtils.toByteArray(entity.getContent());
-        return String.format("data:%s;base64,%s", contentType, Base64.getEncoder().encodeToString(content));
+        String userPart = this.userReferenceSerializer.serialize(CurrentUserReference.INSTANCE);
+        // Prepend the length of the user part to avoid any kind of confusion between user and URL.
+        return String.format("%d:%s:%s", userPart.length(), userPart, url.toString());
     }
 
-    private HttpEntity fetch(URI uri) throws IOException
+    @Override
+    public String convert(String url) throws DiffException
     {
-        HttpClientBuilder httpClientBuilder = HttpClientBuilder.create();
-        httpClientBuilder.useSystemProperties();
-        httpClientBuilder.setUserAgent("XWikiHTMLDiff");
+        if (url.startsWith("data:") || !this.configuration.isEnabled()) {
+            // Already data URI.
+            return url;
+        }
+
+        // Convert URL to absolute URL to avoid issues with relative URLs that might reference different images
+        // in different subwikis.
+        URL absoluteURL = getAbsoluteURL(url, this.xcontextProvider.get());
 
-        CloseableHttpClient httpClient = httpClientBuilder.build();
-        HttpGet getMethod = new HttpGet(uri);
+        String cacheKey = getCacheKey(absoluteURL);
 
-        XWikiRequest request = this.xcontextProvider.get().getRequest();
-        if (request != null) {
-            // Copy the cookies from the current request.
-            getMethod.setHeader(HEADER_COOKIE, request.getHeader(HEADER_COOKIE));
+        try {
+            String dataURI = this.cache.get(cacheKey);
+
+            if (dataURI == null) {
+                DiffException failure = this.failureCache.get(cacheKey);
+
+                if (failure != null) {
+                    throw failure;
+                }
+
+                dataURI = convert(absoluteURL);
+                this.cache.set(cacheKey, dataURI);
+            }
+
+            return dataURI;
+        } catch (IOException | URISyntaxException e) {
+            DiffException diffException = new DiffException("Failed to convert [" + url + "] to data URI.", e);
+            this.failureCache.set(cacheKey, diffException);
+            throw diffException;
         }
+    }
 
-        CloseableHttpResponse response = httpClient.execute(getMethod);
-        StatusLine statusLine = response.getStatusLine();
-        if (statusLine.getStatusCode() == HttpStatus.SC_OK) {
-            return response.getEntity();
-        } else {
-            throw new IOException(statusLine.getStatusCode() + " " + statusLine.getReasonPhrase());
+    private String convert(URL url) throws IOException, URISyntaxException
+    {
+        if (!this.urlSecurityManager.isDomainTrusted(url)) {
+            throw new IOException(String.format("The URL [%s] is not trusted.", url));
         }
+
+        ImageDownloader.DownloadResult downloadResult = this.imageDownloader.download(url.toURI());
+
+        return getDataURI(downloadResult.getContentType(), downloadResult.getData());
     }
 }

programming_language: Java
filename: DefaultXMLDiffDataURIConverterConfiguration.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,71 @@
+/*
+ * 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.diff.xml.internal;
+
+import javax.inject.Inject;
+import javax.inject.Named;
+import javax.inject.Singleton;
+
+import org.xwiki.component.annotation.Component;
+import org.xwiki.configuration.ConfigurationSource;
+import org.xwiki.diff.xml.XMLDiffDataURIConverterConfiguration;
+
+/**
+ * Configuration for the XML diff.
+ *
+ * @version $Id$
+ * @since 14.10.15
+ * @since 15.5.1
+ * @since 15.6
+ */
+@Component
+@Singleton
+public class DefaultXMLDiffDataURIConverterConfiguration implements XMLDiffDataURIConverterConfiguration
+{
+    private static final String PREFIX = "diff.xml.dataURI";
+
+    @Inject
+    @Named("xwikiproperties")
+    private ConfigurationSource configurationSource;
+
+    @Override
+    public int getHTTPTimeout()
+    {
+        return this.configurationSource.getProperty(getFullKeyName("httpTimeout"), Integer.class, 10);
+    }
+
+    @Override
+    public long getMaximumContentSize()
+    {
+        return this.configurationSource.getProperty(getFullKeyName("maximumContentSize"), Long.class, 1024L * 1024L);
+    }
+
+    @Override
+    public boolean isEnabled()
+    {
+        return this.configurationSource.getProperty(getFullKeyName("enabled"), Boolean.class, true);
+    }
+
+    private String getFullKeyName(String shortKeyName)
+    {
+        return String.format("%s.%s", PREFIX, shortKeyName);
+    }
+
+}

programming_language: Java
filename: HttpClientBuilderFactory.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,73 @@
+/*
+ * 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.diff.xml.internal;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+import org.apache.hc.client5.http.config.ConnectionConfig;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
+import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager;
+import org.apache.hc.core5.util.Timeout;
+import org.xwiki.component.annotation.Component;
+import org.xwiki.diff.xml.XMLDiffDataURIConverterConfiguration;
+
+/**
+ * Simple factory for HttpClientBuilder to help testing and set basic properties including user agent and timeouts.
+ *
+ * @since 14.10.15
+ * @since 15.5.1
+ * @since 15.6
+ * @version $Id$
+ */
+@Component(roles = HttpClientBuilderFactory.class)
+@Singleton
+public class HttpClientBuilderFactory
+{
+    @Inject
+    private XMLDiffDataURIConverterConfiguration configuration;
+
+    /**
+     * @return a new HTTPClientBuilder
+     */
+    public HttpClientBuilder create()
+    {
+        HttpClientBuilder result = HttpClientBuilder.create();
+        result.useSystemProperties();
+        result.setUserAgent("XWikiHTMLDiff");
+
+        // Set the connection timeout.
+        Timeout timeout = Timeout.ofSeconds(this.configuration.getHTTPTimeout());
+        ConnectionConfig connectionConfig = ConnectionConfig.custom()
+            .setConnectTimeout(timeout)
+            .setSocketTimeout(timeout)
+            .build();
+
+        BasicHttpClientConnectionManager cm = new BasicHttpClientConnectionManager();
+        cm.setConnectionConfig(connectionConfig);
+        result.setConnectionManager(cm);
+
+        // Set the response timeout.
+        result.setDefaultRequestConfig(RequestConfig.custom().setResponseTimeout(timeout).build());
+
+        return result;
+    }
+}

programming_language: Java
filename: ImageDownloader.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,205 @@
+/*
+ * 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.diff.xml.internal;
+
+import java.io.IOException;
+import java.net.URI;
+import java.util.Optional;
+
+import javax.inject.Inject;
+import javax.inject.Provider;
+import javax.inject.Singleton;
+import javax.servlet.http.HttpServletRequest;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.input.BoundedInputStream;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpStatus;
+import org.xwiki.component.annotation.Component;
+import org.xwiki.diff.xml.XMLDiffDataURIConverterConfiguration;
+import org.xwiki.security.authentication.AuthenticationConfiguration;
+
+import com.xpn.xwiki.XWikiContext;
+import com.xpn.xwiki.web.XWikiRequest;
+
+/**
+ * Component for downloading images from a URL with the given cookies.
+ *
+ * @since 14.10.15
+ * @since 15.5.1
+ * @since 15.6
+ * @version $Id$
+ */
+@Component(roles = ImageDownloader.class)
+@Singleton
+public class ImageDownloader
+{
+    private static final String COOKIE_DOMAIN_PREFIX = ".";
+
+    private static final String HEADER_COOKIE = "Cookie";
+
+    @Inject
+    private HttpClientBuilderFactory httpClientBuilderFactory;
+
+    @Inject
+    private Provider<XWikiContext> xcontextProvider;
+
+    @Inject
+    private XMLDiffDataURIConverterConfiguration configuration;
+
+    @Inject
+    private AuthenticationConfiguration authenticationConfiguration;
+
+    /**
+     * The result of a download request.
+     */
+    public static class DownloadResult
+    {
+        private final byte[] data;
+
+        private final String contentType;
+
+        /**
+         * @param data the downloaded data
+         * @param contentType the MIME type of the downloaded data
+         */
+        public DownloadResult(byte[] data, String contentType)
+        {
+            this.data = data;
+            this.contentType = contentType;
+        }
+
+        /**
+         * @return the downloaded data
+         */
+        public byte[] getData()
+        {
+            return this.data;
+        }
+
+        /**
+         * @return the MIME type of the downloaded data
+         */
+        public String getContentType()
+        {
+            return this.contentType;
+        }
+    }
+
+    /**
+     * Download the image from the given URL with the cookies from the current request.
+     *
+     * @param uri the URL of the image
+     * @return the image as a byte array
+     * @throws IOException if there was an error downloading the image
+     */
+    public DownloadResult download(URI uri) throws IOException
+    {
+        HttpClientBuilder httpClientBuilder = this.httpClientBuilderFactory.create();
+
+        HttpGet getMethod = initializeGetMethod(uri);
+
+        try (CloseableHttpClient httpClient = httpClientBuilder.build()) {
+            return httpClient.execute(getMethod, response -> handleResponse(uri, response));
+        }
+    }
+
+    private DownloadResult handleResponse(URI uri, ClassicHttpResponse response) throws IOException
+    {
+        if (response.getCode() == HttpStatus.SC_OK) {
+            HttpEntity entity = response.getEntity();
+            // Remove the content type parameters, such as the charset, so they don't influence the diff.
+            String contentType = entity.getContentType();
+            contentType = StringUtils.substringBefore(contentType, ";");
+
+            if (!StringUtils.startsWith(contentType, "image/")) {
+                throw new IOException(String.format("The content of [%s] is not an image.", uri));
+            }
+
+            long maximumSize = this.configuration.getMaximumContentSize();
+            if (maximumSize > 0 && entity.getContentLength() > maximumSize) {
+                throw new IOException(String.format("The content length of [%s] is too big.", uri));
+            }
+
+            byte[] content;
+            if (maximumSize > 0) {
+                // The content length is not always available (then it is negative), so we need to use a bounded
+                // input stream to make sure we don't read more than the maximum size.
+                try (BoundedInputStream boundedInputStream = new BoundedInputStream(entity.getContent(),
+                    maximumSize))
+                {
+                    content = IOUtils.toByteArray(boundedInputStream);
+                }
+
+                if (content.length == maximumSize) {
+                    throw new IOException(String.format("The content of [%s] is too big.", uri));
+                }
+            } else {
+                content = IOUtils.toByteArray(entity.getContent());
+            }
+
+            return new DownloadResult(content, contentType);
+        } else {
+            throw new IOException(response.getCode() + " " + response.getReasonPhrase());
+        }
+    }
+
+    private HttpGet initializeGetMethod(URI uri)
+    {
+        HttpGet getMethod = new HttpGet(uri);
+
+        XWikiRequest request = this.xcontextProvider.get().getRequest();
+        if (request != null && matchesCookieDomain(uri.getHost(), request)) {
+            // Copy the cookie header from the current request.
+            getMethod.setHeader(HEADER_COOKIE, request.getHeader(HEADER_COOKIE));
+        }
+
+        return getMethod;
+    }
+
+    /**
+     * @return if the host matches the cookie domain of the current request
+     */
+    private boolean matchesCookieDomain(String host, HttpServletRequest request)
+    {
+        String serverName = request.getServerName();
+        // Add a leading dot to avoid matching domains that are longer versions of the cookie domain and to ensure
+        // that the cookie domain itself is matched as the cookie domain also contains the leading dot. Always add
+        // the dot as two dots will still match.
+        String prefixedServerName = COOKIE_DOMAIN_PREFIX + serverName;
+
+        Optional<String> cookieDomain =
+            this.authenticationConfiguration.getCookieDomains().stream()
+                .filter(prefixedServerName::endsWith)
+                .findFirst();
+
+        // If there is a cookie domain, check if the host also matches it.
+        return cookieDomain.map((COOKIE_DOMAIN_PREFIX + host)::endsWith)
+            // If no cookie domain is configured, check for an exact match with the server name as no domain is sent in
+            // this case and thus the cookie isn't valid for subdomains.
+            .orElseGet(() -> host.equals(serverName));
+    }
+
+}

programming_language: Java
filename: components.txt
change_type: ModificationType.MODIFY
diff: @@ -1 +1,4 @@
 org.xwiki.diff.xml.internal.DefaultDataURIConverter
+org.xwiki.diff.xml.internal.HttpClientBuilderFactory
+org.xwiki.diff.xml.internal.ImageDownloader
+org.xwiki.diff.xml.internal.DefaultXMLDiffDataURIConverterConfiguration

programming_language: Scala
filename: DefaultDataURIConverterTest.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,239 @@
+/*
+ * 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.diff.xml.internal;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.List;
+import java.util.Map;
+
+import javax.inject.Provider;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.xwiki.cache.Cache;
+import org.xwiki.cache.CacheException;
+import org.xwiki.cache.CacheManager;
+import org.xwiki.cache.config.CacheConfiguration;
+import org.xwiki.diff.DiffException;
+import org.xwiki.diff.xml.XMLDiffDataURIConverterConfiguration;
+import org.xwiki.test.annotation.BeforeComponent;
+import org.xwiki.test.junit5.mockito.ComponentTest;
+import org.xwiki.test.junit5.mockito.InjectMockComponents;
+import org.xwiki.test.junit5.mockito.MockComponent;
+import org.xwiki.url.URLSecurityManager;
+import org.xwiki.user.CurrentUserReference;
+import org.xwiki.user.UserReferenceSerializer;
+
+import com.xpn.xwiki.XWikiContext;
+import com.xpn.xwiki.web.XWikiServletRequestStub;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link DefaultDataURIConverter}.
+ *
+ * @version $Id$
+ */
+@ComponentTest
+class DefaultDataURIConverterTest
+{
+    private static final String CURRENT_USER = "XWiki.CurrentUser";
+
+    private static final String CACHE_PREFIX = CURRENT_USER.length() + ":" + CURRENT_USER + ":";
+
+    private static final String URL_PREFIX = "http://localhost:8080";
+
+    @MockComponent
+    private ImageDownloader imageDownloader;
+
+    @MockComponent
+    private CacheManager cacheManager;
+
+    @MockComponent
+    private Provider<XWikiContext> xwikiContextProvider;
+
+    @MockComponent
+    private URLSecurityManager urlSecurityManager;
+
+    private Cache<String> cache;
+
+    private Cache<DiffException> failureCache;
+
+    @MockComponent
+    private UserReferenceSerializer<String> userReferenceSerializer;
+
+    @MockComponent
+    private XMLDiffDataURIConverterConfiguration configuration;
+
+    @InjectMockComponents
+    private DefaultDataURIConverter converter;
+
+    @BeforeComponent
+    public void configureCacheManager() throws CacheException
+    {
+        this.cache = mock();
+        this.failureCache = mock();
+
+        when(this.configuration.isEnabled()).thenReturn(true);
+
+        when(this.cacheManager.createNewCache(any())).then(invocationOnMock -> {
+            CacheConfiguration cacheConfiguration = invocationOnMock.getArgument(0);
+            if ("diff.html.dataURI".equals(cacheConfiguration.getConfigurationId())) {
+                return this.cache;
+            } else if ("diff.html.dataURIFailureCache".equals(cacheConfiguration.getConfigurationId())) {
+                return this.failureCache;
+            }
+
+            return null;
+        });
+    }
+
+    @BeforeEach
+    public void setUp()
+    {
+        when(this.userReferenceSerializer.serialize(CurrentUserReference.INSTANCE)).thenReturn(CURRENT_USER);
+
+        XWikiContext xwikiContext = mock();
+        when(this.xwikiContextProvider.get()).thenReturn(xwikiContext);
+        XWikiServletRequestStub request = new XWikiServletRequestStub.Builder()
+            .setHeaders(Map.of("forwarded", List.of("host=localhost:8080;proto=http")))
+            .build();
+        request.setRequestURI("/xwiki");
+        when(xwikiContext.getRequest()).thenReturn(request);
+    }
+
+    @Test
+    void returnsURLWhenDisabled() throws DiffException
+    {
+        when(this.configuration.isEnabled()).thenReturn(false);
+        String url = "http://www.example.com";
+        assertEquals(url, this.converter.convert(url));
+    }
+
+    @Test
+    void dataURIIsKept() throws DiffException
+    {
+        String dataURI = "data:image/png;base64,abc";
+        assertEquals(dataURI, this.converter.convert(dataURI));
+    }
+
+    @Test
+    void throwsExceptionWhenURLIsMalFormed() throws IOException
+    {
+        String url = "http://w w w.example.com";
+        when(this.urlSecurityManager.isDomainTrusted(new URL(url))).thenReturn(true);
+        DiffException exception = assertThrows(DiffException.class, () -> this.converter.convert(url));
+        assertEquals(getFailureMessage(url), exception.getMessage());
+        assertEquals("Illegal character in authority at index 7: http://w w w.example.com",
+            exception.getCause().getMessage());
+
+        verify(this.imageDownloader, never()).download(any());
+        verify(this.cache, never()).set(any(), any());
+        verify(this.failureCache).set(CACHE_PREFIX + url,
+            exception);
+    }
+
+    @Test
+    void usesCacheWhenAvailable() throws DiffException
+    {
+        String dataURI = "data:image/png;base64,def";
+        String url = "/image.png";
+        when(this.cache.get(CACHE_PREFIX + URL_PREFIX + url)).thenReturn(dataURI);
+
+        assertEquals(dataURI, this.converter.convert(url));
+    }
+
+    @Test
+    void throwsCachedFailure()
+    {
+        String url = "/image.png";
+        DiffException exception = new DiffException("Failed to convert url to absolute URL.");
+        when(this.failureCache.get(CACHE_PREFIX + URL_PREFIX + url)).thenReturn(exception);
+
+        DiffException thrown = assertThrows(DiffException.class, () -> this.converter.convert(url));
+        assertEquals(exception, thrown);
+    }
+
+    @Test
+    void throwsWhenURLIsNotTrusted() throws MalformedURLException
+    {
+        String url = "http://example.com/image.png";
+        when(this.urlSecurityManager.isDomainTrusted(new URL(url))).thenReturn(false);
+
+        DiffException thrown = assertThrows(DiffException.class, () -> this.converter.convert(url));
+        assertEquals(getFailureMessage(url), thrown.getMessage());
+        assertEquals(String.format("The URL [%s] is not trusted.", url), thrown.getCause().getMessage());
+
+        // Make sure that the failure is cached.
+        verify(this.failureCache).set(CACHE_PREFIX + url, thrown);
+    }
+
+    @Test
+    void throwsExceptionWhenImageDownloaderFails() throws URISyntaxException, IOException
+    {
+        String url = "/image.png";
+        URI uri = new URI(URL_PREFIX + url);
+        when(this.urlSecurityManager.isDomainTrusted(uri.toURL())).thenReturn(true);
+        IOException exception = new IOException("Failed to download image.");
+        when(this.imageDownloader.download(uri)).thenThrow(exception);
+
+        DiffException thrown = assertThrows(DiffException.class, () -> this.converter.convert(url));
+        assertEquals(getFailureMessage(url), thrown.getMessage());
+        assertEquals(exception, thrown.getCause());
+
+        // Make sure the failure is cached.
+        verify(this.failureCache).set(CACHE_PREFIX + URL_PREFIX + url, thrown);
+        // Make sure nothing is stored in the data cache.
+        verify(this.cache, never()).set(any(), any());
+    }
+
+    @Test
+    void returnsDataURIForReturnedDataAndMimeType() throws IOException, DiffException
+    {
+        String url = "/image.png";
+        URI uri = URI.create(URL_PREFIX + url);
+        when(this.urlSecurityManager.isDomainTrusted(uri.toURL())).thenReturn(true);
+        String dataURI = "data:image/jpeg;base64,ZGVm";
+        when(this.imageDownloader.download(uri))
+            .thenReturn(new ImageDownloader.DownloadResult(new byte[] { 'd', 'e', 'f' }, "image/jpeg"));
+
+        assertEquals(dataURI, this.converter.convert(url));
+
+        // Make sure the data is cached.
+        verify(this.cache).set(CACHE_PREFIX + URL_PREFIX + url, dataURI);
+        // Make sure nothing is stored in the failure cache.
+        verify(this.failureCache, never()).set(any(), any());
+    }
+
+    private static String getFailureMessage(String url)
+    {
+        return String.format("Failed to convert [%s] to data URI.", url);
+    }
+}

programming_language: Java
filename: ImageDownloaderTest.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,237 @@
+/*
+ * 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.diff.xml.internal;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.util.List;
+
+import javax.inject.Provider;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
+import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.ProtocolVersion;
+import org.apache.hc.core5.http.io.HttpClientResponseHandler;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.xwiki.diff.xml.XMLDiffDataURIConverterConfiguration;
+import org.xwiki.security.authentication.AuthenticationConfiguration;
+import org.xwiki.test.junit5.mockito.ComponentTest;
+import org.xwiki.test.junit5.mockito.InjectMockComponents;
+import org.xwiki.test.junit5.mockito.MockComponent;
+
+import com.xpn.xwiki.XWikiContext;
+import com.xpn.xwiki.web.XWikiRequest;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link ImageDownloader}.
+ *
+ * @version $Id$
+ */
+@ComponentTest
+class ImageDownloaderTest
+{
+    private static final ProtocolVersion HTTP_VERSION = new ProtocolVersion("HTTP", 1, 1);
+
+    private static final URI IMAGE_URI = URI.create("https://www.example.com/image.png");
+
+    private static final String IMAGE_CONTENT_TYPE = "image/png";
+
+    @MockComponent
+    private HttpClientBuilderFactory httpClientBuilderFactory;
+
+    @MockComponent
+    private Provider<XWikiContext> xwikiContextProvider;
+
+    @MockComponent
+    private XMLDiffDataURIConverterConfiguration configuration;
+
+    @MockComponent
+    private AuthenticationConfiguration authenticationConfiguration;
+
+    @InjectMockComponents
+    private ImageDownloader imageDownloader;
+
+    @Mock
+    private HttpClientBuilder httpClientBuilder;
+
+    @Mock
+    private CloseableHttpClient httpClient;
+
+    @Mock
+    private CloseableHttpResponse httpResponse;
+
+    @Mock
+    private XWikiContext xwikiContext;
+
+    @Mock
+    private HttpEntity httpEntity;
+
+    @BeforeEach
+    public void setupMocks() throws IOException
+    {
+        when(this.httpClientBuilderFactory.create()).thenReturn(this.httpClientBuilder);
+        when(this.httpClientBuilder.build()).thenReturn(this.httpClient);
+        when(this.httpClient.execute(any(ClassicHttpRequest.class), any(HttpClientResponseHandler.class)))
+            .then(invocation ->
+            {
+                HttpClientResponseHandler<?> responseHandler = invocation.getArgument(1);
+                return responseHandler.handleResponse(this.httpResponse);
+            });
+        when(this.xwikiContextProvider.get()).thenReturn(this.xwikiContext);
+        when(this.httpResponse.getEntity()).thenReturn(this.httpEntity);
+        when(this.httpResponse.getCode()).thenReturn(HttpStatus.SC_OK);
+        when(this.httpResponse.getReasonPhrase()).thenReturn("OK");
+        when(this.httpEntity.getContentType()).thenReturn(IMAGE_CONTENT_TYPE);
+    }
+
+    @Test
+    void throwsOnNon200Status()
+    {
+        when(this.httpResponse.getCode()).thenReturn(HttpStatus.SC_NOT_FOUND);
+        when(this.httpResponse.getReasonPhrase()).thenReturn("Not Found");
+        IOException ioException = assertThrows(IOException.class, () -> this.imageDownloader.download(IMAGE_URI));
+        assertEquals("404 Not Found", ioException.getMessage());
+    }
+
+    @Test
+    void throwsWhenNonImageContentType()
+    {
+        when(this.httpEntity.getContentType()).thenReturn("text/html");
+        IOException ioException = assertThrows(IOException.class, () -> this.imageDownloader.download(IMAGE_URI));
+        assertEquals(String.format("The content of [%s] is not an image.", IMAGE_URI), ioException.getMessage());
+    }
+
+    @Test
+    void throwsWhenContentTypeHeaderMissing()
+    {
+        when(this.httpEntity.getContentType()).thenReturn(null);
+        IOException ioException = assertThrows(IOException.class, () -> this.imageDownloader.download(IMAGE_URI));
+        assertEquals(String.format("The content of [%s] is not an image.", IMAGE_URI), ioException.getMessage());
+    }
+
+    @Test
+    void throwsWhenContentLengthTooBig()
+    {
+        when(this.httpEntity.getContentLength()).thenReturn(1000000000L);
+        when(this.configuration.getMaximumContentSize()).thenReturn(100L);
+        IOException ioException = assertThrows(IOException.class, () -> this.imageDownloader.download(IMAGE_URI));
+        assertEquals(String.format("The content length of [%s] is too big.", IMAGE_URI), ioException.getMessage());
+    }
+
+    @Test
+    void throwsWhenContentLengthUnknownAndTooBig() throws IOException
+    {
+        when(this.httpEntity.getContentLength()).thenReturn(-1L);
+        InputStream inputStream = new InputStream()
+        {
+            @Override
+            public int read()
+            {
+                return 1;
+            }
+        };
+        when(this.configuration.getMaximumContentSize()).thenReturn(100L);
+
+        when(this.httpEntity.getContent()).thenReturn(inputStream);
+        IOException ioException = assertThrows(IOException.class, () -> this.imageDownloader.download(IMAGE_URI));
+        assertEquals(String.format("The content of [%s] is too big.", IMAGE_URI), ioException.getMessage());
+    }
+
+    @ParameterizedTest
+    @ValueSource(longs = { -1, 1, 100, 200 })
+    void returnsContent(long contentLength) throws IOException
+    {
+        // Set different content lengths to test the different code paths.
+        when(this.httpEntity.getContentLength()).thenReturn(contentLength);
+        if (contentLength < 200) {
+            when(this.configuration.getMaximumContentSize()).thenReturn(200L);
+        } else {
+            // Test unlimited size.
+            when(this.configuration.getMaximumContentSize()).thenReturn(0L);
+        }
+        byte[] content = new byte[] { 1, 2, 3 };
+        when(this.httpEntity.getContent()).thenReturn(new ByteArrayInputStream(content));
+        ImageDownloader.DownloadResult result = this.imageDownloader.download(IMAGE_URI);
+        assertArrayEquals(content, result.getData());
+        assertEquals(IMAGE_CONTENT_TYPE, result.getContentType());
+    }
+
+    @ParameterizedTest
+    @CsvSource({
+        "www.example.com, true, ",
+        "www.xwiki.org, false, ",
+        "test.example.com, false, ",
+        "matches.example.com, true, .example.com"
+    })
+    void passesCookiesFromRequest(String requestDomain, boolean shouldSendCookie, String cookieDomain)
+        throws IOException
+    {
+        // Set a mock request in the context.
+        XWikiRequest request = mock();
+        when(request.getServerName()).thenReturn(requestDomain);
+        String cookieHeader = "cookie1=value1; cookie2=value2";
+        when(request.getHeader("Cookie")).thenReturn(cookieHeader);
+        when(this.xwikiContext.getRequest()).thenReturn(request);
+
+        if (StringUtils.isNotBlank(cookieDomain)) {
+            when(this.authenticationConfiguration.getCookieDomains()).thenReturn(List.of(cookieDomain));
+        }
+
+        // Trigger the download.
+        byte[] content = new byte[] { 1, 2, 3 };
+        when(this.httpEntity.getContent()).thenReturn(new ByteArrayInputStream(content));
+        this.imageDownloader.download(IMAGE_URI);
+
+        ArgumentCaptor<ClassicHttpRequest> requestCaptor = ArgumentCaptor.forClass(ClassicHttpRequest.class);
+        verify(this.httpClient).execute(requestCaptor.capture(), any(HttpClientResponseHandler.class));
+
+        // Verify that the cookies are passed to the HTTP client if it should do so.
+        ClassicHttpRequest httpRequest = requestCaptor.getValue();
+        Header[] headers = httpRequest.getHeaders("Cookie");
+        if (shouldSendCookie) {
+            assertEquals(1, headers.length);
+            assertEquals(cookieHeader, headers[0].getValue());
+        } else {
+            assertEquals(0, headers.length);
+        }
+    }
+}

programming_language: Java
filename: pom.xml
change_type: ModificationType.MODIFY
diff: @@ -134,6 +134,12 @@
       <version>${project.version}</version>
       <type>xar</type>
     </dependency>
+    <!-- Needed for CompareIT -->
+    <dependency>
+      <groupId>org.xwiki.platform</groupId>
+      <artifactId>xwiki-platform-diff-xml</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <!-- ================================
          Test only dependencies
          ================================ -->

programming_language: HTML
filename: AllIT.java
change_type: ModificationType.MODIFY
diff: @@ -200,4 +200,10 @@ class NestedFormTokenInjectionIT extends FormTokenInjectionIT
     class NestedPagePickerIT extends PagePickerIT
     {
     }
+
+    @Nested
+    @DisplayName("Compare Tests")
+    class NestedCompareIT extends CompareIT
+    {
+    }
 }

programming_language: Scala
filename: CompareIT.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,131 @@
+/*
+ * 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.flamingo.test.docker;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Base64;
+import java.util.List;
+
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.openqa.selenium.By;
+import org.openqa.selenium.WebElement;
+import org.xwiki.model.reference.AttachmentReference;
+import org.xwiki.test.docker.junit5.TestReference;
+import org.xwiki.test.docker.junit5.UITest;
+import org.xwiki.test.ui.TestUtils;
+import org.xwiki.test.ui.po.ComparePage;
+import org.xwiki.test.ui.po.ViewPage;
+import org.xwiki.test.ui.po.diff.RenderedChanges;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests related to the compare versions feature.
+ *
+ * @version $Id$
+ */
+@UITest(properties = {
+    // Trust picsum.photos to allow the rendered diff to download images from it
+    "xwikiPropertiesAdditionalProperties=url.trustedDomains=picsum.photos"
+})
+class CompareIT
+{
+    private static final String ATTACHMENT_NAME_1 = "image.gif";
+
+    private static final String ATTACHMENT_NAME_2 = "image2.gif";
+
+    private static final String ATTACHMENT_NAME_3 = "image.png";
+
+    private static final String IMAGE_SYNTAX = "[[image:%s]]";
+
+    private String getLocalAttachmentURL(TestUtils setup, TestReference testReference, String attachmentName)
+        throws URISyntaxException
+    {
+        AttachmentReference attachmentReference = new AttachmentReference(attachmentName, testReference);
+        URI attachmentURL = new URI(setup.getURL(attachmentReference, "download", null));
+        // Replace host and port with localhost and 8080 to make the URL usable from the container.
+        return (new URI("http", null, "localhost", 8080, attachmentURL.getPath(),
+            attachmentURL.getQuery(), attachmentURL.getFragment())).toString();
+    }
+
+    @Test
+    @Order(1)
+    void compareRenderedImageChanges(TestUtils setup, TestReference testReference) throws Exception
+    {
+        setup.loginAsSuperAdmin();
+        setup.attachFile(testReference, ATTACHMENT_NAME_1,
+            getClass().getResourceAsStream("/AttachmentIT/image.gif"), false);
+        // Upload the image a second time under a different name to check that the content and not the URL is used
+        // for comparison when changing the URL to the second image.
+        setup.attachFile(testReference, ATTACHMENT_NAME_2,
+            getClass().getResourceAsStream("/AttachmentIT/image.gif"), false);
+        String url1 = getLocalAttachmentURL(setup, testReference, ATTACHMENT_NAME_1);
+        ViewPage viewPage = setup.createPage(testReference, String.format(IMAGE_SYNTAX, url1));
+        String firstRevision = viewPage.getMetaDataValue("version");
+        // Create a second revision with the new image.
+        String url2 = getLocalAttachmentURL(setup, testReference, ATTACHMENT_NAME_2);
+        viewPage = setup.createPage(testReference, String.format(IMAGE_SYNTAX, url2));
+        String secondRevision = viewPage.getMetaDataValue("version");
+
+        // Open the history pane.
+        ComparePage compare = viewPage.openHistoryDocExtraPane().compare(firstRevision, secondRevision);
+        RenderedChanges renderedChanges = compare.getChangesPane().getRenderedChanges();
+        assertTrue(renderedChanges.hasNoChanges());
+
+        // Upload a new image with different content to verify that the changes are detected.
+        setup.attachFile(testReference, ATTACHMENT_NAME_3,
+            getClass().getResourceAsStream("/AttachmentIT/SmallSizeAttachment.png"), false);
+
+        // Create a third revision with the new image.
+        String url3 = getLocalAttachmentURL(setup, testReference, ATTACHMENT_NAME_3);
+        viewPage = setup.createPage(testReference, String.format(IMAGE_SYNTAX, url3));
+        String thirdRevision = viewPage.getMetaDataValue("version");
+
+        // Open the history pane.
+        compare = viewPage.openHistoryDocExtraPane().compare(secondRevision, thirdRevision);
+        renderedChanges = compare.getChangesPane().getRenderedChanges();
+        assertFalse(renderedChanges.hasNoChanges());
+        List<WebElement> changes = renderedChanges.getChangedBlocks();
+        assertEquals(2, changes.size());
+
+        // Check that the first change is the deletion and the second change the insertion of the new image.
+        WebElement firstChange = changes.get(0);
+        WebElement secondChange = changes.get(1);
+        assertEquals("deleted", firstChange.getAttribute("data-xwiki-html-diff-block"));
+        assertEquals("inserted", secondChange.getAttribute("data-xwiki-html-diff-block"));
+        WebElement deletedImage = firstChange.findElement(By.tagName("img"));
+        WebElement insertedImage = secondChange.findElement(By.tagName("img"));
+
+        // Check that the src attribute of the deleted image ends with the image2 (don't check the start as it
+        // depends on the container setup and the nested/non-nested test execution).
+        assertEquals(url2, deletedImage.getAttribute("src"));
+
+        // Compute the expected base64-encoded content of the inserted image. The HTML diff embeds both images but
+        // replaces the deleted image by the original URL again after the diff computation.
+        String expectedInsertedImageContent = Base64.getEncoder().encodeToString(
+            IOUtils.toByteArray(getClass().getResourceAsStream("/AttachmentIT/SmallSizeAttachment.png")));
+        assertEquals("data:image/png;base64," + expectedInsertedImageContent, insertedImage.getAttribute("src"));
+    }
+}

programming_language: Java
filename: AuthenticationConfiguration.java
change_type: ModificationType.MODIFY
diff: @@ -19,7 +19,10 @@
  */
 package org.xwiki.security.authentication;
 
+import java.util.List;
+
 import org.xwiki.component.annotation.Role;
+import org.xwiki.stability.Unstable;
 
 /**
  * Configuration of the authentication properties.
@@ -54,4 +57,16 @@ default boolean isAuthenticationSecurityEnabled()
     {
         return true;
     }
+
+    /**
+     * @return the list of cookie domains to use for the authentication cookies. Domains are prefix with a dot.
+     * @since 14.10.15
+     * @since 15.5.1
+     * @since 15.6
+     */
+    @Unstable
+    default List<String> getCookieDomains()
+    {
+        return List.of();
+    }
 }

programming_language: Java
filename: DefaultAuthenticationConfiguration.java
change_type: ModificationType.MODIFY
diff: @@ -19,6 +19,8 @@
  */
 package org.xwiki.security.authentication.internal;
 
+import java.util.List;
+
 import javax.inject.Inject;
 import javax.inject.Named;
 import javax.inject.Singleton;
@@ -28,6 +30,8 @@
 import org.xwiki.configuration.ConfigurationSource;
 import org.xwiki.security.authentication.AuthenticationConfiguration;
 
+import static java.util.stream.Collectors.toList;
+
 /**
  * Default implementation for {@link AuthenticationConfiguration}.
  *
@@ -38,6 +42,8 @@
 @Singleton
 public class DefaultAuthenticationConfiguration implements AuthenticationConfiguration
 {
+    private static final String COOKIE_PREFIX = ".";
+
     /**
      * Defines from where to read the Resource configuration data.
      */
@@ -45,6 +51,10 @@ public class DefaultAuthenticationConfiguration implements AuthenticationConfigu
     @Named("authentication")
     private ConfigurationSource configuration;
 
+    @Inject
+    @Named("xwikicfg")
+    private ConfigurationSource xwikiCfgConfiguration;
+
     @Override
     public int getMaxAuthorizedAttempts()
     {
@@ -73,4 +83,15 @@ public boolean isAuthenticationSecurityEnabled()
     {
         return configuration.getProperty("isAuthenticationSecurityEnabled", true);
     }
+
+    @Override
+    public List<String> getCookieDomains()
+    {
+        List<?> rawValues = this.xwikiCfgConfiguration.getProperty("xwiki.authentication.cookiedomains", List.class,
+            List.of());
+        return rawValues.stream()
+            .map(Object::toString)
+            .map(cookie -> StringUtils.startsWith(cookie, COOKIE_PREFIX) ? cookie : COOKIE_PREFIX + cookie)
+            .collect(toList());
+    }
 }

programming_language: Java
filename: DefaultAuthenticationConfigurationTest.java
change_type: ModificationType.ADD
diff: @@ -0,0 +1,71 @@
+/*
+ * 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.security.authentication.internal;
+
+import java.util.List;
+
+import javax.inject.Named;
+
+import org.junit.jupiter.api.Test;
+import org.xwiki.configuration.ConfigurationSource;
+import org.xwiki.test.junit5.mockito.ComponentTest;
+import org.xwiki.test.junit5.mockito.InjectMockComponents;
+import org.xwiki.test.junit5.mockito.MockComponent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit test of {@link DefaultAuthenticationConfiguration}.
+ */
+@ComponentTest
+class DefaultAuthenticationConfigurationTest
+{
+    @MockComponent
+    @Named("xwikicfg")
+    private ConfigurationSource xwikiCfgConfiguration;
+
+    @InjectMockComponents
+    private DefaultAuthenticationConfiguration configuration;
+
+    @Test
+    void getCookieDomains()
+    {
+        // Test with empty configuration.
+        String configurationKey = "xwiki.authentication.cookiedomains";
+        when(this.xwikiCfgConfiguration.getProperty(configurationKey, List.class, List.of()))
+            .thenReturn(List.of());
+
+        assertEquals(List.of(), this.configuration.getCookieDomains());
+
+        // Test with domains without prefix.
+        when(this.xwikiCfgConfiguration.getProperty(configurationKey, List.class, List.of()))
+            .thenReturn(List.of("xwiki.org", "xwiki.com"));
+
+        String xwikiComWithPrefix = ".xwiki.com";
+        assertEquals(List.of(".xwiki.org", xwikiComWithPrefix), this.configuration.getCookieDomains());
+
+        // Test with domains where some have a prefix already.
+        when(this.xwikiCfgConfiguration.getProperty(configurationKey, List.class, List.of()))
+            .thenReturn(List.of("example.com", xwikiComWithPrefix));
+
+        assertEquals(List.of(".example.com", xwikiComWithPrefix), this.configuration.getCookieDomains());
+    }
+}

programming_language: Java
filename: xwiki.properties.vm
change_type: ModificationType.MODIFY
diff: @@ -1556,4 +1556,27 @@ edit.defaultEditor.org.xwiki.rendering.block.XDOM#wysiwyg=$xwikiPropertiesDefaul
 # whatsnew.sources = xwikisas = xwikiblog
 # whatsnew.sources.xwikisas.rssURL = https://xwiki.com/news
 
+#-------------------------------------------------------------------------------------
+# XML Diff
+#-------------------------------------------------------------------------------------
+
+#-# [Since 14.10.15, 15.5.1, 15.6]
+#-# If the compared documents contain images, they can be embedded as data URI to compare the images themselves
+#-# instead of their URLs. For this, images are downloaded via HTTP and embedded as data URI. Images are only
+#-# downloaded from trusted domains when enabled above. Still, this can be a security and stability risk as
+#-# downloading images can easily increase the server load. If this option is set to "false", no images are
+#-# downloaded by the diff and images are compared by URL instead.
+#-# Default is "true".
+# diff.xml.dataURI.enabled = true
+
+#-# [Since 14.10.15, 15.5.1, 15.6]
+#-# Configure the maximum size in bytes of an image to be embedded into the XML diff output as data URI.
+#-# Default is 1MB.
+# diff.xml.dataURI.maximumContentSize = 1048576
+
+#-# [Since 14.10.15, 15.5.1, 15.6]
+#-# Configure the timeout in seconds for downloading an image via HTTP to embed it into the XML diff output as data URI.
+#-# Default is 10 seconds.
+# diff.xml.dataURI.httpTimeout = 10
+
 $!xwikiPropertiesAdditionalProperties

programming_language: Ruby