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 &quot; and
390         * &lt; 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 &quot; and
402         * &lt; characters in order to prevent injection attacks.
403         * <p>
404         * The following characters are escaped:
405         * <ul>
406         *    <li>&apos;</li>
407         *    <li>&quot;</li>
408         *    <li>&lt;</li>
409         *    <li>&gt;</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("&apos;");
433                                                break;
434                                        case '"':
435                                                buffer.append("&quot;");
436                                                break;
437                                        case '<':
438                                                buffer.append("&lt;");
439                                                break;
440                                        case '>':
441                                                buffer.append("&gt;");
442                                                break;
443                                        case '\n':
444                                                buffer.append("&#10;");
445                                                break;
446                                        case '\r':
447                                                buffer.append("&#13;");
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}