From 8936fc15da3e052417e633b7153a706bb61c7b1a Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Mon, 8 Jun 2026 09:56:27 +0300 Subject: [PATCH 01/14] feat: validate URL schemes in Anchor, IFrame and Page#open Re-introduce URL-scheme validation for link and navigation sinks after the revert of #24371, using application-wide configuration plus a per-instance opt-out instead of the previous thread-unsafe static setter. Safe schemes are read from the new com.vaadin.safeUrlSchemes (InitParameters.URL_SAFE_SCHEMES) configuration property, defaulting to http, https, mailto, tel and ftp so that script-capable schemes such as javascript and data are rejected. Setting the property to "*" marks every scheme as safe and keeps the previous behaviour. Relative URLs are always considered safe; the scheme is extracted manually rather than via URI parsing so that valid relative URLs (e.g. containing spaces) are not falsely rejected. For trusted, hard-coded URLs whose scheme is not configured as safe, each sink offers an unsafe variant that bypasses validation: Anchor#setUnsafeHref, IFrame#setUnsafeSrc and Page#openUnsafe. --- .../vaadin/flow/component/html/Anchor.java | 37 +++++ .../vaadin/flow/component/html/IFrame.java | 33 +++++ .../flow/component/html/AnchorTest.java | 16 ++ .../flow/component/html/IFrameTest.java | 15 ++ .../com/vaadin/flow/component/page/Page.java | 56 +++++++ .../com/vaadin/flow/internal/UrlUtil.java | 139 ++++++++++++++++++ .../vaadin/flow/server/InitParameters.java | 17 +++ .../vaadin/flow/component/page/PageTest.java | 37 +++++ .../com/vaadin/flow/internal/UrlUtilTest.java | 76 ++++++++++ 9 files changed, 426 insertions(+) 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..c1607e516f4 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; @@ -268,8 +270,43 @@ 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(String.format( + "The href \"%s\" uses a scheme that is not considered safe. " + + "Configure the safe schemes with the \"%s\" property, " + + "or use setUnsafeHref(String) if this URL is intentional and trusted.", + href, InitParameters.URL_SAFE_SCHEMES)); + } + 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..c372ad55249 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; @@ -160,8 +162,39 @@ 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(String.format( + "The src \"%s\" uses a scheme that is not considered safe. " + + "Configure the safe schemes with the \"%s\" property, " + + "or use setUnsafeSrc(String) if this URL is intentional and trusted.", + src, InitParameters.URL_SAFE_SCHEMES)); + } + 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/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..599696ee4ee 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 @@ -32,6 +32,7 @@ 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 +429,21 @@ void customDownloadHandler_nullType_constructorSetsDownloadMode() { "Custom download handlers should by default add download attribute"); } + @Test + void setHref_disallowedScheme_throws() { + Anchor anchor = new Anchor(); + assertThrows(IllegalArgumentException.class, + () -> anchor.setHref("javascript:alert(1)")); + } + + @Test + void setUnsafeHref_disallowedScheme_setsHrefWithoutValidation() { + Anchor anchor = new Anchor(); + anchor.setUnsafeHref("javascript:alert(1)"); + assertEquals("javascript:alert(1)", + anchor.getElement().getAttribute("href")); + } + private void mockUI() { ui = new UI(); UI.setCurrent(ui); 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..e3c53295944 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,18 @@ public Element getElement() { new TestIFrame(handler); assertTrue(handler.isInline()); } + + @Test + void setSrc_disallowedScheme_throws() { + IFrame iframe = new IFrame(); + assertThrows(IllegalArgumentException.class, + () -> iframe.setSrc("javascript:alert(1)")); + } + + @Test + void setUnsafeSrc_disallowedScheme_setsSrcWithoutValidation() { + IFrame iframe = new IFrame(); + iframe.setUnsafeSrc("javascript:alert(1)"); + assertEquals("javascript:alert(1)", iframe.getSrc()); + } } 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..2055ee94b2c 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; @@ -596,8 +597,63 @@ public void open(String url) { * the URL to open. * @param windowName * the name of the window. + * @throws IllegalArgumentException + * 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 (!UrlUtil.isSafeUrl(url)) { + throw new IllegalArgumentException(String.format( + "The URL \"%s\" uses a scheme that is not considered safe. " + + "Configure the safe schemes with the \"%s\" property, " + + "or use openUnsafe(String, String) if this URL is intentional and trusted.", + url, InitParameters.URL_SAFE_SCHEMES)); + } + 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. 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..69421795800 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,137 @@ 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} value that marks every + * scheme as safe, disabling scheme 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. + * + * @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()); + } + + /** + * 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..4d460d4729f 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,21 @@ 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 + * {@link com.vaadin.flow.component.html.Anchor}, + * {@link com.vaadin.flow.component.html.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}. The single value {@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..a4cd7f9245a 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,43 @@ public PendingJavaScriptResult executeJs(String expression, "Event dispatch should come before window.open in the script"); } + @Test + void open_disallowedScheme_throws() { + Page page = new Page(new MockUI()) { + @Override + public PendingJavaScriptResult executeJs(String expression, + Object... parameters) { + return fail("Disallowed 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 openUnsafe_disallowedScheme_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 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..0fb70ac0480 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,6 +17,8 @@ import jakarta.servlet.http.HttpServletRequest; +import java.util.Set; + import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -204,4 +206,78 @@ void appendQueryParameter_nullValue_returnsOriginalUrl() { null); assertEquals("/styles.css", result); } + + @Test + void isSafeUrl_allowedScheme_returnsTrue() { + assertTrue(UrlUtil.isSafeUrl("https://vaadin.com", + UrlUtil.DEFAULT_SAFE_SCHEMES)); + } + + @Test + void isSafeUrl_disallowedScheme_returnsFalse() { + assertFalse(UrlUtil.isSafeUrl("javascript:alert(1)", + UrlUtil.DEFAULT_SAFE_SCHEMES)); + assertFalse(UrlUtil.isSafeUrl("data:text/html,