001package ca.uhn.fhir.util; 002 003import ca.uhn.fhir.context.FhirContext; 004import ca.uhn.fhir.context.RuntimeResourceDefinition; 005import ca.uhn.fhir.i18n.Msg; 006import ca.uhn.fhir.model.primitive.IdDt; 007import ca.uhn.fhir.parser.DataFormatException; 008import ca.uhn.fhir.rest.api.Constants; 009import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; 010import com.google.common.escape.Escaper; 011import com.google.common.net.PercentEscaper; 012import org.apache.commons.lang3.StringUtils; 013import org.apache.http.NameValuePair; 014import org.apache.http.client.utils.URLEncodedUtils; 015import org.apache.http.message.BasicNameValuePair; 016import org.hl7.fhir.instance.model.api.IPrimitiveType; 017 018import javax.annotation.Nonnull; 019import java.io.UnsupportedEncodingException; 020import java.net.MalformedURLException; 021import java.net.URI; 022import java.net.URISyntaxException; 023import java.net.URL; 024import java.net.URLDecoder; 025import java.util.ArrayList; 026import java.util.Collection; 027import java.util.HashMap; 028import java.util.List; 029import java.util.Map; 030import java.util.Map.Entry; 031import java.util.StringTokenizer; 032import java.util.stream.Collectors; 033 034import static org.apache.commons.lang3.StringUtils.defaultIfBlank; 035import static org.apache.commons.lang3.StringUtils.defaultString; 036import static org.apache.commons.lang3.StringUtils.endsWith; 037import static org.apache.commons.lang3.StringUtils.isBlank; 038 039/* 040 * #%L 041 * HAPI FHIR - Core Library 042 * %% 043 * Copyright (C) 2014 - 2023 Smile CDR, Inc. 044 * %% 045 * Licensed under the Apache License, Version 2.0 (the "License"); 046 * you may not use this file except in compliance with the License. 047 * You may obtain a copy of the License at 048 * 049 * http://www.apache.org/licenses/LICENSE-2.0 050 * 051 * Unless required by applicable law or agreed to in writing, software 052 * distributed under the License is distributed on an "AS IS" BASIS, 053 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 054 * See the License for the specific language governing permissions and 055 * limitations under the License. 056 * #L% 057 */ 058 059public class UrlUtil { 060 private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(UrlUtil.class); 061 062 private static final String URL_FORM_PARAMETER_OTHER_SAFE_CHARS = "-_.*"; 063 private static final Escaper PARAMETER_ESCAPER = new PercentEscaper(URL_FORM_PARAMETER_OTHER_SAFE_CHARS, false); 064 065 /** 066 * Non instantiable 067 */ 068 private UrlUtil() { 069 } 070 071 /** 072 * Cleans up a value that will be serialized as an HTTP header. This method: 073 * <p> 074 * - Strips any newline (\r or \n) characters 075 * 076 * @since 6.2.0 077 */ 078 public static String sanitizeHeaderValue(String theHeader) { 079 return theHeader.replace("\n", "").replace("\r", ""); 080 } 081 082 public static String sanitizeBaseUrl(String theBaseUrl) { 083 return theBaseUrl.replaceAll("[^a-zA-Z0-9:/._-]", ""); 084 } 085 086 /** 087 * Resolve a relative URL - THIS METHOD WILL NOT FAIL but will log a warning and return theEndpoint if the input is invalid. 088 */ 089 public static String constructAbsoluteUrl(String theBase, String theEndpoint) { 090 if (theEndpoint == null) { 091 return null; 092 } 093 if (isAbsolute(theEndpoint)) { 094 return theEndpoint; 095 } 096 if (theBase == null) { 097 return theEndpoint; 098 } 099 100 try { 101 return new URL(new URL(theBase), theEndpoint).toString(); 102 } catch (MalformedURLException e) { 103 ourLog.warn("Failed to resolve relative URL[" + theEndpoint + "] against absolute base[" + theBase + "]", e); 104 return theEndpoint; 105 } 106 } 107 108 public static String constructRelativeUrl(String theParentExtensionUrl, String theExtensionUrl) { 109 if (theParentExtensionUrl == null) { 110 return theExtensionUrl; 111 } 112 if (theExtensionUrl == null) { 113 return null; 114 } 115 116 int parentLastSlashIdx = theParentExtensionUrl.lastIndexOf('/'); 117 int childLastSlashIdx = theExtensionUrl.lastIndexOf('/'); 118 119 if (parentLastSlashIdx == -1 || childLastSlashIdx == -1) { 120 return theExtensionUrl; 121 } 122 123 if (parentLastSlashIdx != childLastSlashIdx) { 124 return theExtensionUrl; 125 } 126 127 if (!theParentExtensionUrl.substring(0, parentLastSlashIdx).equals(theExtensionUrl.substring(0, parentLastSlashIdx))) { 128 return theExtensionUrl; 129 } 130 131 if (theExtensionUrl.length() > parentLastSlashIdx) { 132 return theExtensionUrl.substring(parentLastSlashIdx + 1); 133 } 134 135 return theExtensionUrl; 136 } 137 138 /** 139 * URL encode a value according to RFC 3986 140 * <p> 141 * This method is intended to be applied to an individual parameter 142 * name or value. For example, if you are creating the URL 143 * <code>http://example.com/fhir/Patient?key=føø</code> 144 * it would be appropriate to pass the string "føø" to this method, 145 * but not appropriate to pass the entire URL since characters 146 * such as "/" and "?" would also be escaped. 147 * </P> 148 */ 149 public static String escapeUrlParam(String theUnescaped) { 150 if (theUnescaped == null) { 151 return null; 152 } 153 return PARAMETER_ESCAPER.escape(theUnescaped); 154 } 155 156 /** 157 * Applies the same encodong as {@link #escapeUrlParam(String)} but against all 158 * values in a collection 159 */ 160 public static List<String> escapeUrlParams(@Nonnull Collection<String> theUnescaped) { 161 return theUnescaped 162 .stream() 163 .map(t -> PARAMETER_ESCAPER.escape(t)) 164 .collect(Collectors.toList()); 165 } 166 167 public static boolean isAbsolute(String theValue) { 168 String value = theValue.toLowerCase(); 169 return value.startsWith("http://") || value.startsWith("https://"); 170 } 171 172 public static boolean isNeedsSanitization(CharSequence theString) { 173 if (theString != null) { 174 for (int i = 0; i < theString.length(); i++) { 175 char nextChar = theString.charAt(i); 176 switch (nextChar) { 177 case '\'': 178 case '"': 179 case '<': 180 case '>': 181 case '\n': 182 case '\r': 183 return true; 184 } 185 if (nextChar < ' ') { 186 return true; 187 } 188 } 189 } 190 return false; 191 } 192 193 public static boolean isValid(String theUrl) { 194 if (theUrl == null || theUrl.length() < 8) { 195 return false; 196 } 197 198 String url = theUrl.toLowerCase(); 199 if (url.charAt(0) != 'h') { 200 return false; 201 } 202 if (url.charAt(1) != 't') { 203 return false; 204 } 205 if (url.charAt(2) != 't') { 206 return false; 207 } 208 if (url.charAt(3) != 'p') { 209 return false; 210 } 211 int slashOffset; 212 if (url.charAt(4) == ':') { 213 slashOffset = 5; 214 } else if (url.charAt(4) == 's') { 215 if (url.charAt(5) != ':') { 216 return false; 217 } 218 slashOffset = 6; 219 } else { 220 return false; 221 } 222 223 if (url.charAt(slashOffset) != '/') { 224 return false; 225 } 226 if (url.charAt(slashOffset + 1) != '/') { 227 return false; 228 } 229 230 return true; 231 } 232 233 public static RuntimeResourceDefinition parseUrlResourceType(FhirContext theCtx, String theUrl) throws DataFormatException { 234 String url = theUrl; 235 int paramIndex = url.indexOf('?'); 236 237 // Change pattern of "Observation/?param=foo" into "Observation?param=foo" 238 if (paramIndex > 0 && url.charAt(paramIndex - 1) == '/') { 239 url = url.substring(0, paramIndex - 1) + url.substring(paramIndex); 240 paramIndex--; 241 } 242 243 String resourceName = url.substring(0, paramIndex); 244 if (resourceName.contains("/")) { 245 resourceName = resourceName.substring(resourceName.lastIndexOf('/') + 1); 246 } 247 return theCtx.getResourceDefinition(resourceName); 248 } 249 250 public static Map<String, String[]> parseQueryString(String theQueryString) { 251 HashMap<String, List<String>> map = new HashMap<>(); 252 parseQueryString(theQueryString, map); 253 return toQueryStringMap(map); 254 } 255 256 private static void parseQueryString(String theQueryString, HashMap<String, List<String>> map) { 257 String query = defaultString(theQueryString); 258 if (query.startsWith("?")) { 259 query = query.substring(1); 260 } 261 262 263 StringTokenizer tok = new StringTokenizer(query, "&"); 264 while (tok.hasMoreTokens()) { 265 String nextToken = tok.nextToken(); 266 if (isBlank(nextToken)) { 267 continue; 268 } 269 270 int equalsIndex = nextToken.indexOf('='); 271 String nextValue; 272 String nextKey; 273 if (equalsIndex == -1) { 274 nextKey = nextToken; 275 nextValue = ""; 276 } else { 277 nextKey = nextToken.substring(0, equalsIndex); 278 nextValue = nextToken.substring(equalsIndex + 1); 279 } 280 281 nextKey = unescape(nextKey); 282 nextValue = unescape(nextValue); 283 284 List<String> list = map.computeIfAbsent(nextKey, k -> new ArrayList<>()); 285 list.add(nextValue); 286 } 287 } 288 289 public static Map<String, String[]> parseQueryStrings(String... theQueryString) { 290 HashMap<String, List<String>> map = new HashMap<>(); 291 for (String next : theQueryString) { 292 parseQueryString(next, map); 293 } 294 return toQueryStringMap(map); 295 } 296 297 /** 298 * Normalizes canonical URLs for comparison. Trailing "/" is stripped, 299 * and any version identifiers or fragment hash is removed 300 */ 301 public static String normalizeCanonicalUrlForComparison(String theUrl) { 302 String retVal; 303 try { 304 retVal = new URI(theUrl).normalize().toString(); 305 } catch (URISyntaxException e) { 306 retVal = theUrl; 307 } 308 while (endsWith(retVal, "/")) { 309 retVal = retVal.substring(0, retVal.length() - 1); 310 } 311 int hashOrPipeIndex = StringUtils.indexOfAny(retVal, '#', '|'); 312 if (hashOrPipeIndex != -1) { 313 retVal = retVal.substring(0, hashOrPipeIndex); 314 } 315 return retVal; 316 } 317 318 /** 319 * Parse a URL in one of the following forms: 320 * <ul> 321 * <li>[Resource Type]?[Search Params] 322 * <li>[Resource Type]/[Resource ID] 323 * <li>[Resource Type]/[Resource ID]/_history/[Version ID] 324 * </ul> 325 */ 326 public static UrlParts parseUrl(String theUrl) { 327 String url = theUrl; 328 UrlParts retVal = new UrlParts(); 329 if (url.startsWith("http")) { 330 int qmIdx = url.indexOf('?'); 331 if (qmIdx != -1) { 332 retVal.setParams(defaultIfBlank(url.substring(qmIdx + 1), null)); 333 url = url.substring(0, qmIdx); 334 } 335 336 IdDt id = new IdDt(url); 337 retVal.setResourceType(id.getResourceType()); 338 retVal.setResourceId(id.getIdPart()); 339 retVal.setVersionId(id.getVersionIdPart()); 340 return retVal; 341 } 342 343 int parsingStart = 0; 344 if (url.length() > 2) { 345 if (url.charAt(0) == '/') { 346 if (Character.isLetter(url.charAt(1))) { 347 parsingStart = 1; 348 } 349 } 350 } 351 352 int nextStart = parsingStart; 353 boolean nextIsHistory = false; 354 355 for (int idx = parsingStart; idx < url.length(); idx++) { 356 char nextChar = url.charAt(idx); 357 boolean atEnd = (idx + 1) == url.length(); 358 if (nextChar == '?' || nextChar == '/' || atEnd) { 359 int endIdx = (atEnd && nextChar != '?') ? idx + 1 : idx; 360 String nextSubstring = url.substring(nextStart, endIdx); 361 if (retVal.getResourceType() == null) { 362 retVal.setResourceType(nextSubstring); 363 } else if (retVal.getResourceId() == null) { 364 retVal.setResourceId(nextSubstring); 365 } else if (nextIsHistory) { 366 retVal.setVersionId(nextSubstring); 367 } else { 368 if (nextSubstring.equals(Constants.URL_TOKEN_HISTORY)) { 369 nextIsHistory = true; 370 } else { 371 throw new InvalidRequestException(Msg.code(1742) + "Invalid FHIR resource URL: " + url); 372 } 373 } 374 if (nextChar == '?') { 375 if (url.length() > idx + 1) { 376 retVal.setParams(url.substring(idx + 1)); 377 } 378 break; 379 } 380 nextStart = idx + 1; 381 } 382 } 383 384 return retVal; 385 386 } 387 388 /** 389 * This method specifically HTML-encodes the " and 390 * < characters in order to prevent injection attacks 391 */ 392 public static String sanitizeUrlPart(IPrimitiveType<?> theString) { 393 String retVal = null; 394 if (theString != null) { 395 retVal = sanitizeUrlPart(theString.getValueAsString()); 396 } 397 return retVal; 398 } 399 400 /** 401 * This method specifically HTML-encodes the " and 402 * < characters in order to prevent injection attacks. 403 * <p> 404 * The following characters are escaped: 405 * <ul> 406 * <li>'</li> 407 * <li>"</li> 408 * <li><</li> 409 * <li>></li> 410 * <li>\n (newline)</li> 411 * </ul> 412 */ 413 public static String sanitizeUrlPart(CharSequence theString) { 414 if (theString == null) { 415 return null; 416 } 417 418 boolean needsSanitization = isNeedsSanitization(theString); 419 420 if (needsSanitization) { 421 // Ok, we're sanitizing 422 StringBuilder buffer = new StringBuilder(theString.length() + 10); 423 for (int j = 0; j < theString.length(); j++) { 424 425 char nextChar = theString.charAt(j); 426 switch (nextChar) { 427 /* 428 * NB: If you add a constant here, you also need to add it 429 * to isNeedsSanitization()!! 430 */ 431 case '\'': 432 buffer.append("'"); 433 break; 434 case '"': 435 buffer.append("""); 436 break; 437 case '<': 438 buffer.append("<"); 439 break; 440 case '>': 441 buffer.append(">"); 442 break; 443 case '\n': 444 buffer.append(" "); 445 break; 446 case '\r': 447 buffer.append(" "); 448 break; 449 default: 450 if (nextChar >= ' ') { 451 buffer.append(nextChar); 452 } 453 break; 454 } 455 456 } // for build escaped string 457 458 return buffer.toString(); 459 } 460 461 return theString.toString(); 462 } 463 464 /** 465 * Applies the same logic as {@link #sanitizeUrlPart(CharSequence)} but against an array, returning an array with the 466 * same strings as the input but with sanitization applied 467 */ 468 public static String[] sanitizeUrlPart(String[] theParameterValues) { 469 String[] retVal = null; 470 if (theParameterValues != null) { 471 retVal = new String[theParameterValues.length]; 472 for (int i = 0; i < theParameterValues.length; i++) { 473 retVal[i] = sanitizeUrlPart(theParameterValues[i]); 474 } 475 } 476 return retVal; 477 } 478 479 private static Map<String, String[]> toQueryStringMap(HashMap<String, List<String>> map) { 480 HashMap<String, String[]> retVal = new HashMap<>(); 481 for (Entry<String, List<String>> nextEntry : map.entrySet()) { 482 retVal.put(nextEntry.getKey(), nextEntry.getValue().toArray(new String[0])); 483 } 484 return retVal; 485 } 486 487 public static String unescape(String theString) { 488 if (theString == null) { 489 return null; 490 } 491 // If the user passes "_outputFormat" as a GET request parameter directly in the URL: 492 final boolean shouldEscapePlus = !theString.startsWith("application/"); 493 494 for (int i = 0; i < theString.length(); i++) { 495 char nextChar = theString.charAt(i); 496 if (nextChar == '%' || (nextChar == '+' && shouldEscapePlus)) { 497 try { 498 // Yes it would be nice to not use a string "UTF-8" but the equivalent 499 // method that takes Charset is JDK10+ only... sigh.... 500 return URLDecoder.decode(theString, "UTF-8"); 501 } catch (UnsupportedEncodingException e) { 502 throw new Error(Msg.code(1743) + "UTF-8 not supported, this shouldn't happen", e); 503 } 504 } 505 } 506 return theString; 507 } 508 509 public static List<NameValuePair> translateMatchUrl(String theMatchUrl) { 510 List<NameValuePair> parameters; 511 String matchUrl = theMatchUrl; 512 int questionMarkIndex = matchUrl.indexOf('?'); 513 if (questionMarkIndex != -1) { 514 matchUrl = matchUrl.substring(questionMarkIndex + 1); 515 } 516 517 final String[] searchList = new String[]{ 518 "+", 519 "|", 520 "=>=", 521 "=<=", 522 "=>", 523 "=<" 524 }; 525 final String[] replacementList = new String[]{ 526 "%2B", 527 "%7C", 528 "=%3E%3D", 529 "=%3C%3D", 530 "=%3E", 531 "=%3C" 532 }; 533 matchUrl = StringUtils.replaceEach(matchUrl, searchList, replacementList); 534 if (matchUrl.contains(" ")) { 535 throw new InvalidRequestException(Msg.code(1744) + "Failed to parse match URL[" + theMatchUrl + "] - URL is invalid (must not contain spaces)"); 536 } 537 538 parameters = URLEncodedUtils.parse((matchUrl), Constants.CHARSET_UTF8, '&'); 539 540 // One issue that has happened before is people putting a "+" sign into an email address in a match URL 541 // and having that turn into a " ". Since spaces are never appropriate for email addresses, let's just 542 // assume they really meant "+". 543 for (int i = 0; i < parameters.size(); i++) { 544 NameValuePair next = parameters.get(i); 545 if (next.getName().equals("email") && next.getValue().contains(" ")) { 546 BasicNameValuePair newPair = new BasicNameValuePair(next.getName(), next.getValue().replace(' ', '+')); 547 parameters.set(i, newPair); 548 } 549 } 550 551 return parameters; 552 } 553 554 public static class UrlParts { 555 private String myParams; 556 private String myResourceId; 557 private String myResourceType; 558 private String myVersionId; 559 560 public String getParams() { 561 return myParams; 562 } 563 564 public void setParams(String theParams) { 565 myParams = theParams; 566 } 567 568 public String getResourceId() { 569 return myResourceId; 570 } 571 572 public void setResourceId(String theResourceId) { 573 myResourceId = theResourceId; 574 } 575 576 public String getResourceType() { 577 return myResourceType; 578 } 579 580 public void setResourceType(String theResourceType) { 581 myResourceType = theResourceType; 582 } 583 584 public String getVersionId() { 585 return myVersionId; 586 } 587 588 public void setVersionId(String theVersionId) { 589 myVersionId = theVersionId; 590 } 591 } 592}