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 #-------------------------------------------------------------------------------------