diff --git a/xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-resources/src/main/resources/flamingo/javascript.vm b/xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-resources/src/main/resources/flamingo/javascript.vm
index a897d8caccb7..2b9b710d182c 100644
--- a/xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-resources/src/main/resources/flamingo/javascript.vm
+++ b/xwiki-platform-core/xwiki-platform-flamingo/xwiki-platform-flamingo-skin/xwiki-platform-flamingo-skin-resources/src/main/resources/flamingo/javascript.vm
@@ -299,6 +299,15 @@ $xwiki.jsfx.use("flamingo$jsExtension", {'forceSkinAction' : true, 'language' :
'wysiwyg': true
})##
#end
+#if ($services.security.url.isFrontendUrlCheckEnabled())
+
+ $xwiki.jsfx.use('uicomponents/link/link-protection.js')##
+#end
##
## Hooks for inserting JavaScript skin extensions
##
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/NavigationIT.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/NavigationIT.java
index d496c9abb00c..3c8495947ed4 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/NavigationIT.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/NavigationIT.java
@@ -27,6 +27,9 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
+import org.openqa.selenium.Alert;
+import org.openqa.selenium.By;
+import org.openqa.selenium.NoAlertPresentException;
import org.xwiki.administration.test.po.AdministrationPage;
import org.xwiki.flamingo.skin.test.po.AttachmentsPane;
import org.xwiki.flamingo.skin.test.po.AttachmentsViewPage;
@@ -35,6 +38,7 @@
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.XWikiWebDriver;
import org.xwiki.test.ui.po.CommentsTab;
import org.xwiki.test.ui.po.HistoryPane;
import org.xwiki.test.ui.po.InformationPane;
@@ -43,6 +47,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
/**
* Tests related to navigation in the wiki.
@@ -50,7 +55,10 @@
* @since 11.10
* @version $Id$
*/
-@UITest
+@UITest(properties = {
+ "xwikiPropertiesAdditionalProperties=url.trustedDomains=www.xwiki.org,extensions.xwiki.org\n"
+ + "url.allowedFrontendUrls=https://github.com/xwiki/xwiki-platform,https://github.com/xwiki/"
+})
public class NavigationIT
{
@BeforeAll
@@ -206,4 +214,87 @@ public void simpleBinUrlDoesNotThrowException(TestUtils testUtils)
ViewPage viewPage = new ViewPage();
assertEquals("XWiki - Main - Main", viewPage.getPageTitle());
}
+
+ @Order(5)
+ @Test
+ void navigationToExternalPages(TestUtils testUtils, TestReference testReference) throws Exception
+ {
+ String pageContent = """
+ [[Internal link>>doc:Navigation.Test]]
+ [[Google external>>https://www.google.com]]
+ [[XWiki.org external>>https://www.xwiki.org]]
+ [[Contrib xwiki>>https://contrib.xwiki.org]]
+ [[Extensions xwiki>>https://extensions.xwiki.org]]
+ [[Specific extensions pages>>https://extensions.xwiki.org/bin/view/WebHome]]
+ [[Github commons>>https://github.com/xwiki/xwiki-commons]]
+ [[Github XWiki>>https://github.com/xwiki/]]
+ [[Github platform>>https://github.com/xwiki/xwiki-platform]]
+ """;
+ testUtils.rest().savePage(testReference, pageContent, "Test link navigation");
+ testUtils.rest().savePage(new DocumentReference("xwiki", "Navigation", "Test"), "Test navigation internal "
+ + "link", "Navigation test page");
+
+ XWikiWebDriver driver = testUtils.getDriver();
+ testUtils.gotoPage(testReference);
+ driver.findElementWithoutWaiting(By.linkText("Google external")).click();
+ Alert alert = driver.switchTo().alert();
+ assertEquals("You are about to leave the domain \"host.testcontainers.internal\" to follow a link "
+ + "to \"www.google.com\". Are you sure you want to continue?", alert.getText());
+ alert.dismiss();
+
+ driver.findElementWithoutWaiting(By.linkText("Internal link")).click();
+ ViewPage viewPage = new ViewPage();
+ assertEquals("Test navigation internal link", viewPage.getContent());
+
+ testUtils.gotoPage(testReference);
+ driver.findElementWithoutWaiting(By.linkText("XWiki.org external")).click();
+ try {
+ driver.switchTo().alert();
+ fail("No alert should be present");
+ } catch (NoAlertPresentException e) {
+ }
+
+ testUtils.gotoPage(testReference);
+ driver.findElementWithoutWaiting(By.linkText("Contrib xwiki")).click();
+ alert = driver.switchTo().alert();
+ assertEquals("You are about to leave the domain \"host.testcontainers.internal\" to follow a link "
+ + "to \"contrib.xwiki.org\". Are you sure you want to continue?", alert.getText());
+ alert.dismiss();
+
+ driver.findElementWithoutWaiting(By.linkText("Extensions xwiki")).click();
+ try {
+ driver.switchTo().alert();
+ fail("No alert should be present");
+ } catch (NoAlertPresentException e) {
+ }
+
+ testUtils.gotoPage(testReference);
+ driver.findElementWithoutWaiting(By.linkText("Specific extensions pages")).click();
+ try {
+ driver.switchTo().alert();
+ fail("No alert should be present");
+ } catch (NoAlertPresentException e) {
+ }
+
+ testUtils.gotoPage(testReference);
+ driver.findElementWithoutWaiting(By.linkText("Github commons")).click();
+ assertEquals("You are about to leave the domain \"host.testcontainers.internal\" to follow a link "
+ + "to \"github.com\". Are you sure you want to continue?", alert.getText());
+ alert.dismiss();
+
+ driver.findElementWithoutWaiting(By.linkText("Github XWiki")).click();
+ try {
+ driver.switchTo().alert();
+ fail("No alert should be present");
+ } catch (NoAlertPresentException e) {
+ }
+
+ testUtils.gotoPage(testReference);
+ driver.findElementWithoutWaiting(By.linkText("Github platform")).click();
+ try {
+ driver.switchTo().alert();
+ fail("No alert should be present");
+ } catch (NoAlertPresentException e) {
+ }
+ }
}
diff --git a/xwiki-platform-core/xwiki-platform-url/xwiki-platform-url-api/src/main/java/org/xwiki/url/URLConfiguration.java b/xwiki-platform-core/xwiki-platform-url/xwiki-platform-url-api/src/main/java/org/xwiki/url/URLConfiguration.java
index 55f6b20be1a7..48cf1676692a 100644
--- a/xwiki-platform-core/xwiki-platform-url/xwiki-platform-url-api/src/main/java/org/xwiki/url/URLConfiguration.java
+++ b/xwiki-platform-core/xwiki-platform-url/xwiki-platform-url-api/src/main/java/org/xwiki/url/URLConfiguration.java
@@ -19,10 +19,10 @@
*/
package org.xwiki.url;
-import java.util.Collections;
import java.util.List;
import org.xwiki.component.annotation.Role;
+import org.xwiki.stability.Unstable;
/**
* Configuration options for the URL module.
@@ -60,7 +60,7 @@ default boolean useResourceLastModificationDate()
*/
default List getTrustedDomains()
{
- return Collections.emptyList();
+ return List.of();
}
/**
@@ -88,4 +88,34 @@ default List getTrustedSchemes()
{
return List.of("http", "https", "ftp");
}
+
+ /**
+ * @return {@code true} if checks should be done in the frontend when clicking on a link to validate it's driving
+ * to an authorized domain. This is independent from {@link #isTrustedDomainsEnabled()} which aims at enabling
+ * checks server side only.
+ * @since 17.9.0RC1
+ * @since 17.4.6
+ * @since 16.10.13
+ */
+ @Unstable
+ default boolean isFrontendUrlCheckEnabled()
+ {
+ return true;
+ }
+
+ /**
+ * Define a list of allowed frontend URLs: in case the {@link #isFrontendUrlCheckEnabled()} is enabled, then
+ * this list can be used to allow specific URLs without asking confirmation from the user, while avoiding to add
+ * an entire domain in the list of trusted domains.
+ *
+ * @return the list of allowed frontend URLs
+ * @since 17.9.0RC1
+ * @since 17.4.6
+ * @since 16.10.13
+ */
+ @Unstable
+ default List getAllowedFrontendUrls()
+ {
+ return List.of();
+ }
}
diff --git a/xwiki-platform-core/xwiki-platform-url/xwiki-platform-url-api/src/main/java/org/xwiki/url/script/URLSecurityScriptService.java b/xwiki-platform-core/xwiki-platform-url/xwiki-platform-url-api/src/main/java/org/xwiki/url/script/URLSecurityScriptService.java
index 8a721f884ac8..7eeafca11027 100644
--- a/xwiki-platform-core/xwiki-platform-url/xwiki-platform-url-api/src/main/java/org/xwiki/url/script/URLSecurityScriptService.java
+++ b/xwiki-platform-core/xwiki-platform-url/xwiki-platform-url-api/src/main/java/org/xwiki/url/script/URLSecurityScriptService.java
@@ -21,6 +21,7 @@
import java.net.URI;
import java.net.URISyntaxException;
+import java.util.List;
import javax.inject.Inject;
import javax.inject.Named;
@@ -29,6 +30,8 @@
import org.slf4j.Logger;
import org.xwiki.component.annotation.Component;
import org.xwiki.script.service.ScriptService;
+import org.xwiki.stability.Unstable;
+import org.xwiki.url.URLConfiguration;
import org.xwiki.url.URLSecurityManager;
/**
@@ -46,6 +49,9 @@ public class URLSecurityScriptService implements ScriptService
@Inject
private URLSecurityManager urlSecurityManager;
+ @Inject
+ private URLConfiguration urlConfiguration;
+
@Inject
private Logger logger;
@@ -71,4 +77,40 @@ public URI parseToSafeURI(String uriRepresentation) throws URISyntaxException, S
return null;
}
}
+
+ /**
+ * @return the list of trusted domains.
+ * @since 17.9.0RC1
+ * @since 17.4.6
+ * @since 16.10.13
+ */
+ @Unstable
+ public List getTrustedDomains()
+ {
+ return this.urlConfiguration.getTrustedDomains();
+ }
+
+ /**
+ * @return {@code true} if the mechanism to enforce URLs check on frontend is enabled.
+ * @since 17.9.0RC1
+ * @since 17.4.6
+ * @since 16.10.13
+ */
+ @Unstable
+ public boolean isFrontendUrlCheckEnabled()
+ {
+ return this.urlConfiguration.isFrontendUrlCheckEnabled();
+ }
+
+ /**
+ * @return the list of URLs that are allowed to avoid asking confirmation to users when accessing them.
+ * @since 17.9.0RC1
+ * @since 17.4.6
+ * @since 16.10.13
+ */
+ @Unstable
+ public List getAllowedFrontendUrls()
+ {
+ return this.urlConfiguration.getAllowedFrontendUrls();
+ }
}
diff --git a/xwiki-platform-core/xwiki-platform-url/xwiki-platform-url-api/src/main/resources/ApplicationResources.properties b/xwiki-platform-core/xwiki-platform-url/xwiki-platform-url-api/src/main/resources/ApplicationResources.properties
new file mode 100644
index 000000000000..d1c5dc728fd1
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-url/xwiki-platform-url-api/src/main/resources/ApplicationResources.properties
@@ -0,0 +1,21 @@
+# ---------------------------------------------------------------------------
+# 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.
+# ---------------------------------------------------------------------------
+url.api.followLinkConfirmationText=You are about to leave the domain "{0}" to follow a link to "{1}". Are you sure \
+ you want to continue?
\ No newline at end of file
diff --git a/xwiki-platform-core/xwiki-platform-url/xwiki-platform-url-default/src/main/java/org/xwiki/url/internal/DefaultURLConfiguration.java b/xwiki-platform-core/xwiki-platform-url/xwiki-platform-url-default/src/main/java/org/xwiki/url/internal/DefaultURLConfiguration.java
index a98a1349181e..677aba936c0c 100644
--- a/xwiki-platform-core/xwiki-platform-url/xwiki-platform-url-default/src/main/java/org/xwiki/url/internal/DefaultURLConfiguration.java
+++ b/xwiki-platform-core/xwiki-platform-url/xwiki-platform-url-default/src/main/java/org/xwiki/url/internal/DefaultURLConfiguration.java
@@ -19,7 +19,6 @@
*/
package org.xwiki.url.internal;
-import java.util.Collections;
import java.util.List;
import javax.inject.Inject;
@@ -67,7 +66,7 @@ public boolean useResourceLastModificationDate()
@Override
public List getTrustedDomains()
{
- return this.configuration.get().getProperty(PREFIX + "trustedDomains", Collections.emptyList());
+ return this.configuration.get().getProperty(PREFIX + "trustedDomains", List.of());
}
@Override
@@ -81,4 +80,16 @@ public List getTrustedSchemes()
{
return this.configuration.get().getProperty(PREFIX + "trustedSchemes", List.of("http", "https", "ftp"));
}
+
+ @Override
+ public boolean isFrontendUrlCheckEnabled()
+ {
+ return this.configuration.get().getProperty(PREFIX + "frontendUrlCheckEnabled", true);
+ }
+
+ @Override
+ public List getAllowedFrontendUrls()
+ {
+ return this.configuration.get().getProperty(PREFIX + "allowedFrontendUrls", List.of());
+ }
}
diff --git a/xwiki-platform-core/xwiki-platform-web/xwiki-platform-web-war/src/main/webapp/resources/uicomponents/link/link-protection.js b/xwiki-platform-core/xwiki-platform-web/xwiki-platform-web-war/src/main/webapp/resources/uicomponents/link/link-protection.js
new file mode 100644
index 000000000000..a43ef557331a
--- /dev/null
+++ b/xwiki-platform-core/xwiki-platform-web/xwiki-platform-web-war/src/main/webapp/resources/uicomponents/link/link-protection.js
@@ -0,0 +1,85 @@
+/*
+ * 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.
+ */
+define('link-protection-translations', {
+ prefix: '',
+ keys: [
+ 'url.api.followLinkConfirmationText'
+ ]
+});
+require(['jquery', 'xwiki-l10n!link-protection-translations', 'xwiki-events-bridge'], function ($, l10n) {
+
+ function protectLinks () {
+ let configuration = null;
+ try {
+ const trustedDomainConfigElement = $('script#trusted-domains-configuration');
+ if (trustedDomainConfigElement.length > 0) {
+ configuration = JSON.parse(trustedDomainConfigElement.text());
+ }
+ } catch (err) {
+ console.error("Error while parsing the trusted domain configurations, falling back on enforcing checks on all" +
+ " links going outside current domain.", err);
+ }
+ $(document).on('click', 'a[href]', function (event) {
+ return askIfLinkNotTrusted(event, this, configuration);
+ });
+ }
+
+ function askIfLinkNotTrusted (event, anchor, configuration) {
+ let currentHostname = window.location.hostname;
+ let anchorHostname = anchor.hostname;
+ let customizedMessage = l10n.get('url.api.followLinkConfirmationText', currentHostname, anchorHostname);
+ if (configuration == null && !isAnchorCurrentDomain(anchor)) {
+ return confirm(customizedMessage);
+ } else if (!isAnchorTrustedOomain(anchor, configuration.trustedDomains, configuration.allowedUrls)) {
+ return confirm(customizedMessage);
+ } else {
+ return true;
+ }
+ }
+
+ function isAnchorCurrentDomain (anchor) {
+ let currentHostname = window.location.hostname;
+ let anchorHostname = anchor.hostname;
+ return (!anchorHostname || anchorHostname === currentHostname);
+ }
+
+ function isAnchorTrustedOomain (anchor, trustedDomains, allowedUrls) {
+ if (isAnchorCurrentDomain(anchor)) {
+ return true;
+ } else {
+ if (allowedUrls.indexOf(anchor.href) > -1) {
+ return true;
+ }
+ let host = anchor.hostname;
+ do {
+ if (trustedDomains.indexOf(host) > -1) {
+ return true;
+ } else if (host.indexOf(".") > -1) {
+ host = host.substring(host.indexOf(".") + 1);
+ } else {
+ host = "";
+ }
+ } while (host !== "");
+ }
+ return false;
+ }
+
+ (XWiki.domIsLoaded && protectLinks()) || document.observe('xwiki:dom:loaded', protectLinks);
+});
\ No newline at end of file
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 290ac718fdf3..f96d9d3bb45e 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
@@ -1035,6 +1035,24 @@ distribution.automaticStartOnWiki=$xwikiPropertiesAutomaticStartOnWiki
#-# The default is:
# url.forceAllowAnyCharacter=true
+#-# [Since 17.9.0RC1]
+#-# [Since 17.4.6]
+#-# [Since 16.10.13]
+#-# Allow to enable or disable checks performed when clicking links in the UI based on the list of trusted domains.
+#-#
+#-# By default this property is set to true:
+# url.frontendUrlCheckEnabled=true
+
+#-# [Since 17.9.0RC1]
+#-# [Since 17.4.6]
+#-# [Since 16.10.13]
+#-# Allow to allow specific URLs to be accessible from the frontend without asking confirmation, and without
+#-# needing to allow and entire domain. The expected format is absolute URLs separated by commas, e.g.:
+#-# https://github.com/xwiki/xwiki-platform,https://www.xwiki.org/xwiki/bin/view/Main/WebHome
+#-#
+#-# By default this property is empty:
+# url.allowedFrontendUrls=
+
#-------------------------------------------------------------------------------------
# Attachment
#-------------------------------------------------------------------------------------