+ * 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
+ * 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
+ * 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
+ * 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