Skip to content

Commit 59cf995

Browse files
Artur-tltv
andauthored
feat: OpenInNewTabAction for the action trigger framework (#24431)
Introduces a new `OpenInNewTabAction` class to the internal trigger system, providing a secure and flexible way to open URLs in a new browser tab or window in response to user actions. The implementation ensures that potentially dangerous `javascript:` URLs are blocked both server-side and client-side, and offers configurable popup features. --------- Co-authored-by: Tomi Virtanen <tltv@vaadin.com>
1 parent 78d3fa1 commit 59cf995

4 files changed

Lines changed: 552 additions & 0 deletions

File tree

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.component.trigger.internal;
17+
18+
import java.util.Objects;
19+
import java.util.regex.Pattern;
20+
21+
import com.vaadin.flow.dom.JsFunction;
22+
23+
/**
24+
* Opens a URL in a new browser tab or window via {@code window.open(url,
25+
* "_blank", features)} when the bound trigger fires.
26+
* <p>
27+
* Most browsers block {@code window.open} calls that don't happen inside a
28+
* transient user activation. Bind this action to a {@link Trigger} that fires
29+
* during such a gesture — typically a {@link ClickTrigger} — so the call
30+
* inherits the gesture and the popup is allowed.
31+
* <p>
32+
* Two axes of configuration:
33+
* <ul>
34+
* <li><b>URL</b> — either a known {@link String} or an {@link Action.Input} of
35+
* {@code String} resolved on the client at fire time (e.g. the current value of
36+
* an input field, or a {@code blob:} URL the client just minted).</li>
37+
* <li><b>Features</b> — the third argument to {@code window.open}, controlling
38+
* popup characteristics such as opener access (the standard {@code "noopener"}
39+
* / {@code "noreferrer"} tokens) and window sizing
40+
* ({@code "width=600,height=400"}). When not supplied, defaults to
41+
* {@value #DEFAULT_FEATURES}, which severs {@code window.opener} on the new
42+
* page and strips the {@code Referer} header — the safe default for opening
43+
* untrusted URLs. Callers that supply their own features are responsible for
44+
* including those tokens themselves if they want the same protection.</li>
45+
* </ul>
46+
* <p>
47+
* The target is always {@code "_blank"} — opening into the current tab is not
48+
* the point of this action, and reusing a named window has different popup
49+
* blocker semantics that this primitive does not try to model. If you need to
50+
* navigate the current tab, use {@code Page.setLocation}; if you need named
51+
* windows, call {@code window.open} via {@code executeJs} directly.
52+
* <p>
53+
* {@code javascript:} URLs are rejected: static-{@link String} URLs throw
54+
* {@link IllegalArgumentException} from the constructor, and {@link Input} URLs
55+
* resolved on the client are silently dropped at fire time (the generated JS
56+
* short-circuits {@code window.open}). This prevents an attacker-controlled
57+
* value flowing through a {@link PropertyInput} from executing arbitrary script
58+
* in the opener's origin. The check mirrors the URL parser's leading-whitespace
59+
* handling (C0 controls and ASCII space are stripped before the scheme) so a
60+
* leading tab/newline can't smuggle one through.
61+
* <p>
62+
* No outcome callback: the browser does not reliably report whether the popup
63+
* was blocked (the spec lets {@code window.open} return {@code null}, but not
64+
* every blocker path does so), and never reports when the new tab is closed.
65+
*
66+
* <pre>{@code
67+
* // Open a known URL in a new tab.
68+
* new ClickTrigger(button)
69+
* .triggers(new OpenInNewTabAction("https://vaadin.com/docs"));
70+
*
71+
* // Open whatever URL the user typed into a field.
72+
* Action.Input<String> urlInput = new PropertyInput<>(urlField, "value",
73+
* String.class);
74+
* new ClickTrigger(button).triggers(new OpenInNewTabAction(urlInput));
75+
*
76+
* // Open a sized popup window with custom features. Note that supplying
77+
* // features replaces the noopener/noreferrer defaults — include them
78+
* // explicitly if you want the same protection.
79+
* new ClickTrigger(button).triggers(new OpenInNewTabAction("/help",
80+
* "noopener,noreferrer,width=600,height=400"));
81+
* }</pre>
82+
*
83+
* For internal use only. May be renamed or removed in a future release.
84+
*/
85+
public class OpenInNewTabAction extends Action {
86+
87+
/**
88+
* Default features applied when the caller does not supply their own:
89+
* severs {@code window.opener} on the new page and strips the
90+
* {@code Referer} header.
91+
*/
92+
static final String DEFAULT_FEATURES = "noopener,noreferrer";
93+
94+
/**
95+
* Matches a {@code javascript:} scheme prefix, ignoring leading C0 controls
96+
* and ASCII space (the characters the URL parser strips before reading the
97+
* scheme). Case-insensitive so {@code JavaScript:}, {@code JAVASCRIPT:}, …
98+
* are all caught.
99+
*/
100+
private static final Pattern JAVASCRIPT_SCHEME = Pattern
101+
.compile("^[\\x00-\\x20]*javascript:", Pattern.CASE_INSENSITIVE);
102+
103+
/**
104+
* Client-side guard, mirroring {@link #JAVASCRIPT_SCHEME}, that evaluates
105+
* to {@code true} when the resolved URL {@code u} carries a
106+
* {@code javascript:} scheme. {@code [\x00-\x20]*} skips the leading C0
107+
* controls and ASCII space the URL parser strips before the scheme, and
108+
* {@code /i} catches any casing. {@code String(u)} coerces non-string
109+
* inputs so {@code .test} never throws. Used to short-circuit
110+
* {@code window.open} so an attacker-controlled {@link Input} value can't
111+
* execute script in the opener's origin.
112+
*/
113+
private static final String JAVASCRIPT_SCHEME_JS_GUARD = "/^[\\x00-\\x20]*javascript:/i.test(String(u))";
114+
115+
private final Action.Input<String> urlInput;
116+
private final Action.Input<String> featuresInput;
117+
118+
/**
119+
* Opens {@code url} in a new tab with the default features
120+
* ({@value #DEFAULT_FEATURES}).
121+
*
122+
* @param url
123+
* the URL to open, not {@code null} and not a
124+
* {@code javascript:} URL
125+
* @throws IllegalArgumentException
126+
* if {@code url} starts with {@code javascript:} (after
127+
* stripping leading whitespace/control characters)
128+
*/
129+
public OpenInNewTabAction(String url) {
130+
this(urlLiteral(url), new LiteralInput<>(DEFAULT_FEATURES));
131+
}
132+
133+
/**
134+
* Opens {@code url} in a new tab with the given {@code features}. The
135+
* features string is passed verbatim as the third argument to
136+
* {@code window.open}; supplying it replaces — does not extend — the
137+
* default features, so include {@code "noopener,noreferrer"} explicitly if
138+
* you want the same protection.
139+
*
140+
* @param url
141+
* the URL to open, not {@code null} and not a
142+
* {@code javascript:} URL
143+
* @param features
144+
* the features string for {@code window.open}, not {@code null}
145+
* @throws IllegalArgumentException
146+
* if {@code url} starts with {@code javascript:} (after
147+
* stripping leading whitespace/control characters)
148+
*/
149+
public OpenInNewTabAction(String url, String features) {
150+
this(urlLiteral(url), literal(features, "features"));
151+
}
152+
153+
/**
154+
* Opens a URL resolved on the client at fire time, with the default
155+
* features ({@value #DEFAULT_FEATURES}). {@code javascript:} URLs produced
156+
* by the input are blocked on the client.
157+
*
158+
* @param url
159+
* input supplying the URL when the trigger fires, not
160+
* {@code null}
161+
*/
162+
public OpenInNewTabAction(Action.Input<String> url) {
163+
this(Objects.requireNonNull(url, "url must not be null"),
164+
new LiteralInput<>(DEFAULT_FEATURES));
165+
}
166+
167+
/**
168+
* Opens a URL resolved on the client at fire time, with features also
169+
* resolved on the client. {@code javascript:} URLs produced by the input
170+
* are blocked on the client.
171+
*
172+
* @param url
173+
* input supplying the URL when the trigger fires, not
174+
* {@code null}
175+
* @param features
176+
* input supplying the features string when the trigger fires,
177+
* not {@code null}
178+
*/
179+
public OpenInNewTabAction(Action.Input<String> url,
180+
Action.Input<String> features) {
181+
this.urlInput = Objects.requireNonNull(url, "url must not be null");
182+
this.featuresInput = Objects.requireNonNull(features,
183+
"features must not be null");
184+
}
185+
186+
@Override
187+
protected JsFunction toJs(Trigger trigger) {
188+
// Defence in depth for Input-based URLs whose value is only known on
189+
// the client. Static String URLs are rejected by the constructor, so
190+
// for those this check is redundant — but keeping it unconditional
191+
// keeps the rendered JS identical regardless of how the URL was
192+
// supplied, and protects LiteralInput<>("javascript:…") too. The
193+
// JAVASCRIPT_SCHEME_JS_GUARD short-circuits window.open when the
194+
// resolved URL is a javascript: URL; see its Javadoc for the regex.
195+
// $0 = URL input's JsFunction; $1 = features input's JsFunction —
196+
// both invoked with the firing event so handler-scoped inputs work.
197+
return JsFunction.of("((u) => " + JAVASCRIPT_SCHEME_JS_GUARD
198+
+ " || window.open(u, \"_blank\", $1(event)))" + "($0(event))",
199+
urlInput.toJs(trigger), featuresInput.toJs(trigger))
200+
.withArguments("event");
201+
}
202+
203+
private static LiteralInput<String> urlLiteral(String url) {
204+
Objects.requireNonNull(url, "url must not be null");
205+
if (JAVASCRIPT_SCHEME.matcher(url).find()) {
206+
throw new IllegalArgumentException(
207+
"javascript: URLs are not allowed");
208+
}
209+
return new LiteralInput<>(url);
210+
}
211+
212+
private static LiteralInput<String> literal(String value, String name) {
213+
return new LiteralInput<>(
214+
Objects.requireNonNull(value, name + " must not be null"));
215+
}
216+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.component.trigger.internal;
17+
18+
import org.junit.jupiter.api.Test;
19+
20+
import com.vaadin.flow.component.UI;
21+
import com.vaadin.flow.dom.JsFunction;
22+
import com.vaadin.tests.util.MockUI;
23+
24+
import static com.vaadin.flow.component.trigger.internal.TriggerTestUtil.actionOf;
25+
import static com.vaadin.flow.component.trigger.internal.TriggerTestUtil.singleInstallFn;
26+
import static org.junit.jupiter.api.Assertions.assertEquals;
27+
import static org.junit.jupiter.api.Assertions.assertThrows;
28+
29+
class OpenInNewTabActionTest {
30+
31+
private static final String EXPECTED_BODY = "((u) =>"
32+
+ " /^[\\x00-\\x20]*javascript:/i.test(String(u))"
33+
+ " || window.open(u, \"_blank\", $1(event)))($0(event))";
34+
35+
@Test
36+
void urlString_emitsWindowOpenWithBlankTargetAndDefaultFeatures() {
37+
UI ui = new MockUI();
38+
TagComponent button = new TagComponent("button");
39+
ui.getElement().appendChild(button.getElement());
40+
41+
new DomEventTrigger(button, "click")
42+
.triggers(new OpenInNewTabAction("https://vaadin.com/docs"));
43+
44+
ui.getInternals().getStateTree().runExecutionsBeforeClientResponse();
45+
46+
// The action body wraps window.open in an IIFE that short-circuits
47+
// when the resolved URL starts with javascript:. URL and features
48+
// each live on their own input JsFunction captured by the action.
49+
JsFunction action = actionOf(singleInstallFn(ui));
50+
assertEquals(EXPECTED_BODY, action.getBody());
51+
52+
JsFunction url = (JsFunction) action.getCaptures().get(0);
53+
assertEquals("https://vaadin.com/docs", url.getCaptures().get(0));
54+
55+
JsFunction features = (JsFunction) action.getCaptures().get(1);
56+
assertEquals("noopener,noreferrer", features.getCaptures().get(0));
57+
}
58+
59+
@Test
60+
void urlStringWithFeatures_passesCustomFeaturesVerbatim() {
61+
UI ui = new MockUI();
62+
TagComponent button = new TagComponent("button");
63+
ui.getElement().appendChild(button.getElement());
64+
65+
new DomEventTrigger(button, "click").triggers(new OpenInNewTabAction(
66+
"/help", "noopener,width=600,height=400"));
67+
68+
ui.getInternals().getStateTree().runExecutionsBeforeClientResponse();
69+
70+
// Supplying features replaces — does not extend — the defaults.
71+
JsFunction action = actionOf(singleInstallFn(ui));
72+
assertEquals(EXPECTED_BODY, action.getBody());
73+
74+
JsFunction features = (JsFunction) action.getCaptures().get(1);
75+
assertEquals("noopener,width=600,height=400",
76+
features.getCaptures().get(0));
77+
}
78+
79+
@Test
80+
void inputUrl_splicesPropertyInputThatReadsAtFireTime() {
81+
UI ui = new MockUI();
82+
TagComponent button = new TagComponent("button");
83+
TagComponent field = new TagComponent("input");
84+
ui.getElement().appendChild(button.getElement(), field.getElement());
85+
86+
new DomEventTrigger(button, "click").triggers(new OpenInNewTabAction(
87+
new PropertyInput<>(field, "value", String.class)));
88+
89+
ui.getInternals().getStateTree().runExecutionsBeforeClientResponse();
90+
91+
// The URL slot now holds a PropertyInput JsFunction that reads
92+
// field["value"] on the client at fire time.
93+
JsFunction action = actionOf(singleInstallFn(ui));
94+
assertEquals(EXPECTED_BODY, action.getBody());
95+
96+
JsFunction url = (JsFunction) action.getCaptures().get(0);
97+
assertEquals("return $0[$1]", url.getBody());
98+
assertEquals(field.getElement(), url.getCaptures().get(0));
99+
assertEquals("value", url.getCaptures().get(1));
100+
}
101+
102+
@Test
103+
void javascriptUrl_rejectedByConstructor() {
104+
assertThrows(IllegalArgumentException.class,
105+
() -> new OpenInNewTabAction("javascript:alert(1)"));
106+
}
107+
108+
@Test
109+
void javascriptUrlCaseAndWhitespaceVariants_allRejected() {
110+
// Mixed case: scheme is case-insensitive per the URL spec.
111+
assertThrows(IllegalArgumentException.class,
112+
() -> new OpenInNewTabAction("JavaScript:alert(1)"));
113+
assertThrows(IllegalArgumentException.class,
114+
() -> new OpenInNewTabAction("JAVASCRIPT:alert(1)"));
115+
// Leading whitespace and C0 controls are stripped by the URL parser
116+
// before scheme matching, so they don't let the URL through.
117+
assertThrows(IllegalArgumentException.class,
118+
() -> new OpenInNewTabAction(" javascript:alert(1)"));
119+
assertThrows(IllegalArgumentException.class,
120+
() -> new OpenInNewTabAction("\tjavascript:alert(1)"));
121+
assertThrows(IllegalArgumentException.class,
122+
() -> new OpenInNewTabAction("\njavascript:alert(1)"));
123+
// Two-arg constructor also rejects.
124+
assertThrows(IllegalArgumentException.class,
125+
() -> new OpenInNewTabAction("javascript:alert(1)",
126+
"noopener,noreferrer"));
127+
}
128+
}

0 commit comments

Comments
 (0)