Skip to content

Commit 691149c

Browse files
Artur-platosha
andauthored
feat: validate navigation-related URL schemes (#9447)
## Motivation Mirror the Flow URL-scheme validation ([vaadin/flow#24539](vaadin/flow#24539)) for the navigation, action and link sinks in flow-components. A `javascript:` (or other non-allow-listed) scheme on a value that the browser later treats as a link, form action or script source is a familiar XSS shape; rejecting it at the setter closes that path while leaving an explicit escape hatch for trusted, hard-coded URLs. ## Changes Validating setters added across the affected components, each with a matching `setUnsafe*` variant that bypasses validation: - `SideNavItem.setPath` / `BreadcrumbsItem.setPath` — navigation link. - `Credits.setHref` — clickable Highcharts credits link. - `AbstractLogin.setAction` — the form `action` attribute the browser POSTs to on submit. - `Exporting.setUrl` — Highcharts posts SVG to this URL as a form action for export fallback. - `Exporting.setLibURL` — concatenated into `<script src="…">` to load offline-export dependencies at runtime. `SideNavItem` and `BreadcrumbsItem` constructors that take a `String path` validate transitively via `setPath`; their Javadoc now documents `@throws IllegalArgumentException` and explicit constructor tests cover the behaviour. Safe schemes are configured through the `com.vaadin.safeUrlSchemes` property and default to `http`, `https`, `mailto`, `tel`, `ftp`. Image, icon, SVG, Avatar, map tile and chart-resource URLs are intentionally **not** validated: `javascript:` cannot execute in `<img src>` / SVG `<image>` elements, and `data:` URLs are a legitimate, common use there. `LoginFormTest` and `LoginOverlayTest` already mock `LoggerFactory` to capture the existing "action attribute together with login listeners" warning. After merging Flow main, those tests started NPE-ing: Flow's `UrlUtil.isSafeUrl` debug-logs a fallback notice, going through `LoggerFactory.getLogger(UrlUtil.class)` which returned `null` inside the broad mock. The mock now uses `Mockito.CALLS_REAL_METHODS` so unrelated lookups fall through to the real factory; the explicit `LoginForm/Overlay.class` stubs still take precedence. Part of vaadin/flow#24539 --------- Co-authored-by: Anton Platonov <platosha@gmail.com>
1 parent 1cedd7c commit 691149c

11 files changed

Lines changed: 380 additions & 4 deletions

File tree

vaadin-breadcrumbs-flow-parent/vaadin-breadcrumbs-flow/src/main/java/com/vaadin/flow/component/breadcrumbs/BreadcrumbsItem.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
2323
import com.vaadin.flow.component.dependency.JsModule;
2424
import com.vaadin.flow.component.dependency.NpmPackage;
2525
import com.vaadin.flow.component.shared.HasPrefix;
26+
import com.vaadin.flow.internal.UrlUtil;
2627
import com.vaadin.flow.router.RouteConfiguration;
2728
import com.vaadin.flow.router.RouteParameters;
29+
import com.vaadin.flow.server.InitParameters;
2830

2931
/**
3032
* An item of the {@link Breadcrumbs} component, representing a single entry in
@@ -64,6 +66,11 @@ public BreadcrumbsItem(String text) {
6466
* the text of the item
6567
* @param path
6668
* the path to link to
69+
* @throws IllegalArgumentException
70+
* if {@code path} uses a scheme that is not considered safe;
71+
* see {@link #setUnsafePath(String)} and the
72+
* {@value InitParameters#URL_SAFE_SCHEMES} configuration
73+
* property
6774
*/
6875
public BreadcrumbsItem(String text, String path) {
6976
setPath(path);
@@ -111,6 +118,11 @@ public BreadcrumbsItem(String text, Class<? extends Component> view,
111118
* the path to link to
112119
* @param prefixComponent
113120
* the prefix component for the item (usually an icon)
121+
* @throws IllegalArgumentException
122+
* if {@code path} uses a scheme that is not considered safe;
123+
* see {@link #setUnsafePath(String)} and the
124+
* {@value InitParameters#URL_SAFE_SCHEMES} configuration
125+
* property
114126
*/
115127
public BreadcrumbsItem(String text, String path,
116128
Component prefixComponent) {
@@ -177,6 +189,33 @@ public String getPath() {
177189
* @see #setPath(Class)
178190
*/
179191
public void setPath(String path) {
192+
if (path != null && !UrlUtil.isSafeUrl(path)) {
193+
throw new IllegalArgumentException(UrlUtil.getUnsafeUrlMessage(
194+
"path", path, "setUnsafePath(String)"));
195+
}
196+
doSetPath(path);
197+
}
198+
199+
/**
200+
* Sets the path this item links to without validating its scheme.
201+
* <p>
202+
* Unlike {@link #setPath(String)}, this method does not reject paths based
203+
* on the {@value InitParameters#URL_SAFE_SCHEMES} configuration. Use it
204+
* only for paths that are fully under your control and known to be safe,
205+
* such as a hard-coded {@code javascript:} URL. Passing untrusted input
206+
* here can expose the application to cross-site scripting (XSS) attacks.
207+
*
208+
* @see #setPath(String)
209+
*
210+
* @param path
211+
* the path to link to, or {@code null} to remove the path and
212+
* render the item as the current page (a non-link)
213+
*/
214+
public void setUnsafePath(String path) {
215+
doSetPath(path);
216+
}
217+
218+
private void doSetPath(String path) {
180219
if (path == null) {
181220
getElement().removeAttribute("path");
182221
} else {

vaadin-breadcrumbs-flow-parent/vaadin-breadcrumbs-flow/src/test/java/com/vaadin/flow/component/breadcrumbs/tests/BreadcrumbsItemTest.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,35 @@ void setStringPath_pathAttributeSet() {
9393
item.getElement().getAttribute("path"));
9494
}
9595

96+
@Test
97+
void setPathWithUnsafeScheme_throws() {
98+
var item = new BreadcrumbsItem("Docs");
99+
100+
Assertions.assertThrows(IllegalArgumentException.class,
101+
() -> item.setPath("javascript:alert(1)"));
102+
}
103+
104+
@Test
105+
void setUnsafePathWithUnsafeScheme_pathSet() {
106+
var item = new BreadcrumbsItem("Docs");
107+
item.setUnsafePath("javascript:alert(1)");
108+
109+
Assertions.assertEquals("javascript:alert(1)", item.getPath());
110+
}
111+
112+
@Test
113+
void constructor_textPath_unsafeScheme_throws() {
114+
Assertions.assertThrows(IllegalArgumentException.class,
115+
() -> new BreadcrumbsItem("Docs", "javascript:alert(1)"));
116+
}
117+
118+
@Test
119+
void constructor_textPathPrefixComponent_unsafeScheme_throws() {
120+
Assertions.assertThrows(IllegalArgumentException.class,
121+
() -> new BreadcrumbsItem("Docs", "javascript:alert(1)",
122+
new Div()));
123+
}
124+
96125
@Test
97126
void setNullStringPath_pathAttributeRemoved() {
98127
var item = new BreadcrumbsItem("Docs", "/docs");

vaadin-charts-flow-parent/vaadin-charts-flow/src/main/java/com/vaadin/flow/component/charts/model/Credits.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
package com.vaadin.flow.component.charts.model;
1010

1111
import com.vaadin.flow.component.charts.model.style.Style;
12+
import com.vaadin.flow.internal.UrlUtil;
13+
import com.vaadin.flow.server.InitParameters;
1214

1315
/**
1416
* Highchart by default puts a credits label in the lower right corner of the
@@ -58,6 +60,28 @@ public String getHref() {
5860
* Defaults to: http://www.highcharts.com
5961
*/
6062
public void setHref(String href) {
63+
if (href != null && !UrlUtil.isSafeUrl(href)) {
64+
throw new IllegalArgumentException(UrlUtil.getUnsafeUrlMessage(
65+
"href", href, "setUnsafeHref(String)"));
66+
}
67+
this.href = href;
68+
}
69+
70+
/**
71+
* Sets the URL for the credits label without validating its scheme.
72+
* <p>
73+
* Unlike {@link #setHref(String)}, this method does not reject URLs based
74+
* on the {@value InitParameters#URL_SAFE_SCHEMES} configuration. Use it
75+
* only for URLs that are fully under your control and known to be safe.
76+
* Passing untrusted input here can expose the application to cross-site
77+
* scripting (XSS) attacks.
78+
*
79+
* @see #setHref(String)
80+
*
81+
* @param href
82+
* the URL for the credits label
83+
*/
84+
public void setUnsafeHref(String href) {
6185
this.href = href;
6286
}
6387

vaadin-charts-flow-parent/vaadin-charts-flow/src/main/java/com/vaadin/flow/component/charts/model/Exporting.java

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010

1111
import java.util.Map;
1212

13+
import com.vaadin.flow.internal.UrlUtil;
14+
import com.vaadin.flow.server.InitParameters;
15+
1316
/**
1417
* Options for the exporting module. For an overview on the matter, see
1518
* <a href="http://www.highcharts.com/docs/export-module/export-module-overview"
@@ -166,8 +169,39 @@ public String getLibURL() {
166169
* for client side export in certain browsers.
167170
* <p>
168171
* Defaults to: https://code.highcharts.com/{version}/lib
172+
*
173+
* @throws IllegalArgumentException
174+
* if {@code libURL} uses a scheme that is not considered safe;
175+
* see {@link #setUnsafeLibURL(String)} and the
176+
* {@value InitParameters#URL_SAFE_SCHEMES} configuration
177+
* property
178+
* @see #setUnsafeLibURL(String)
169179
*/
170180
public void setLibURL(String libURL) {
181+
if (libURL != null && !UrlUtil.isSafeUrl(libURL)) {
182+
throw new IllegalArgumentException(UrlUtil.getUnsafeUrlMessage(
183+
"libURL", libURL, "setUnsafeLibURL(String)"));
184+
}
185+
this.libURL = libURL;
186+
}
187+
188+
/**
189+
* Sets the path for the export module dependencies without validating its
190+
* scheme.
191+
* <p>
192+
* Unlike {@link #setLibURL(String)}, this method does not reject URLs based
193+
* on the {@value InitParameters#URL_SAFE_SCHEMES} configuration. Use it
194+
* only for URLs that are fully under your control and known to be safe.
195+
* Passing untrusted input here can expose the application to cross-site
196+
* scripting (XSS) attacks.
197+
*
198+
* @see #setLibURL(String)
199+
*
200+
* @param libURL
201+
* the path where Highcharts will look for export module
202+
* dependencies
203+
*/
204+
public void setUnsafeLibURL(String libURL) {
171205
this.libURL = libURL;
172206
}
173207

@@ -301,8 +335,38 @@ public String getUrl() {
301335
* format. By default this points to Highchart's free web service.
302336
* <p>
303337
* Defaults to: https://export.highcharts.com
338+
*
339+
* @throws IllegalArgumentException
340+
* if {@code url} uses a scheme that is not considered safe; see
341+
* {@link #setUnsafeUrl(String)} and the
342+
* {@value InitParameters#URL_SAFE_SCHEMES} configuration
343+
* property
344+
* @see #setUnsafeUrl(String)
304345
*/
305346
public void setUrl(String url) {
347+
if (url != null && !UrlUtil.isSafeUrl(url)) {
348+
throw new IllegalArgumentException(UrlUtil
349+
.getUnsafeUrlMessage("url", url, "setUnsafeUrl(String)"));
350+
}
351+
this.url = url;
352+
}
353+
354+
/**
355+
* Sets the URL for the export server without validating its scheme.
356+
* <p>
357+
* Unlike {@link #setUrl(String)}, this method does not reject URLs based on
358+
* the {@value InitParameters#URL_SAFE_SCHEMES} configuration. Use it only
359+
* for URLs that are fully under your control and known to be safe. Passing
360+
* untrusted input here can expose the application to cross-site scripting
361+
* (XSS) attacks.
362+
*
363+
* @see #setUrl(String)
364+
*
365+
* @param url
366+
* the URL for the server module converting the SVG string to an
367+
* image format
368+
*/
369+
public void setUnsafeUrl(String url) {
306370
this.url = url;
307371
}
308372

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* This program is available under Vaadin Commercial License and Service Terms.
5+
*
6+
* See {@literal <https://vaadin.com/commercial-license-and-service-terms>} for the full
7+
* license.
8+
*/
9+
package com.vaadin.flow.component.charts.model;
10+
11+
import org.junit.jupiter.api.Assertions;
12+
import org.junit.jupiter.api.Test;
13+
14+
public class CreditsTest {
15+
16+
@Test
17+
void setHref_safeScheme_hrefSet() {
18+
Credits credits = new Credits();
19+
credits.setHref("https://vaadin.com");
20+
21+
Assertions.assertEquals("https://vaadin.com", credits.getHref());
22+
}
23+
24+
@Test
25+
void setHref_unsafeScheme_throws() {
26+
Credits credits = new Credits();
27+
28+
Assertions.assertThrows(IllegalArgumentException.class,
29+
() -> credits.setHref("javascript:alert(1)"));
30+
}
31+
32+
@Test
33+
void setUnsafeHref_unsafeScheme_hrefSet() {
34+
Credits credits = new Credits();
35+
credits.setUnsafeHref("javascript:alert(1)");
36+
37+
Assertions.assertEquals("javascript:alert(1)", credits.getHref());
38+
}
39+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* This program is available under Vaadin Commercial License and Service Terms.
5+
*
6+
* See {@literal <https://vaadin.com/commercial-license-and-service-terms>} for the full
7+
* license.
8+
*/
9+
package com.vaadin.flow.component.charts.model;
10+
11+
import org.junit.jupiter.api.Assertions;
12+
import org.junit.jupiter.api.Test;
13+
14+
public class ExportingTest {
15+
16+
@Test
17+
void setUrl_safeScheme_urlSet() {
18+
Exporting exporting = new Exporting();
19+
exporting.setUrl("https://export.highcharts.com");
20+
21+
Assertions.assertEquals("https://export.highcharts.com",
22+
exporting.getUrl());
23+
}
24+
25+
@Test
26+
void setUrl_unsafeScheme_throws() {
27+
Exporting exporting = new Exporting();
28+
29+
Assertions.assertThrows(IllegalArgumentException.class,
30+
() -> exporting.setUrl("javascript:alert(1)"));
31+
}
32+
33+
@Test
34+
void setUnsafeUrl_unsafeScheme_urlSet() {
35+
Exporting exporting = new Exporting();
36+
exporting.setUnsafeUrl("javascript:alert(1)");
37+
38+
Assertions.assertEquals("javascript:alert(1)", exporting.getUrl());
39+
}
40+
41+
@Test
42+
void setLibURL_safeScheme_libURLSet() {
43+
Exporting exporting = new Exporting();
44+
exporting.setLibURL("https://code.highcharts.com/lib");
45+
46+
Assertions.assertEquals("https://code.highcharts.com/lib",
47+
exporting.getLibURL());
48+
}
49+
50+
@Test
51+
void setLibURL_unsafeScheme_throws() {
52+
Exporting exporting = new Exporting();
53+
54+
Assertions.assertThrows(IllegalArgumentException.class,
55+
() -> exporting.setLibURL("javascript:alert(1)"));
56+
}
57+
58+
@Test
59+
void setUnsafeLibURL_unsafeScheme_libURLSet() {
60+
Exporting exporting = new Exporting();
61+
exporting.setUnsafeLibURL("javascript:alert(1)");
62+
63+
Assertions.assertEquals("javascript:alert(1)", exporting.getLibURL());
64+
}
65+
}

vaadin-login-flow-parent/vaadin-login-flow/src/main/java/com/vaadin/flow/component/login/AbstractLogin.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
import com.vaadin.flow.dom.PropertyChangeListener;
3131
import com.vaadin.flow.dom.SignalBinding;
3232
import com.vaadin.flow.internal.JacksonUtils;
33+
import com.vaadin.flow.internal.UrlUtil;
34+
import com.vaadin.flow.server.InitParameters;
3335
import com.vaadin.flow.shared.Registration;
3436
import com.vaadin.flow.signals.Signal;
3537

@@ -111,10 +113,44 @@ private void registerDefaultLoginListener() {
111113
* listeners added with {@link #addLoginListener(ComponentEventListener)}.
112114
* See class Javadoc for more information.
113115
*
116+
* @throws IllegalArgumentException
117+
* if {@code action} uses a scheme that is not considered safe;
118+
* see {@link #setUnsafeAction(String)} and the
119+
* {@value InitParameters#URL_SAFE_SCHEMES} configuration
120+
* property
121+
*
114122
* @see #getAction()
123+
* @see #setUnsafeAction(String)
115124
* @see #addLoginListener(ComponentEventListener)
116125
*/
117126
public void setAction(String action) {
127+
if (action != null && !UrlUtil.isSafeUrl(action)) {
128+
throw new IllegalArgumentException(UrlUtil.getUnsafeUrlMessage(
129+
"action", action, "setUnsafeAction(String)"));
130+
}
131+
doSetAction(action);
132+
}
133+
134+
/**
135+
* Sets the action URL without validating its scheme.
136+
* <p>
137+
* Unlike {@link #setAction(String)}, this method does not reject URLs based
138+
* on the {@value InitParameters#URL_SAFE_SCHEMES} configuration. Use it
139+
* only for URLs that are fully under your control and known to be safe,
140+
* such as a hard-coded {@code javascript:} URL. Passing untrusted input
141+
* here can expose the application to cross-site scripting (XSS) attacks.
142+
*
143+
* @see #setAction(String)
144+
*
145+
* @param action
146+
* the action URL, or {@code null} to remove the action and
147+
* restore the default login listener handling
148+
*/
149+
public void setUnsafeAction(String action) {
150+
doSetAction(action);
151+
}
152+
153+
private void doSetAction(String action) {
118154
if (action == null) {
119155
getElement().removeProperty(PROP_ACTION);
120156
if (registration == null) {

0 commit comments

Comments
 (0)