|
| 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 | +} |
0 commit comments