View Javadoc
1   /*
2    * Copyright (c) 2002-2026 Gargoyle Software Inc.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    * https://www.apache.org/licenses/LICENSE-2.0
8    *
9    * Unless required by applicable law or agreed to in writing, software
10   * distributed under the License is distributed on an "AS IS" BASIS,
11   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12   * See the License for the specific language governing permissions and
13   * limitations under the License.
14   */
15  package org.htmlunit.html;
16  
17  import static org.htmlunit.BrowserVersionFeatures.ANCHOR_SEND_PING_REQUEST;
18  import static org.htmlunit.BrowserVersionFeatures.FORM_SUBMISSION_HEADER_CACHE_CONTROL_MAX_AGE;
19  import static org.htmlunit.html.DomElement.ATTRIBUTE_NOT_DEFINED;
20  
21  import java.io.IOException;
22  import java.net.MalformedURLException;
23  import java.net.URL;
24  import java.util.Locale;
25  
26  import org.apache.commons.logging.Log;
27  import org.apache.commons.logging.LogFactory;
28  import org.htmlunit.BrowserVersion;
29  import org.htmlunit.FormEncodingType;
30  import org.htmlunit.HttpHeader;
31  import org.htmlunit.HttpMethod;
32  import org.htmlunit.Page;
33  import org.htmlunit.WebClient;
34  import org.htmlunit.WebRequest;
35  import org.htmlunit.WebWindow;
36  import org.htmlunit.protocol.javascript.JavaScriptURLConnection;
37  import org.htmlunit.util.ArrayUtils;
38  import org.htmlunit.util.StringUtils;
39  import org.htmlunit.util.UrlUtils;
40  
41  /**
42   * <span style="color:red">INTERNAL API - SUBJECT TO CHANGE AT ANY TIME - USE AT YOUR OWN RISK.</span><br>
43   *
44   * A helper class to be used by elements which support {@link HyperlinkElement} -
45   * currently {@link HtmlAnchor} ({@code a}) and {@link HtmlArea} ({@code area}), the two
46   * elements the HTML spec defines as "hyperlink elements".
47   *
48   * @author Ronald Brill
49   */
50  public final class HyperlinkElementSupport {
51  
52      private static final Log LOG = LogFactory.getLog(HyperlinkElementSupport.class);
53  
54      private HyperlinkElementSupport() {
55          // util class
56      }
57  
58      /**
59       * Support method that is called from {@link HtmlAnchor#doClickStateUpdate(boolean, boolean, String)}
60       * and {@link HtmlArea#doClickStateUpdate(boolean, boolean)}: resolves the target URL, sends the
61       * hyperlink-auditing ("ping") request if applicable, builds the navigation {@link WebRequest}
62       * (including Sec-Fetch-* setup), and hands it off for download/navigation.
63       *
64       * @param element the hyperlink element being clicked
65       * @param shiftKey {@code true} if SHIFT is pressed
66       * @param ctrlKey {@code true} if CTRL is pressed
67       * @param hrefSuffix the suffix to add to the element's {@code href} attribute
68       *        (for instance coordinates from an image map)
69       * @throws IOException if an IO error occurs
70       */
71      public static void doClickStateUpdate(final HyperlinkElement element, final boolean shiftKey,
72              final boolean ctrlKey, final String hrefSuffix) throws IOException {
73          final HtmlElement htmlElement = (HtmlElement) element;
74  
75          final String href = (element.getHrefAttribute() + hrefSuffix).trim();
76          if (LOG.isDebugEnabled()) {
77              final String w = htmlElement.getPage().getEnclosingWindow().getName();
78              LOG.debug("do click action in window '" + w + "', using href '" + href + "'");
79          }
80          if (ATTRIBUTE_NOT_DEFINED == element.getHrefAttribute()) {
81              return;
82          }
83          final String downloadAttribute = element.getDownloadAttribute();
84          HtmlPage page = (HtmlPage) htmlElement.getPage();
85          if (StringUtils.startsWithIgnoreCase(href, JavaScriptURLConnection.JAVASCRIPT_PREFIX)) {
86              final StringBuilder builder = new StringBuilder(href.length());
87              builder.append(JavaScriptURLConnection.JAVASCRIPT_PREFIX);
88              for (int i = JavaScriptURLConnection.JAVASCRIPT_PREFIX.length(); i < href.length(); i++) {
89                  final char ch = href.charAt(i);
90                  if (ch == '%' && i + 2 < href.length()) {
91                      final char ch1 = Character.toUpperCase(href.charAt(i + 1));
92                      final char ch2 = Character.toUpperCase(href.charAt(i + 2));
93                      if ((Character.isDigit(ch1) || ch1 >= 'A' && ch1 <= 'F')
94                              && (Character.isDigit(ch2) || ch2 >= 'A' && ch2 <= 'F')) {
95                          builder.append((char) Integer.parseInt(href.substring(i + 1, i + 3), 16));
96                          i += 2;
97                          continue;
98                      }
99                  }
100                 builder.append(ch);
101             }
102 
103             final String target;
104             if (shiftKey || ctrlKey || ATTRIBUTE_NOT_DEFINED != downloadAttribute) {
105                 target = WebClient.TARGET_BLANK;
106             }
107             else {
108                 target = page.getResolvedTarget(element.getTargetAttribute());
109             }
110             final WebWindow win = page.getWebClient().openTargetWindow(page.getEnclosingWindow(),
111                     target, WebClient.TARGET_SELF);
112             Page enclosedPage = win.getEnclosedPage();
113             if (enclosedPage == null) {
114                 win.getWebClient().getPage(win, WebRequest.newAboutBlankRequest());
115                 enclosedPage = win.getEnclosedPage();
116             }
117             if (enclosedPage != null && enclosedPage.isHtmlPage()) {
118                 page = (HtmlPage) enclosedPage;
119                 page.executeJavaScript(builder.toString(), "javascript url", htmlElement.getStartLineNumber());
120             }
121             return;
122         }
123 
124         final URL url = getTargetUrl(href, page);
125 
126         final URL pageUrl = page.getUrl();
127 
128         final WebClient webClient = page.getWebClient();
129         final BrowserVersion browser = webClient.getBrowserVersion();
130         if (ATTRIBUTE_NOT_DEFINED != element.getPingAttribute() && browser.hasFeature(ANCHOR_SEND_PING_REQUEST)) {
131             final URL pingUrl = getTargetUrl(element.getPingAttribute(), page);
132             final WebRequest pingRequest = new WebRequest(pingUrl, HttpMethod.POST);
133             pingRequest.setAdditionalHeader(HttpHeader.ACCEPT_ENCODING, browser.getAcceptEncodingHeader());
134             pingRequest.setAdditionalHeader(HttpHeader.PING_FROM, pageUrl.toExternalForm());
135             pingRequest.setAdditionalHeader(HttpHeader.PING_TO, url.toExternalForm());
136             pingRequest.setEncodingType(FormEncodingType.TEXT_PLAIN); // text/ping
137             try {
138                 pingRequest.setAdditionalHeader(HttpHeader.ORIGIN,
139                         UrlUtils.getUrlWithProtocolAndAuthority(pageUrl).toExternalForm());
140             }
141             catch (final MalformedURLException e) {
142                 if (LOG.isInfoEnabled()) {
143                     LOG.info("Invalid origin url '" + pageUrl + "'");
144                 }
145             }
146             if (browser.hasFeature(FORM_SUBMISSION_HEADER_CACHE_CONTROL_MAX_AGE)) {
147                 pingRequest.setAdditionalHeader(HttpHeader.CACHE_CONTROL, "max-age=0");
148             }
149             pingRequest.setRequestBody("PING");
150 
151             // Sec-Fetch-* support (https://www.w3.org/TR/fetch-metadata/):
152             // hyperlink-auditing (ping) requests have no destination and are always no-cors
153             pingRequest.setFetchDestination(WebRequest.FetchDestination.EMPTY);
154             pingRequest.setFetchModeOverride(WebRequest.FetchMode.NO_CORS);
155             pingRequest.setRequestingUrl(pageUrl);
156 
157             try {
158                 webClient.loadWebResponse(pingRequest);
159             }
160             catch (final Exception e) {
161                 // ignore
162             }
163         }
164 
165         final WebRequest webRequest = new WebRequest(url, browser.getHtmlAcceptHeader(),
166                                                             browser.getAcceptEncodingHeader());
167         // use the page encoding even if this is a GET requests
168         webRequest.setCharset(page.getCharset());
169 
170         // Sec-Fetch-* support (https://www.w3.org/TR/fetch-metadata/):
171         // this is a top-level navigation, initiated by the page containing the link.
172         // The initiator must be set regardless of "noreferrer", since Sec-Fetch-Site
173         // reflects the true relationship between initiator and target even when the
174         // Referer header itself is suppressed.
175         // TODO: userActivation is hardcoded to true here because doClickStateUpdate()
176         // currently has no way to tell a real (WebDriver-simulated) click apart from a
177         // script-triggered one (e.g. anchor.click()/area.click()); real browsers only
178         // send Sec-Fetch-User for the former. Once the click/event dispatch path exposes
179         // a trusted/user-gesture flag, thread it through instead of hardcoding this.
180         webRequest.markAsNavigation(page.getUrl(), true);
181 
182         if (!relContainsNoreferrer(element)) {
183             webRequest.setRefererHeader(page.getUrl());
184         }
185 
186         if (LOG.isDebugEnabled()) {
187             LOG.debug(
188                     "Getting page for " + url.toExternalForm()
189                     + ", derived from href '" + href
190                     + "', using the originating URL "
191                     + page.getUrl());
192         }
193 
194         final String target;
195         if (shiftKey || ctrlKey
196                 || (webClient.getAttachmentHandler() == null
197                         && ATTRIBUTE_NOT_DEFINED != downloadAttribute)) {
198             target = WebClient.TARGET_BLANK;
199         }
200         else {
201             target = page.getResolvedTarget(element.getTargetAttribute());
202         }
203         page.getWebClient().download(page.getEnclosingWindow(), target, webRequest,
204                 true, (ATTRIBUTE_NOT_DEFINED == downloadAttribute) ? null : downloadAttribute,
205                 htmlElement.getClass().getSimpleName() + " click");
206     }
207 
208     /**
209      * Returns {@code true} if the element's {@code rel} attribute contains {@code noreferrer}.
210      *
211      * @param element the hyperlink element
212      * @return {@code true} if the element's {@code rel} attribute contains {@code noreferrer}
213      */
214     public static boolean relContainsNoreferrer(final HyperlinkElement element) {
215         String rel = element.getRelAttribute();
216         if (rel != null) {
217             rel = rel.toLowerCase(Locale.ROOT);
218             return ArrayUtils.contains(StringUtils.splitAtBlank(rel), "noreferrer");
219         }
220         return false;
221     }
222 
223     /**
224      * Returns the calculated target url.
225      *
226      * @param href the href
227      * @param page the HtmlPage
228      * @return the calculated target url.
229      * @throws MalformedURLException if an IO error occurs
230      */
231     public static URL getTargetUrl(final String href, final HtmlPage page) throws MalformedURLException {
232         URL url = page.getFullyQualifiedUrl(href);
233         // fix for empty url
234         if (StringUtils.isEmptyOrNull(href)) {
235             url = UrlUtils.getUrlWithNewRef(url, null);
236         }
237         return url;
238     }
239 }