CVE-2023-48240: Server-Side Request Forgery (SSRF) (CWE-918)
value: 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/`.
CWE 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.
CVSS v3 base score: 8.8
Published: 2023-11-20T18:15Z
Repository: https://github.com/xwiki/xwiki-platform
Language: Java
Files changed: xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/pom.xml, xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/XMLDiffDataURIConverterConfiguration.java, xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/DefaultDataURIConverter.java, xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/DefaultXMLDiffDataURIConverterConfiguration.java, xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/HttpClientBuilderFactory.java, xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/ImageDownloader.java, xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/resources/META-INF/components.txt, xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/test/java/org/xwiki/diff/xml/internal/DefaultDataURIConverterTest.java, xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/test/java/org/xwiki/diff/xml/internal/ImageDownloaderTest.java, xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/pom.xml, xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/src/test/it/org/xwiki/flamingo/test/docker/AllIT.java, xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/src/test/it/org/xwiki/flamingo/test/docker/CompareIT.java, xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-api/src/main/java/org/xwiki/security/authentication/AuthenticationConfiguration.java, xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/main/java/org/xwiki/security/authentication/internal/DefaultAuthenticationConfiguration.java, xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/test/java/org/xwiki/security/authentication/internal/DefaultAuthenticationConfigurationTest.java, xwiki-platform-tools/xwiki-platform-tool-configuration-resources/src/main/resources/xwiki.properties.vm
Commit date: 2023-07-26 15:01:55 +0200
Commit message: 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

Diff: diff --git a/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/pom.xml b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/pom.xml
index 41c481cd233ed..ac36f66d078fe 100644
--- a/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/pom.xml
+++ b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/pom.xml
@@ -29,24 +29,46 @@
   </parent>
   <artifactId>xwiki-platform-diff-xml</artifactId>
   <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>
       <version>${commons.version}</version>
     </dependency>
     <dependency>
       <groupId>org.xwiki.platform</groupId>
       <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
diff --git a/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/XMLDiffDataURIConverterConfiguration.java b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/XMLDiffDataURIConverterConfiguration.java
new file mode 100644
index 0000000000000..aea6f7b30d0b0
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/XMLDiffDataURIConverterConfiguration.java
@@ -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();
+}
diff --git a/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/DefaultDataURIConverter.java b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/DefaultDataURIConverter.java
index d5f670f44b9ca..aa15c08dabe57 100644
--- a/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/DefaultDataURIConverter.java
+++ b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/DefaultDataURIConverter.java
@@ -19,132 +19,206 @@
  */
 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.Base64;
 
 import javax.inject.Inject;
 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());
     }
 }
diff --git a/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/DefaultXMLDiffDataURIConverterConfiguration.java b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/DefaultXMLDiffDataURIConverterConfiguration.java
new file mode 100644
index 0000000000000..bbcf63e859392
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/DefaultXMLDiffDataURIConverterConfiguration.java
@@ -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);
+    }
+
+}
diff --git a/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/HttpClientBuilderFactory.java b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/HttpClientBuilderFactory.java
new file mode 100644
index 0000000000000..f267bfe90f957
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/HttpClientBuilderFactory.java
@@ -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;
+    }
+}
diff --git a/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/ImageDownloader.java b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/ImageDownloader.java
new file mode 100644
index 0000000000000..8b6525d52f33e
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/java/org/xwiki/diff/xml/internal/ImageDownloader.java
@@ -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));
+    }
+
+}
diff --git a/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/resources/META-INF/components.txt b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/resources/META-INF/components.txt
index 44fe56f04fc90..d96273fe0d113 100644
--- a/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/resources/META-INF/components.txt
+++ b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/main/resources/META-INF/components.txt
@@ -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
diff --git a/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/test/java/org/xwiki/diff/xml/internal/DefaultDataURIConverterTest.java b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/test/java/org/xwiki/diff/xml/internal/DefaultDataURIConverterTest.java
new file mode 100644
index 0000000000000..be889e633d695
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/test/java/org/xwiki/diff/xml/internal/DefaultDataURIConverterTest.java
@@ -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);
+    }
+}
diff --git a/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/test/java/org/xwiki/diff/xml/internal/ImageDownloaderTest.java b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/test/java/org/xwiki/diff/xml/internal/ImageDownloaderTest.java
new file mode 100644
index 0000000000000..07a73e7b20907
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-diff/xwiki-platform-diff-xml/src/test/java/org/xwiki/diff/xml/internal/ImageDownloaderTest.java
@@ -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);
+        }
+    }
+}
diff --git a/xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/pom.xml b/xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/pom.xml
index fd48b6ce0faae..cd13ee5975dd2 100644
--- a/xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/pom.xml
+++ b/xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/pom.xml
@@ -132,10 +132,16 @@
       <groupId>org.xwiki.platform</groupId>
       <artifactId>xwiki-platform-sandbox</artifactId>
       <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
          ================================ -->
     <dependency>
       <groupId>org.xwiki.platform</groupId>
diff --git a/xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/src/test/it/org/xwiki/flamingo/test/docker/AllIT.java b/xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/src/test/it/org/xwiki/flamingo/test/docker/AllIT.java
index 19d720c37f79c..0fad0355eb101 100644
--- a/xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/src/test/it/org/xwiki/flamingo/test/docker/AllIT.java
+++ b/xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/src/test/it/org/xwiki/flamingo/test/docker/AllIT.java
@@ -198,6 +198,12 @@ class NestedFormTokenInjectionIT extends FormTokenInjectionIT
     @Nested
     @DisplayName("Page Picker Tests")
     class NestedPagePickerIT extends PagePickerIT
     {
     }
+
+    @Nested
+    @DisplayName("Compare Tests")
+    class NestedCompareIT extends CompareIT
+    {
+    }
 }
diff --git a/xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/src/test/it/org/xwiki/flamingo/test/docker/CompareIT.java b/xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/src/test/it/org/xwiki/flamingo/test/docker/CompareIT.java
new file mode 100644
index 0000000000000..c113085921e0c
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-test/xwiki-platform-flamingo-skin-test-docker/src/test/it/org/xwiki/flamingo/test/docker/CompareIT.java
@@ -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"));
+    }
+}
diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-api/src/main/java/org/xwiki/security/authentication/AuthenticationConfiguration.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-api/src/main/java/org/xwiki/security/authentication/AuthenticationConfiguration.java
index 6eb3e3333a640..d31c3dd3c87fc 100644
--- a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-api/src/main/java/org/xwiki/security/authentication/AuthenticationConfiguration.java
+++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-api/src/main/java/org/xwiki/security/authentication/AuthenticationConfiguration.java
@@ -17,11 +17,14 @@
  * 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;
 
+import java.util.List;
+
 import org.xwiki.component.annotation.Role;
+import org.xwiki.stability.Unstable;
 
 /**
  * Configuration of the authentication properties.
  *
  * @since 13.1RC1
@@ -52,6 +55,18 @@
      */
     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();
+    }
 }
diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/main/java/org/xwiki/security/authentication/internal/DefaultAuthenticationConfiguration.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/main/java/org/xwiki/security/authentication/internal/DefaultAuthenticationConfiguration.java
index 39be273b95f71..860640764f1e7 100644
--- a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/main/java/org/xwiki/security/authentication/internal/DefaultAuthenticationConfiguration.java
+++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/main/java/org/xwiki/security/authentication/internal/DefaultAuthenticationConfiguration.java
@@ -17,36 +17,46 @@
  * 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.Inject;
 import javax.inject.Named;
 import javax.inject.Singleton;
 
 import org.apache.commons.lang3.StringUtils;
 import org.xwiki.component.annotation.Component;
 import org.xwiki.configuration.ConfigurationSource;
 import org.xwiki.security.authentication.AuthenticationConfiguration;
 
+import static java.util.stream.Collectors.toList;
+
 /**
  * Default implementation for {@link AuthenticationConfiguration}.
  *
  * @version $Id$
  * @since 11.6RC1
  */
 @Component
 @Singleton
 public class DefaultAuthenticationConfiguration implements AuthenticationConfiguration
 {
+    private static final String COOKIE_PREFIX = ".";
+
     /**
      * Defines from where to read the Resource configuration data.
      */
     @Inject
     @Named("authentication")
     private ConfigurationSource configuration;
 
+    @Inject
+    @Named("xwikicfg")
+    private ConfigurationSource xwikiCfgConfiguration;
+
     @Override
     public int getMaxAuthorizedAttempts()
     {
         return configuration.getProperty("maxAuthorizedAttempts", 3);
     }
@@ -71,6 +81,17 @@ public String[] getFailureStrategies()
     @Override
     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());
+    }
 }
diff --git a/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/test/java/org/xwiki/security/authentication/internal/DefaultAuthenticationConfigurationTest.java b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/test/java/org/xwiki/security/authentication/internal/DefaultAuthenticationConfigurationTest.java
new file mode 100644
index 0000000000000..28a0ef6d790e1
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-security/xwiki-platform-security-authentication/xwiki-platform-security-authentication-default/src/test/java/org/xwiki/security/authentication/internal/DefaultAuthenticationConfigurationTest.java
@@ -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());
+    }
+}
diff --git a/xwiki-platform-tools/xwiki-platform-tool-configuration-resources/src/main/resources/xwiki.properties.vm b/xwiki-platform-tools/xwiki-platform-tool-configuration-resources/src/main/resources/xwiki.properties.vm
index e18f2b9171d42..4262ba931a6f7 100644
--- a/xwiki-platform-tools/xwiki-platform-tool-configuration-resources/src/main/resources/xwiki.properties.vm
+++ b/xwiki-platform-tools/xwiki-platform-tool-configuration-resources/src/main/resources/xwiki.properties.vm
@@ -1554,6 +1554,29 @@ edit.defaultEditor.org.xwiki.rendering.block.XDOM#wysiwyg=$xwikiPropertiesDefaul
 # whatsnew.sources = xwikiorg = xwikiblog
 # whatsnew.source.xwikiorg.rssURL = https://extensions.xwiki.org/news
 # 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

Vulnerable code:     <xwiki.jacoco.instructionRatio>0.00</xwiki.jacoco.instructionRatio>
import java.net.URI;
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 com.xpn.xwiki.web.XWikiRequest;
 * Default implementation of {@link DataURIConverter}.
 * 
public class DefaultDataURIConverter implements DataURIConverter, Initializable
    private static final String HEADER_COOKIE = "Cookie";

        cacheConfig.put(LRUEvictionConfiguration.CONFIGURATIONID, lru);
    public String convert(String url) throws DiffException
        if (url.startsWith("data:")) {
            // Already data URI.
            return url;
        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);

        return cachedDataURI;
    private URL getAbsoluteURI(String relativeURL) throws MalformedURLException
        XWikiContext xcontext = this.xcontextProvider.get();
        URL baseURL = xcontext.getURLFactory().getServerURL(xcontext);
        return new URL(baseURL, relativeURL);
    private String convert(URL url) throws IOException, URISyntaxException
        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));
    private HttpEntity fetch(URI uri) throws IOException
        HttpClientBuilder httpClientBuilder = HttpClientBuilder.create();
        httpClientBuilder.useSystemProperties();
        httpClientBuilder.setUserAgent("XWikiHTMLDiff");
        CloseableHttpClient httpClient = httpClientBuilder.build();
        HttpGet getMethod = new HttpGet(uri);
        XWikiRequest request = this.xcontextProvider.get().getRequest();
        if (request != null) {
            // Copy the cookies from the current request.
            getMethod.setHeader(HEADER_COOKIE, request.getHeader(HEADER_COOKIE));
        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());
Fixed code:     <xwiki.jacoco.instructionRatio>0.77</xwiki.jacoco.instructionRatio>
    <dependency>
      <groupId>org.apache.httpcomponents.client5</groupId>
      <artifactId>httpclient5</artifactId>
    </dependency>
    <dependency>
      <groupId>org.xwiki.platform</groupId>
      <artifactId>xwiki-platform-security-authentication-api</artifactId>
      <version>${project.version}</version>
    </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>
/*
 * 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();
}
import org.xwiki.cache.eviction.EntryEvictionConfiguration;
import org.xwiki.component.phase.Disposable;
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.XWikiException;
 * Default Implementation of {@link DataURIConverter} that uses an HTTP client to embed images.
 *
public class DefaultDataURIConverter implements Initializable, Disposable, DataURIConverter
    @Inject
    private URLSecurityManager urlSecurityManager;

    @Inject
    private UserReferenceSerializer<String> userReferenceSerializer;

    @Inject
    private ImageDownloader imageDownloader;

    @Inject
    private XMLDiffDataURIConverterConfiguration configuration;

    private Cache<DiffException> failureCache;

        if (!this.configuration.isEnabled()) {
            return;
        }

        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);

            this.failureCache = this.cacheManager.createNewCache(failureCacheConfiguration);
            // Dispose the cache if it has been created.
            if (this.cache != null) {
                this.cache.dispose();
            }
    public void dispose()
        if (this.cache != null) {
            this.cache.dispose();
        }
        if (this.failureCache != null) {
            this.failureCache.dispose();
    }
    /**
     * 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 absoluteURL;
    /**
     * 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)
        return String.format("data:%s;base64,%s", contentType, Base64.getEncoder().encodeToString(content));
    /**
     * 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)
        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());
    @Override
    public String convert(String url) throws DiffException
        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());
        String cacheKey = getCacheKey(absoluteURL);
        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;
    }
    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());
/*
 * 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);
    }

}
/*
 * 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;
    }
}
/*
 * 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));
    }

}
org.xwiki.diff.xml.internal.HttpClientBuilderFactory
org.xwiki.diff.xml.internal.ImageDownloader
org.xwiki.diff.xml.internal.DefaultXMLDiffDataURIConverterConfiguration
/*
 * 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);
    }
}
/*
 * 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);
        }
    }
}
    <!-- Needed for CompareIT -->
    <dependency>
      <groupId>org.xwiki.platform</groupId>
      <artifactId>xwiki-platform-diff-xml</artifactId>
      <version>${project.version}</version>
    </dependency>

    @Nested
    @DisplayName("Compare Tests")
    class NestedCompareIT extends CompareIT
    {
    }
/*
 * 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"));
    }
}
import java.util.List;

import org.xwiki.stability.Unstable;

    /**
     * @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();
    }
import java.util.List;

import static java.util.stream.Collectors.toList;

    private static final String COOKIE_PREFIX = ".";

    @Inject
    @Named("xwikicfg")
    private ConfigurationSource xwikiCfgConfiguration;


    @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());
    }
/*
 * 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());
    }
}
#-------------------------------------------------------------------------------------
# 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