diff --git a/flow-html-components/src/main/java/com/vaadin/flow/component/html/Anchor.java b/flow-html-components/src/main/java/com/vaadin/flow/component/html/Anchor.java index 58f2da25ee3..55422c4fc4e 100644 --- a/flow-html-components/src/main/java/com/vaadin/flow/component/html/Anchor.java +++ b/flow-html-components/src/main/java/com/vaadin/flow/component/html/Anchor.java @@ -26,7 +26,9 @@ import com.vaadin.flow.component.PropertyDescriptor; import com.vaadin.flow.component.PropertyDescriptors; import com.vaadin.flow.component.Tag; +import com.vaadin.flow.internal.UrlUtil; import com.vaadin.flow.server.AbstractStreamResource; +import com.vaadin.flow.server.InitParameters; import com.vaadin.flow.server.StreamResource; import com.vaadin.flow.server.StreamResourceRegistry; import com.vaadin.flow.server.streams.AbstractDownloadHandler; @@ -70,6 +72,11 @@ public Anchor() { * the href to set * @param text * the text content to set + * @throws IllegalArgumentException + * if {@code href} uses a scheme that is not considered safe; + * see {@link #setUnsafeHref(String)} and the + * {@value InitParameters#URL_SAFE_SCHEMES} configuration + * property */ public Anchor(String href, String text) { setHref(href); @@ -96,6 +103,11 @@ public Anchor(String href, String text) { * the href to set * @param textSignal * the signal to bind, not {@code null} + * @throws IllegalArgumentException + * if {@code href} uses a scheme that is not considered safe; + * see {@link #setUnsafeHref(String)} and the + * {@value InitParameters#URL_SAFE_SCHEMES} configuration + * property * @since 25.1 */ public Anchor(String href, Signal textSignal) { @@ -117,6 +129,11 @@ public Anchor(String href, Signal textSignal) { * the text content to set * @param target * the target window, tab or frame + * @throws IllegalArgumentException + * if {@code href} uses a scheme that is not considered safe; + * see {@link #setUnsafeHref(String)} and the + * {@value InitParameters#URL_SAFE_SCHEMES} configuration + * property */ public Anchor(String href, String text, AnchorTarget target) { setHref(href); @@ -248,6 +265,11 @@ public Anchor(DownloadHandler downloadHandler, * the href to set * @param components * the components to add + * @throws IllegalArgumentException + * if {@code href} uses a scheme that is not considered safe; + * see {@link #setUnsafeHref(String)} and the + * {@value InitParameters#URL_SAFE_SCHEMES} configuration + * property */ public Anchor(String href, Component... components) { setHref(href); @@ -268,8 +290,40 @@ public Anchor(String href, Component... components) { * * @param href * the href to set + * @throws IllegalArgumentException + * if the URL uses a scheme that is not considered safe; see + * {@link #setUnsafeHref(String)} and the + * {@value InitParameters#URL_SAFE_SCHEMES} configuration + * property */ public void setHref(String href) { + if (href == null) { + throw new IllegalArgumentException("Href must not be null"); + } + if (!UrlUtil.isSafeUrl(href)) { + throw new IllegalArgumentException(UrlUtil.getUnsafeUrlMessage( + "href", href, "setUnsafeHref(String)")); + } + this.href = href; + assignHrefAttribute(); + } + + /** + * Sets the URL that this anchor links to without validating its scheme. + *

+ * Unlike {@link #setHref(String)}, this method does not reject URLs based + * on the {@value InitParameters#URL_SAFE_SCHEMES} configuration. Use it + * only for URLs that are fully under your control and known to be safe, + * such as a hard-coded {@code javascript:} or {@code data:} URL. Passing + * untrusted input here can expose the application to cross-site scripting + * (XSS) attacks. + * + * @see #setHref(String) + * + * @param href + * the href to set + */ + public void setUnsafeHref(String href) { if (href == null) { throw new IllegalArgumentException("Href must not be null"); } diff --git a/flow-html-components/src/main/java/com/vaadin/flow/component/html/IFrame.java b/flow-html-components/src/main/java/com/vaadin/flow/component/html/IFrame.java index c8506300d85..ea405bdb0c9 100644 --- a/flow-html-components/src/main/java/com/vaadin/flow/component/html/IFrame.java +++ b/flow-html-components/src/main/java/com/vaadin/flow/component/html/IFrame.java @@ -24,7 +24,9 @@ import com.vaadin.flow.component.PropertyDescriptor; import com.vaadin.flow.component.PropertyDescriptors; import com.vaadin.flow.component.Tag; +import com.vaadin.flow.internal.UrlUtil; import com.vaadin.flow.server.AbstractStreamResource; +import com.vaadin.flow.server.InitParameters; import com.vaadin.flow.server.StreamResource; import com.vaadin.flow.server.streams.AbstractDownloadHandler; import com.vaadin.flow.server.streams.DownloadHandler; @@ -131,6 +133,11 @@ public IFrame() { * * @param src * Source URL + * @throws IllegalArgumentException + * if {@code src} uses a scheme that is not considered safe; see + * {@link #setUnsafeSrc(String)} and the + * {@value InitParameters#URL_SAFE_SCHEMES} configuration + * property */ public IFrame(String src) { setSrc(src); @@ -160,8 +167,36 @@ public IFrame(DownloadHandler downloadHandler) { * * @param src * Source URL. + * @throws IllegalArgumentException + * if the URL uses a scheme that is not considered safe; see + * {@link #setUnsafeSrc(String)} and the + * {@value InitParameters#URL_SAFE_SCHEMES} configuration + * property */ public void setSrc(String src) { + if (src != null && !UrlUtil.isSafeUrl(src)) { + throw new IllegalArgumentException(UrlUtil + .getUnsafeUrlMessage("src", src, "setUnsafeSrc(String)")); + } + set(srcDescriptor, src); + } + + /** + * Sets the source of the iframe without validating its scheme. + *

+ * Unlike {@link #setSrc(String)}, this method does not reject URLs based on + * the {@value InitParameters#URL_SAFE_SCHEMES} configuration. Use it only + * for URLs that are fully under your control and known to be safe, such as + * a hard-coded {@code javascript:} or {@code data:} URL. Passing untrusted + * input here can expose the application to cross-site scripting (XSS) + * attacks. + * + * @see #setSrc(String) + * + * @param src + * Source URL. + */ + public void setUnsafeSrc(String src) { set(srcDescriptor, src); } diff --git a/flow-html-components/src/main/java/com/vaadin/flow/component/html/Image.java b/flow-html-components/src/main/java/com/vaadin/flow/component/html/Image.java index a1f65cb4199..2366f5d2bdd 100644 --- a/flow-html-components/src/main/java/com/vaadin/flow/component/html/Image.java +++ b/flow-html-components/src/main/java/com/vaadin/flow/component/html/Image.java @@ -26,6 +26,7 @@ import com.vaadin.flow.component.PropertyDescriptors; import com.vaadin.flow.component.Tag; import com.vaadin.flow.server.AbstractStreamResource; +import com.vaadin.flow.server.InitParameters; import com.vaadin.flow.server.StreamResource; import com.vaadin.flow.server.streams.AbstractDownloadHandler; import com.vaadin.flow.server.streams.DownloadHandler; @@ -196,6 +197,10 @@ public String getSrc() { /** * Sets the image URL. + *

+ * Unlike {@link Anchor#setHref(String)} and {@link IFrame#setSrc(String)}, + * image URLs are not validated against the + * {@value InitParameters#URL_SAFE_SCHEMES} configuration. * * @param src * the image URL diff --git a/flow-html-components/src/test/java/com/vaadin/flow/component/html/AnchorTest.java b/flow-html-components/src/test/java/com/vaadin/flow/component/html/AnchorTest.java index 1f4116cdf93..67676d73167 100644 --- a/flow-html-components/src/test/java/com/vaadin/flow/component/html/AnchorTest.java +++ b/flow-html-components/src/test/java/com/vaadin/flow/component/html/AnchorTest.java @@ -28,10 +28,12 @@ import com.vaadin.flow.server.AbstractStreamResource; import com.vaadin.flow.server.streams.DownloadHandler; import com.vaadin.flow.server.streams.ServletResourceDownloadHandler; +import com.vaadin.flow.signals.local.ValueSignal; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class AnchorTest extends ComponentTest { @@ -428,6 +430,47 @@ void customDownloadHandler_nullType_constructorSetsDownloadMode() { "Custom download handlers should by default add download attribute"); } + @Test + void setHref_unsafeScheme_throws() { + Anchor anchor = new Anchor(); + assertThrows(IllegalArgumentException.class, + () -> anchor.setHref("javascript:alert(1)")); + } + + @Test + void setUnsafeHref_unsafeScheme_setsHrefWithoutValidation() { + Anchor anchor = new Anchor(); + anchor.setUnsafeHref("javascript:alert(1)"); + assertEquals("javascript:alert(1)", + anchor.getElement().getAttribute("href")); + } + + @Test + void constructor_stringHrefStringText_unsafeScheme_throws() { + assertThrows(IllegalArgumentException.class, + () -> new Anchor("javascript:alert(1)", "Click")); + } + + @Test + void constructor_stringHrefSignalText_unsafeScheme_throws() { + assertThrows(IllegalArgumentException.class, + () -> new Anchor("javascript:alert(1)", + new ValueSignal<>("Click"))); + } + + @Test + void constructor_stringHrefStringTextTarget_unsafeScheme_throws() { + assertThrows(IllegalArgumentException.class, + () -> new Anchor("javascript:alert(1)", "Click", + AnchorTarget.BLANK)); + } + + @Test + void constructor_stringHrefComponents_unsafeScheme_throws() { + assertThrows(IllegalArgumentException.class, + () -> new Anchor("javascript:alert(1)")); + } + private void mockUI() { ui = new UI(); UI.setCurrent(ui); diff --git a/flow-html-components/src/test/java/com/vaadin/flow/component/html/HtmlComponentSmokeTest.java b/flow-html-components/src/test/java/com/vaadin/flow/component/html/HtmlComponentSmokeTest.java index 131aaf9884e..97c9cc98a19 100644 --- a/flow-html-components/src/test/java/com/vaadin/flow/component/html/HtmlComponentSmokeTest.java +++ b/flow-html-components/src/test/java/com/vaadin/flow/component/html/HtmlComponentSmokeTest.java @@ -263,6 +263,11 @@ private static boolean isSpecialSetter(Method method) { return true; } + if (method.getDeclaringClass() == IFrame.class + && method.getName().equals("setUnsafeSrc")) { + return true; + } + if (method.getDeclaringClass() == HtmlObject.class && method.getName().startsWith("setData") && method.getParameterTypes()[0] == DownloadHandler.class) { @@ -275,6 +280,11 @@ private static boolean isSpecialSetter(Method method) { return true; } + if (method.getDeclaringClass() == Anchor.class + && method.getName().equals("setUnsafeHref")) { + return true; + } + if (method.getDeclaringClass() == Image.class && method.getName().startsWith("setSrc") && method.getParameterTypes()[0] == DownloadHandler.class) { diff --git a/flow-html-components/src/test/java/com/vaadin/flow/component/html/IFrameTest.java b/flow-html-components/src/test/java/com/vaadin/flow/component/html/IFrameTest.java index 0af700ace68..d152cb0b0d1 100644 --- a/flow-html-components/src/test/java/com/vaadin/flow/component/html/IFrameTest.java +++ b/flow-html-components/src/test/java/com/vaadin/flow/component/html/IFrameTest.java @@ -30,6 +30,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class IFrameTest extends ComponentTest { @@ -116,4 +117,24 @@ public Element getElement() { new TestIFrame(handler); assertTrue(handler.isInline()); } + + @Test + void setSrc_unsafeScheme_throws() { + IFrame iframe = new IFrame(); + assertThrows(IllegalArgumentException.class, + () -> iframe.setSrc("javascript:alert(1)")); + } + + @Test + void setUnsafeSrc_unsafeScheme_setsSrcWithoutValidation() { + IFrame iframe = new IFrame(); + iframe.setUnsafeSrc("javascript:alert(1)"); + assertEquals("javascript:alert(1)", iframe.getSrc()); + } + + @Test + void constructor_unsafeSrc_throws() { + assertThrows(IllegalArgumentException.class, + () -> new IFrame("javascript:alert(1)")); + } } diff --git a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java index ab9bf72d747..b7b6c3694ab 100644 --- a/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java +++ b/flow-server/src/main/java/com/vaadin/flow/component/page/Page.java @@ -40,6 +40,7 @@ import com.vaadin.flow.dom.JsFunction; import com.vaadin.flow.function.SerializableConsumer; import com.vaadin.flow.internal.UrlUtil; +import com.vaadin.flow.server.InitParameters; import com.vaadin.flow.shared.Registration; import com.vaadin.flow.shared.ui.Dependency; import com.vaadin.flow.shared.ui.Dependency.Type; @@ -559,6 +560,11 @@ void setPageVisibility(String value) { * * @param url * the URL to open. + * @throws IllegalArgumentException + * if {@code url} is {@code null}, or if the URL uses a scheme + * that is not considered safe; see {@link #openUnsafe(String)} + * and the {@value InitParameters#URL_SAFE_SCHEMES} + * configuration property */ public void open(String url) { open(url, "_blank"); @@ -596,8 +602,64 @@ public void open(String url) { * the URL to open. * @param windowName * the name of the window. + * @throws IllegalArgumentException + * if {@code url} is {@code null}, or if the URL uses a scheme + * that is not considered safe; see + * {@link #openUnsafe(String, String)} and the + * {@value InitParameters#URL_SAFE_SCHEMES} configuration + * property */ public void open(String url, String windowName) { + if (url == null) { + throw new IllegalArgumentException("URL must not be null"); + } + if (!UrlUtil.isSafeUrl(url)) { + throw new IllegalArgumentException(UrlUtil.getUnsafeUrlMessage( + "URL", url, "openUnsafe(String, String)")); + } + openInternal(url, windowName); + } + + /** + * Opens the given url in a new tab without validating its scheme. + *

+ * Unlike {@link #open(String)}, this method does not reject URLs based on + * the {@value InitParameters#URL_SAFE_SCHEMES} configuration. Use it only + * for URLs that are fully under your control and known to be safe. Passing + * untrusted input here can expose the application to cross-site scripting + * (XSS) attacks. + * + * @see #open(String) + * + * @param url + * the URL to open. + */ + public void openUnsafe(String url) { + openInternal(url, "_blank"); + } + + /** + * Opens the given URL in a window with the given name without validating + * its scheme. + *

+ * Unlike {@link #open(String, String)}, this method does not reject URLs + * based on the {@value InitParameters#URL_SAFE_SCHEMES} configuration. Use + * it only for URLs that are fully under your control and known to be safe. + * Passing untrusted input here can expose the application to cross-site + * scripting (XSS) attacks. + * + * @see #open(String, String) + * + * @param url + * the URL to open. + * @param windowName + * the name of the window. + */ + public void openUnsafe(String url, String windowName) { + openInternal(url, windowName); + } + + private void openInternal(String url, String windowName) { // The vaadin-redirect-pending event might be useful to block other // client side // reload/redirection triggered by other components, for example Vite. @@ -613,6 +675,12 @@ public void open(String url, String windowName) { * * @param uri * the URI to show + * @throws IllegalArgumentException + * if {@code uri} is {@code null}, or if the URI uses a scheme + * that is not considered safe; call + * {@code openUnsafe(uri, "_self")} to bypass scheme validation, + * and see the {@value InitParameters#URL_SAFE_SCHEMES} + * configuration property */ public void setLocation(String uri) { open(uri, "_self"); @@ -624,6 +692,13 @@ public void setLocation(String uri) { * * @param uri * the URI to show + * @throws IllegalArgumentException + * if {@code uri} is {@code null}, or if the URI uses a scheme + * that is not considered safe; call + * {@code openUnsafe(uri.toString(), "_self")} to bypass scheme + * validation, and see the + * {@value InitParameters#URL_SAFE_SCHEMES} configuration + * property */ public void setLocation(URI uri) { setLocation(uri.toString()); diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/UrlUtil.java b/flow-server/src/main/java/com/vaadin/flow/internal/UrlUtil.java index ab70f2dacd8..ea50a0796c1 100644 --- a/flow-server/src/main/java/com/vaadin/flow/internal/UrlUtil.java +++ b/flow-server/src/main/java/com/vaadin/flow/internal/UrlUtil.java @@ -20,9 +20,15 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; +import com.vaadin.flow.server.InitParameters; +import com.vaadin.flow.server.VaadinService; + /** * Internal utility class for URL handling. *

@@ -237,4 +243,168 @@ public static String getServletPathRelative(String absolutePath, } return ret.substring(0, ret.length() - 1); } + + /** + * The URL schemes that are considered safe by default when no custom set is + * configured through the {@link InitParameters#URL_SAFE_SCHEMES} property. + *

+ * Script-capable schemes such as {@code javascript} and {@code data} are + * intentionally excluded as they can be used to execute scripts in the + * browser when used as a link or frame target. + */ + public static final Set DEFAULT_SAFE_SCHEMES = Set.of("http", + "https", "mailto", "tel", "ftp"); + + /** + * Special {@link InitParameters#URL_SAFE_SCHEMES} entry that marks every + * scheme as safe, disabling scheme validation. Mixing this entry with other + * schemes still disables validation. + */ + public static final String ALL_SCHEMES_SAFE = "*"; + + /** + * Checks whether the scheme of the given URL is considered safe by the + * current deployment configuration. + *

+ * The set of safe schemes is read from the + * {@link InitParameters#URL_SAFE_SCHEMES} configuration property, falling + * back to {@link #DEFAULT_SAFE_SCHEMES} when the property is not set or + * when no {@link VaadinService} is available. Relative URLs (without a + * scheme) are always considered safe, whereas URLs containing control + * characters are rejected as they can be used to obfuscate the scheme. + *

+ * When called from a thread without a current {@link VaadinService}, the + * configured safe schemes are not read and {@link #DEFAULT_SAFE_SCHEMES} is + * used instead. + * + * @param url + * the URL to check, may be {@code null} + * @return {@code true} if the URL is safe, {@code false} otherwise + */ + public static boolean isSafeUrl(String url) { + return isSafeUrl(url, getConfiguredSafeSchemes()); + } + + /** + * Builds the message for the {@link IllegalArgumentException} that a + * validating URL setter throws when given a URL whose scheme is not + * considered safe. The message points to both the + * {@link InitParameters#URL_SAFE_SCHEMES} configuration property and the + * setter that bypasses validation. + * + * @param type + * the kind of URL being set, for example {@code "href"}, + * {@code "src"} or {@code "path"} + * @param url + * the rejected URL + * @param unsafeMethod + * the signature of the method that bypasses validation, for + * example {@code "setUnsafeHref(String)"} + * @return the exception message + */ + public static String getUnsafeUrlMessage(String type, String url, + String unsafeMethod) { + return String.format( + "The %s \"%s\" uses a scheme that is not considered safe. " + + "Configure the safe schemes with the \"%s\" property, " + + "or use %s if this URL is intentional and trusted.", + type, url, InitParameters.URL_SAFE_SCHEMES, unsafeMethod); + } + + /** + * Checks whether the scheme of the given URL is part of the given set of + * safe schemes. See {@link #isSafeUrl(String)} for the validation rules. + * + * @param url + * the URL to check, may be {@code null} + * @param safeSchemes + * the set of safe lower-case schemes, or a set containing + * {@link #ALL_SCHEMES_SAFE} to treat any scheme as safe + * @return {@code true} if the URL is safe, {@code false} otherwise + */ + static boolean isSafeUrl(String url, Set safeSchemes) { + if (url == null) { + return false; + } + // A wildcard entry treats every scheme as safe, keeping the behaviour + // fully backwards compatible for applications that opt out. + if (safeSchemes.contains(ALL_SCHEMES_SAFE)) { + return true; + } + String trimmed = url.trim(); + if (trimmed.isEmpty()) { + return true; + } + // Reject control characters which browsers may strip, allowing a + // different URL to be acted upon than the one validated here (for + // example "java\tscript:alert(1)"). + for (int i = 0; i < trimmed.length(); i++) { + if (Character.isISOControl(trimmed.charAt(i))) { + return false; + } + } + String scheme = extractScheme(trimmed); + if (scheme == null) { + // Relative URLs have no scheme and cannot trigger scheme-based + // script execution. + return true; + } + return safeSchemes.contains(scheme.toLowerCase(Locale.ROOT)); + } + + /** + * Extracts the scheme from the given URL, or returns {@code null} if the + * URL is relative (has no scheme). The scheme is determined according to + * RFC 3986: a letter followed by any number of letters, digits, + * {@code '+'}, {@code '-'} or {@code '.'}, terminated by a {@code ':'} that + * occurs before any {@code '/'}, {@code '?'} or {@code '#'}. + *

+ * The scheme is extracted without parsing the whole URL so that valid + * relative URLs containing characters that a strict URI parser would reject + * (such as spaces) are not falsely flagged. + */ + private static String extractScheme(String url) { + for (int i = 0; i < url.length(); i++) { + char c = url.charAt(i); + if (c == ':') { + return i == 0 ? null : url.substring(0, i); + } + boolean validSchemeChar = (i == 0) ? isAlpha(c) + : (isAlpha(c) || (c >= '0' && c <= '9') || c == '+' + || c == '-' || c == '.'); + if (!validSchemeChar) { + // The ':' (if any) belongs to the path or query, so there is no + // scheme and the URL is relative. + return null; + } + } + return null; + } + + private static boolean isAlpha(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + } + + private static Set getConfiguredSafeSchemes() { + VaadinService service = VaadinService.getCurrent(); + if (service == null) { + return DEFAULT_SAFE_SCHEMES; + } + return parseSafeSchemes(service.getDeploymentConfiguration() + .getStringProperty(InitParameters.URL_SAFE_SCHEMES, null)); + } + + static Set parseSafeSchemes(String configured) { + if (configured == null || configured.isBlank()) { + return DEFAULT_SAFE_SCHEMES; + } + Set schemes = new HashSet<>(); + for (String scheme : configured.split(",")) { + String trimmed = scheme.trim(); + if (!trimmed.isEmpty()) { + schemes.add(trimmed.toLowerCase(Locale.ROOT)); + } + } + return schemes.isEmpty() ? DEFAULT_SAFE_SCHEMES : schemes; + } } diff --git a/flow-server/src/main/java/com/vaadin/flow/server/InitParameters.java b/flow-server/src/main/java/com/vaadin/flow/server/InitParameters.java index 9e2fd1f525e..057f663a3c5 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/InitParameters.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/InitParameters.java @@ -315,4 +315,20 @@ public class InitParameters implements Serializable { */ public static final String MINIMUM_FRONTEND_PACKAGE_AGE_DAYS = "npm.minimumFrontendPackageAgeDays"; + /** + * Configuration name for the comma-separated list of URL schemes that are + * considered safe in URLs set on components such as {@code Anchor}, + * {@code IFrame} and in + * {@link com.vaadin.flow.component.page.Page#open(String, String)}. + *

+ * When not set, a built-in default set of safe schemes is used (for example + * {@code http}, {@code https}, {@code mailto}, {@code tel} and + * {@code ftp}), which excludes script-capable schemes such as + * {@code javascript} and {@code data}. Any entry equal to {@code *} marks + * every scheme as safe, disabling scheme validation. URLs whose scheme is + * not safe can still be set through the dedicated {@code setUnsafe*} + * methods. + */ + public static final String URL_SAFE_SCHEMES = "com.vaadin.safeUrlSchemes"; + } diff --git a/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java b/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java index 747a146e97c..16393ae0b99 100644 --- a/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/component/page/PageTest.java @@ -350,6 +350,95 @@ public PendingJavaScriptResult executeJs(String expression, "Event dispatch should come before window.open in the script"); } + @Test + void open_unsafeScheme_throws() { + Page page = new Page(new MockUI()) { + @Override + public PendingJavaScriptResult executeJs(String expression, + Object... parameters) { + return fail("Unsafe URL should not reach the client"); + } + }; + + assertThrows(IllegalArgumentException.class, + () -> page.open("javascript:alert(1)")); + assertThrows(IllegalArgumentException.class, + () -> page.open("javascript:alert(1)", "_blank")); + } + + @Test + void setLocation_unsafeScheme_throws() { + Page page = new Page(new MockUI()) { + @Override + public PendingJavaScriptResult executeJs(String expression, + Object... parameters) { + return fail("Unsafe URL should not reach the client"); + } + }; + + assertThrows(IllegalArgumentException.class, + () -> page.setLocation("javascript:alert(1)")); + } + + @Test + void open_nullUrl_throwsWithUsefulMessage() { + Page page = new Page(new MockUI()) { + @Override + public PendingJavaScriptResult executeJs(String expression, + Object... parameters) { + return fail("Null URL should not reach the client"); + } + }; + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> page.open(null, "_blank")); + assertEquals("URL must not be null", ex.getMessage()); + } + + @Test + void openUnsafe_unsafeScheme_opensWithoutValidation() { + AtomicReference capture = new AtomicReference<>(); + List params = new ArrayList<>(); + Page page = new Page(new MockUI()) { + @Override + public PendingJavaScriptResult executeJs(String expression, + Object... parameters) { + capture.set(expression); + params.addAll(Arrays.asList(parameters)); + return Mockito.mock(PendingJavaScriptResult.class); + } + }; + + page.openUnsafe("javascript:alert(1)"); + + assertTrue(capture.get().contains("window.open"), + "Should call window.open"); + assertEquals("javascript:alert(1)", params.get(0)); + } + + @Test + void openUnsafe_twoArg_opensWithoutValidation() { + AtomicReference capture = new AtomicReference<>(); + List params = new ArrayList<>(); + Page page = new Page(new MockUI()) { + @Override + public PendingJavaScriptResult executeJs(String expression, + Object... parameters) { + capture.set(expression); + params.addAll(Arrays.asList(parameters)); + return Mockito.mock(PendingJavaScriptResult.class); + } + }; + + page.openUnsafe("javascript:alert(1)", "_blank"); + + assertTrue(capture.get().contains("window.open"), + "Should call window.open"); + assertEquals("javascript:alert(1)", params.get(0)); + assertEquals("_blank", params.get(1)); + } + @Test void setColorScheme_setsStyleProperty() { AtomicReference capturedExpression = new AtomicReference<>(); diff --git a/flow-server/src/test/java/com/vaadin/flow/internal/UrlUtilTest.java b/flow-server/src/test/java/com/vaadin/flow/internal/UrlUtilTest.java index 6c611987841..5d077d3a6ba 100644 --- a/flow-server/src/test/java/com/vaadin/flow/internal/UrlUtilTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/internal/UrlUtilTest.java @@ -17,9 +17,15 @@ import jakarta.servlet.http.HttpServletRequest; +import java.util.Set; + import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; import org.mockito.Mockito; +import com.vaadin.flow.function.DeploymentConfiguration; +import com.vaadin.flow.server.InitParameters; +import com.vaadin.flow.server.VaadinService; import com.vaadin.flow.server.VaadinServletRequest; import com.vaadin.flow.server.VaadinServletService; @@ -204,4 +210,114 @@ void appendQueryParameter_nullValue_returnsOriginalUrl() { null); assertEquals("/styles.css", result); } + + @Test + void isSafeUrl_safeScheme_returnsTrue() { + assertTrue(UrlUtil.isSafeUrl("https://vaadin.com", + UrlUtil.DEFAULT_SAFE_SCHEMES)); + } + + @Test + void isSafeUrl_unsafeScheme_returnsFalse() { + assertFalse(UrlUtil.isSafeUrl("javascript:alert(1)", + UrlUtil.DEFAULT_SAFE_SCHEMES)); + assertFalse(UrlUtil.isSafeUrl("data:text/html,