001/* 002 * Copyright 2022-2026 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.soklet; 018 019import com.soklet.exception.IllegalRequestException; 020import com.soklet.internal.spring.LinkedCaseInsensitiveMap; 021import com.soklet.internal.util.HostHeaderValidator; 022import org.jspecify.annotations.NonNull; 023import org.jspecify.annotations.Nullable; 024 025import javax.annotation.concurrent.ThreadSafe; 026import java.io.ByteArrayOutputStream; 027import java.lang.Thread.UncaughtExceptionHandler; 028import java.lang.invoke.MethodHandle; 029import java.lang.invoke.MethodHandles; 030import java.lang.invoke.MethodHandles.Lookup; 031import java.lang.invoke.MethodType; 032import java.net.InetAddress; 033import java.net.InetSocketAddress; 034import java.net.URI; 035import java.net.URISyntaxException; 036import java.net.URLEncoder; 037import java.net.UnknownHostException; 038import java.nio.charset.Charset; 039import java.nio.charset.IllegalCharsetNameException; 040import java.nio.charset.StandardCharsets; 041import java.nio.charset.UnsupportedCharsetException; 042import java.util.ArrayDeque; 043import java.util.ArrayList; 044import java.util.Arrays; 045import java.util.Collections; 046import java.util.Comparator; 047import java.util.Deque; 048import java.util.LinkedHashMap; 049import java.util.LinkedHashSet; 050import java.util.List; 051import java.util.Locale; 052import java.util.Locale.LanguageRange; 053import java.util.Map; 054import java.util.Map.Entry; 055import java.util.Optional; 056import java.util.Set; 057import java.util.concurrent.ExecutorService; 058import java.util.concurrent.Executors; 059import java.util.concurrent.ThreadFactory; 060import java.util.function.Predicate; 061import java.util.regex.Matcher; 062import java.util.regex.Pattern; 063import java.util.stream.Collectors; 064 065import static java.lang.String.format; 066import static java.util.Objects.requireNonNull; 067 068/** 069 * A non-instantiable collection of utility methods. 070 * 071 * @author <a href="https://www.revetkn.com">Mark Allen</a> 072 */ 073@ThreadSafe 074public final class Utilities { 075 private static final boolean VIRTUAL_THREADS_AVAILABLE; 076 private static final byte @NonNull [] EMPTY_BYTE_ARRAY; 077 @NonNull 078 private static final Pattern HEAD_WHITESPACE_PATTERN; 079 @NonNull 080 private static final Pattern TAIL_WHITESPACE_PATTERN; 081 @NonNull 082 private static final Pattern HEADER_PERCENT_ENCODING_PATTERN; 083 084 static { 085 EMPTY_BYTE_ARRAY = new byte[0]; 086 087 boolean virtualThreadsAvailable = false; 088 089 try { 090 // Detect if Virtual Threads are usable by feature testing via reflection. 091 // Hat tip to https://github.com/javalin/javalin for this technique 092 Class.forName("java.lang.Thread$Builder$OfVirtual"); 093 virtualThreadsAvailable = true; 094 } catch (Exception ignored) { 095 // We don't care why this failed, but if we're here we know JVM does not support virtual threads 096 } 097 098 VIRTUAL_THREADS_AVAILABLE = virtualThreadsAvailable; 099 100 // See https://www.regular-expressions.info/unicode.html 101 // \p{Z} or \p{Separator}: Unicode space-separator characters. 102 // 103 // First pattern matches those separator characters at the head of a string, second matches the same for tail. 104 // Useful for a "stronger" trim() function, which is almost always what we want in a web context 105 // with user-supplied input. 106 HEAD_WHITESPACE_PATTERN = Pattern.compile("^(\\p{Z})+"); 107 TAIL_WHITESPACE_PATTERN = Pattern.compile("(\\p{Z})+$"); 108 109 HEADER_PERCENT_ENCODING_PATTERN = Pattern.compile("%([0-9A-Fa-f]{2})"); 110 } 111 112 private Utilities() { 113 // Non-instantiable 114 } 115 116 /** 117 * Does the platform runtime support virtual threads (either Java 19 and 20 w/preview enabled or Java 21+)? 118 * 119 * @return {@code true} if the runtime supports virtual threads, {@code false} otherwise 120 */ 121 @NonNull 122 static Boolean virtualThreadsAvailable() { 123 return VIRTUAL_THREADS_AVAILABLE; 124 } 125 126 /** 127 * Provides a virtual thread factory if supported by the runtime. 128 * <p> 129 * In order to support Soklet users who are not yet ready to enable virtual threads (those <strong>not</strong> running either Java 19 and 20 w/preview enabled or Java 21+), 130 * we compile Soklet with a source level < 19 and avoid any hard references to virtual threads by dynamically creating our {@link ThreadFactory} via {@link MethodHandle} references. 131 * <p> 132 * <strong>You should not call this method if {@link Utilities#virtualThreadsAvailable()} is {@code false}.</strong> 133 * 134 * @param threadNamePrefix thread name prefix for the virtual thread factory builder 135 * @param uncaughtExceptionHandler uncaught exception handler for the virtual thread factory builder 136 * @return a virtual thread factory 137 * @throws IllegalStateException if the runtime environment does not support virtual threads 138 */ 139 @NonNull 140 static ThreadFactory createVirtualThreadFactory(@NonNull String threadNamePrefix, 141 @NonNull UncaughtExceptionHandler uncaughtExceptionHandler) { 142 requireNonNull(threadNamePrefix); 143 requireNonNull(uncaughtExceptionHandler); 144 145 if (!virtualThreadsAvailable()) 146 throw new IllegalStateException("Virtual threads are not available. Please confirm you are using Java 19-20 with the '--enable-preview' javac parameter specified or Java 21+"); 147 148 // Hat tip to https://github.com/javalin/javalin for this technique 149 Class<?> threadBuilderOfVirtualClass; 150 151 try { 152 threadBuilderOfVirtualClass = Class.forName("java.lang.Thread$Builder$OfVirtual"); 153 } catch (ClassNotFoundException e) { 154 throw new IllegalStateException("Unable to load virtual thread builder class", e); 155 } 156 157 Lookup lookup = MethodHandles.publicLookup(); 158 159 MethodHandle methodHandleThreadOfVirtual; 160 MethodHandle methodHandleThreadBuilderOfVirtualName; 161 MethodHandle methodHandleThreadBuilderOfVirtualUncaughtExceptionHandler; 162 MethodHandle methodHandleThreadBuilderOfVirtualFactory; 163 164 try { 165 methodHandleThreadOfVirtual = lookup.findStatic(Thread.class, "ofVirtual", MethodType.methodType(threadBuilderOfVirtualClass)); 166 methodHandleThreadBuilderOfVirtualName = lookup.findVirtual(threadBuilderOfVirtualClass, "name", MethodType.methodType(threadBuilderOfVirtualClass, String.class, long.class)); 167 methodHandleThreadBuilderOfVirtualUncaughtExceptionHandler = lookup.findVirtual(threadBuilderOfVirtualClass, "uncaughtExceptionHandler", MethodType.methodType(threadBuilderOfVirtualClass, UncaughtExceptionHandler.class)); 168 methodHandleThreadBuilderOfVirtualFactory = lookup.findVirtual(threadBuilderOfVirtualClass, "factory", MethodType.methodType(ThreadFactory.class)); 169 } catch (NoSuchMethodException | IllegalAccessException e) { 170 throw new IllegalStateException("Unable to load method handle for virtual thread factory", e); 171 } 172 173 try { 174 // Thread.ofVirtual() 175 Object virtualThreadBuilder = methodHandleThreadOfVirtual.invoke(); 176 // .name(threadNamePrefix, start) 177 methodHandleThreadBuilderOfVirtualName.invoke(virtualThreadBuilder, threadNamePrefix, 1); 178 // .uncaughtExceptionHandler(uncaughtExceptionHandler) 179 methodHandleThreadBuilderOfVirtualUncaughtExceptionHandler.invoke(virtualThreadBuilder, uncaughtExceptionHandler); 180 // .factory(); 181 return (ThreadFactory) methodHandleThreadBuilderOfVirtualFactory.invoke(virtualThreadBuilder); 182 } catch (Throwable t) { 183 throw new IllegalStateException("Unable to create virtual thread factory", t); 184 } 185 } 186 187 /** 188 * Provides a virtual-thread-per-task executor service if supported by the runtime. 189 * <p> 190 * In order to support Soklet users who are not yet ready to enable virtual threads (those <strong>not</strong> running either Java 19 and 20 w/preview enabled or Java 21+), 191 * we compile Soklet with a source level < 19 and avoid any hard references to virtual threads by dynamically creating our executor service via {@link MethodHandle} references. 192 * <p> 193 * <strong>You should not call this method if {@link Utilities#virtualThreadsAvailable()} is {@code false}.</strong> 194 * <pre>{@code // This method is effectively equivalent to this code 195 * return Executors.newThreadPerTaskExecutor( 196 * Thread.ofVirtual() 197 * .name(threadNamePrefix) 198 * .uncaughtExceptionHandler(uncaughtExceptionHandler) 199 * .factory() 200 * );}</pre> 201 * 202 * @param threadNamePrefix thread name prefix for the virtual thread factory builder 203 * @param uncaughtExceptionHandler uncaught exception handler for the virtual thread factory builder 204 * @return a virtual-thread-per-task executor service 205 * @throws IllegalStateException if the runtime environment does not support virtual threads 206 */ 207 @NonNull 208 static ExecutorService createVirtualThreadsNewThreadPerTaskExecutor(@NonNull String threadNamePrefix, 209 @NonNull UncaughtExceptionHandler uncaughtExceptionHandler) { 210 requireNonNull(threadNamePrefix); 211 requireNonNull(uncaughtExceptionHandler); 212 213 if (!virtualThreadsAvailable()) 214 throw new IllegalStateException("Virtual threads are not available. Please confirm you are using Java 19-20 with the '--enable-preview' javac parameter specified or Java 21+"); 215 216 ThreadFactory threadFactory = createVirtualThreadFactory(threadNamePrefix, uncaughtExceptionHandler); 217 218 Lookup lookup = MethodHandles.publicLookup(); 219 MethodHandle methodHandleExecutorsNewThreadPerTaskExecutor; 220 221 try { 222 methodHandleExecutorsNewThreadPerTaskExecutor = lookup.findStatic(Executors.class, "newThreadPerTaskExecutor", MethodType.methodType(ExecutorService.class, ThreadFactory.class)); 223 } catch (NoSuchMethodException | IllegalAccessException e) { 224 throw new IllegalStateException("Unable to load method handle for virtual thread factory", e); 225 } 226 227 try { 228 // return Executors.newThreadPerTaskExecutor(threadFactory); 229 return (ExecutorService) methodHandleExecutorsNewThreadPerTaskExecutor.invoke(threadFactory); 230 } catch (Throwable t) { 231 throw new IllegalStateException("Unable to create virtual thread executor service", t); 232 } 233 } 234 235 /** 236 * Returns a shared zero-length {@code byte[]} instance. 237 * <p> 238 * Useful as a sentinel when you need a non-{@code null} byte array but have no content. 239 * 240 * @return a zero-length byte array (never {@code null}) 241 */ 242 static byte @NonNull [] emptyByteArray() { 243 return EMPTY_BYTE_ARRAY; 244 } 245 246 /** 247 * Parses a query string such as {@code "a=1&b=2&c=%20"} into a multimap of names to values. 248 * <p> 249 * Decodes percent-escapes using UTF-8, which is usually what you want (see {@link #extractQueryParametersFromQuery(String, QueryFormat, Charset)} if you need to specify a different charset). 250 * <p> 251 * Pairs missing a name are ignored. 252 * <p> 253 * Multiple occurrences of the same name are collected into a {@link Set} in insertion order (duplicates are de-duplicated). 254 * 255 * @param query a raw query string such as {@code "a=1&b=2&c=%20"} 256 * @param queryFormat how to decode: {@code application/x-www-form-urlencoded} or "strict" RFC 3986 257 * @return a map of parameter names to their distinct values, preserving first-seen name order; empty if none 258 * @throws IllegalRequestException if the query string contains malformed percent-encoding 259 */ 260 @NonNull 261 public static Map<@NonNull String, @NonNull Set<@NonNull String>> extractQueryParametersFromQuery(@NonNull String query, 262 @NonNull QueryFormat queryFormat) { 263 requireNonNull(query); 264 requireNonNull(queryFormat); 265 266 return extractQueryParametersFromQuery(query, queryFormat, StandardCharsets.UTF_8); 267 } 268 269 /** 270 * Parses a query string such as {@code "a=1&b=2&c=%20"} into a multimap of names to values. 271 * <p> 272 * Decodes percent-escapes using the specified charset. 273 * <p> 274 * Pairs missing a name are ignored. 275 * <p> 276 * Multiple occurrences of the same name are collected into a {@link Set} in insertion order (duplicates are de-duplicated). 277 * 278 * @param query a raw query string such as {@code "a=1&b=2&c=%20"} 279 * @param queryFormat how to decode: {@code application/x-www-form-urlencoded} or "strict" RFC 3986 280 * @param charset the charset to use when decoding percent-escapes 281 * @return a map of parameter names to their distinct values, preserving first-seen name order; empty if none 282 * @throws IllegalRequestException if the query string contains malformed percent-encoding 283 */ 284 @NonNull 285 public static Map<@NonNull String, @NonNull Set<@NonNull String>> extractQueryParametersFromQuery(@NonNull String query, 286 @NonNull QueryFormat queryFormat, 287 @NonNull Charset charset) { 288 requireNonNull(query); 289 requireNonNull(queryFormat); 290 requireNonNull(charset); 291 292 // For form parameters, body will look like "One=Two&Three=Four" ...a query string. 293 String syntheticUrl = format("https://soklet.invalid?%s", query); // avoid referencing real domain 294 return extractQueryParametersFromUrl(syntheticUrl, queryFormat, charset); 295 } 296 297 @NonNull 298 static Optional<Set<@NonNull String>> extractQueryParameterValuesFromQuery(@NonNull String query, 299 @NonNull String name, 300 @NonNull QueryFormat queryFormat, 301 @NonNull Charset charset) { 302 requireNonNull(query); 303 requireNonNull(name); 304 requireNonNull(queryFormat); 305 requireNonNull(charset); 306 307 query = trimAggressivelyToEmpty(query); 308 309 if (query.isEmpty()) 310 return Optional.empty(); 311 312 String singleValue = null; 313 Set<String> values = null; 314 boolean matched = false; 315 int pairStart = 0; 316 317 while (pairStart <= query.length()) { 318 int pairEnd = query.indexOf('&', pairStart); 319 if (pairEnd == -1) 320 pairEnd = query.length(); 321 322 if (pairEnd > pairStart) { 323 int separator = query.indexOf('=', pairStart); 324 if (separator == -1 || separator > pairEnd) 325 separator = pairEnd; 326 327 String rawName = trimAggressivelyToNull(query.substring(pairStart, separator)); 328 329 if (rawName != null) { 330 String decodedName = decodeQueryComponent(rawName, queryFormat, charset); 331 332 if (decodedName.equals(name)) { 333 String rawValue = separator < pairEnd ? trimAggressivelyToNull(query.substring(separator + 1, pairEnd)) : null; 334 335 if (rawValue == null) 336 rawValue = ""; 337 338 String value = decodeQueryComponent(rawValue, queryFormat, charset); 339 340 if (!matched) { 341 singleValue = value; 342 matched = true; 343 } else { 344 if (values == null) { 345 values = new LinkedHashSet<>(); 346 values.add(singleValue); 347 } 348 349 values.add(value); 350 } 351 } 352 } 353 } 354 355 if (pairEnd == query.length()) 356 break; 357 358 pairStart = pairEnd + 1; 359 } 360 361 if (!matched) 362 return Optional.empty(); 363 364 if (values == null) 365 return Optional.of(Set.of(singleValue)); 366 367 return Optional.of(Collections.unmodifiableSet(values)); 368 } 369 370 /** 371 * Parses query strings from relative or absolute URLs such as {@code "/example?a=a=1&b=2&c=%20"} or {@code "https://www.soklet.com/example?a=1&b=2&c=%20"} into a multimap of names to values. 372 * <p> 373 * Decodes percent-escapes using UTF-8, which is usually what you want (see {@link #extractQueryParametersFromUrl(String, QueryFormat, Charset)} if you need to specify a different charset). 374 * <p> 375 * Pairs missing a name are ignored. 376 * <p> 377 * Multiple occurrences of the same name are collected into a {@link Set} in insertion order (duplicates are de-duplicated). 378 * 379 * @param url a relative or absolute URL/URI string 380 * @param queryFormat how to decode: {@code application/x-www-form-urlencoded} or "strict" RFC 3986 381 * @return a map of parameter names to their distinct values, preserving first-seen name order; empty if none 382 * @throws IllegalRequestException if the URL or query contains malformed percent-encoding 383 */ 384 @NonNull 385 public static Map<@NonNull String, @NonNull Set<@NonNull String>> extractQueryParametersFromUrl(@NonNull String url, 386 @NonNull QueryFormat queryFormat) { 387 requireNonNull(url); 388 requireNonNull(queryFormat); 389 390 return extractQueryParametersFromUrl(url, queryFormat, StandardCharsets.UTF_8); 391 } 392 393 /** 394 * Parses query strings from relative or absolute URLs such as {@code "/example?a=a=1&b=2&c=%20"} or {@code "https://www.soklet.com/example?a=1&b=2&c=%20"} into a multimap of names to values. 395 * <p> 396 * Decodes percent-escapes using the specified charset. 397 * <p> 398 * Pairs missing a name are ignored. 399 * <p> 400 * Multiple occurrences of the same name are collected into a {@link Set} in insertion order (duplicates are de-duplicated). 401 * 402 * @param url a relative or absolute URL/URI string 403 * @param queryFormat how to decode: {@code application/x-www-form-urlencoded} or "strict" RFC 3986 404 * @param charset the charset to use when decoding percent-escapes 405 * @return a map of parameter names to their distinct values, preserving first-seen name order; empty if none 406 * @throws IllegalRequestException if the URL or query contains malformed percent-encoding 407 */ 408 @NonNull 409 public static Map<@NonNull String, @NonNull Set<@NonNull String>> extractQueryParametersFromUrl(@NonNull String url, 410 @NonNull QueryFormat queryFormat, 411 @NonNull Charset charset) { 412 requireNonNull(url); 413 requireNonNull(queryFormat); 414 requireNonNull(charset); 415 416 URI uri; 417 418 try { 419 uri = new URI(url); 420 } catch (URISyntaxException e) { 421 throw new IllegalRequestException(format("Invalid URL '%s'", url), e); 422 } 423 424 String query = trimAggressivelyToNull(uri.getRawQuery()); 425 426 if (query == null) 427 return Map.of(); 428 429 Map<String, Set<String>> queryParameters = new LinkedHashMap<>(); 430 for (String pair : query.split("&", -1)) { 431 if (pair.isEmpty()) 432 continue; 433 434 String[] nv = pair.split("=", 2); 435 String rawName = trimAggressivelyToNull(nv.length > 0 ? nv[0] : null); 436 String rawValue = trimAggressivelyToNull(nv.length > 1 ? nv[1] : null); 437 438 if (rawName == null) 439 continue; 440 441 // Preserve empty values; it's what users probably expect 442 if (rawValue == null) 443 rawValue = ""; 444 445 String name = decodeQueryComponent(rawName, queryFormat, charset); 446 String value = decodeQueryComponent(rawValue, queryFormat, charset); 447 448 addStringValue(queryParameters, name, value); 449 } 450 451 freezeStringValueSets(queryParameters); 452 return queryParameters; 453 } 454 455 /** 456 * Decodes a single key or value using the given mode and charset. 457 */ 458 @NonNull 459 private static String decodeQueryComponent(@NonNull String string, 460 @NonNull QueryFormat queryFormat, 461 @NonNull Charset charset) { 462 requireNonNull(string); 463 requireNonNull(queryFormat); 464 requireNonNull(charset); 465 466 if (string.isEmpty()) 467 return ""; 468 469 // Step 1: in form mode, '+' means space 470 String prepped = (queryFormat == QueryFormat.X_WWW_FORM_URLENCODED) ? string.replace('+', ' ') : string; 471 // Step 2: percent-decode bytes, then interpret bytes with the provided charset 472 return percentDecode(prepped, charset); 473 } 474 475 /** 476 * Percent-decodes a string into bytes, then constructs a String using the provided charset. 477 * One pass only: invalid %xy sequences trigger an exception. 478 */ 479 @NonNull 480 private static String percentDecode(@NonNull String s, @NonNull Charset charset) { 481 requireNonNull(s); 482 requireNonNull(charset); 483 484 if (s.isEmpty()) 485 return ""; 486 487 StringBuilder sb = new StringBuilder(s.length()); 488 ByteArrayOutputStream bytes = new ByteArrayOutputStream(); 489 490 for (int i = 0; i < s.length(); ) { 491 char c = s.charAt(i); 492 493 if (c == '%') { 494 // Consume one or more consecutive %xx triplets into bytes 495 bytes.reset(); 496 int j = i; 497 498 while (j < s.length() && s.charAt(j) == '%') { 499 if (j + 2 >= s.length()) 500 throw new IllegalRequestException("Invalid percent-encoding in URL component"); 501 502 int hi = hex(s.charAt(j + 1)); 503 int lo = hex(s.charAt(j + 2)); 504 if (hi < 0 || lo < 0) 505 throw new IllegalRequestException("Invalid percent-encoding in URL component"); 506 507 bytes.write((hi << 4) | lo); 508 j += 3; 509 } 510 511 sb.append(new String(bytes.toByteArray(), charset)); 512 i = j; 513 continue; 514 } 515 516 // Non-'%' char: append it as-is. 517 // This preserves surrogate pairs naturally as the loop hits both chars. 518 sb.append(c); 519 i++; 520 } 521 522 return sb.toString(); 523 } 524 525 static void validatePercentEncodingInUrlComponent(@NonNull String urlComponent) { 526 requireNonNull(urlComponent); 527 528 for (int i = 0; i < urlComponent.length(); i++) { 529 if (urlComponent.charAt(i) != '%') 530 continue; 531 532 if (i + 2 >= urlComponent.length()) 533 throw new IllegalRequestException("Invalid percent-encoding in URL component"); 534 535 int hi = hex(urlComponent.charAt(i + 1)); 536 int lo = hex(urlComponent.charAt(i + 2)); 537 if (hi < 0 || lo < 0) 538 throw new IllegalRequestException("Invalid percent-encoding in URL component"); 539 540 i += 2; 541 } 542 } 543 544 private static int hex(char c) { 545 if (c >= '0' && c <= '9') return c - '0'; 546 if (c >= 'A' && c <= 'F') return c - 'A' + 10; 547 if (c >= 'a' && c <= 'f') return c - 'a' + 10; 548 return -1; 549 } 550 551 /** 552 * Parses {@code Cookie} request headers into a map of cookie names to values. 553 * <p> 554 * Header name matching is case-insensitive ({@code "Cookie"} vs {@code "cookie"}), but <em>cookie names are case-sensitive</em>. 555 * Values are parsed per the following liberal rules: 556 * <ul> 557 * <li>Components are split on {@code ';'} unless inside a quoted string.</li> 558 * <li>Quoted values have surrounding quotes removed and common backslash escapes unescaped.</li> 559 * <li>Percent-escapes are decoded as UTF-8. {@code '+'} is <strong>not</strong> treated specially.</li> 560 * </ul> 561 * Multiple occurrences of the same cookie name are collected into a {@link Set} in insertion order. 562 * 563 * @param headers request headers as a multimap of header name to values (must be non-{@code null}) 564 * @return a map of cookie name to distinct values; empty if no valid cookies are present 565 */ 566 @NonNull 567 public static Map<@NonNull String, @NonNull Set<@NonNull String>> extractCookiesFromHeaders(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) { 568 requireNonNull(headers); 569 570 // Cookie *names* must be case-sensitive; keep LinkedHashMap (NOT case-insensitive) 571 Map<String, Set<String>> cookies = new LinkedHashMap<>(); 572 573 for (Entry<String, Set<String>> entry : headers.entrySet()) { 574 String headerName = entry.getKey(); 575 if (headerName == null || !"cookie".equalsIgnoreCase(headerName.trim())) 576 continue; 577 578 Set<String> values = entry.getValue(); 579 if (values == null) continue; 580 581 for (String headerValue : values) { 582 headerValue = trimAggressivelyToNull(headerValue); 583 if (headerValue == null) continue; 584 585 // Split on ';' only when NOT inside a quoted string 586 List<String> cookieComponents = splitCookieHeaderRespectingQuotes(headerValue); 587 588 for (String cookieComponent : cookieComponents) { 589 cookieComponent = trimAggressivelyToNull(cookieComponent); 590 if (cookieComponent == null) continue; 591 592 String[] cookiePair = cookieComponent.split("=", 2); 593 String rawName = trimAggressivelyToNull(cookiePair[0]); 594 String rawValue = (cookiePair.length == 2 ? trimAggressivelyToNull(cookiePair[1]) : null); 595 596 if (rawName == null) continue; 597 598 // DO NOT decode the name; cookie names are case-sensitive and rarely encoded 599 String cookieName = rawName; 600 601 String cookieValue = null; 602 if (rawValue != null) { 603 // If it's quoted, unquote+unescape first, then percent-decode (still no '+' -> space) 604 String unquoted = unquoteCookieValueIfNeeded(rawValue); 605 cookieValue = percentDecodeCookieValue(unquoted); 606 } 607 608 cookies.putIfAbsent(cookieName, Set.of()); 609 if (cookieValue != null) 610 addStringValue(cookies, cookieName, cookieValue); 611 } 612 } 613 } 614 615 freezeStringValueSets(cookies); 616 return cookies; 617 } 618 619 /** 620 * Percent-decodes %HH to bytes->UTF-8. Does NOT treat '+' specially. 621 */ 622 @NonNull 623 private static String percentDecodeCookieValue(@NonNull String cookieValue) { 624 requireNonNull(cookieValue); 625 626 ByteArrayOutputStream out = new ByteArrayOutputStream(cookieValue.length()); 627 628 for (int i = 0; i < cookieValue.length(); ) { 629 char c = cookieValue.charAt(i); 630 if (c == '%') { 631 if (i + 2 >= cookieValue.length()) 632 throw new IllegalRequestException("Invalid percent-encoding in Cookie header"); 633 634 int hi = Character.digit(cookieValue.charAt(i + 1), 16); 635 int lo = Character.digit(cookieValue.charAt(i + 2), 16); 636 if (hi < 0 || lo < 0) 637 throw new IllegalRequestException("Invalid percent-encoding in Cookie header"); 638 639 out.write((hi << 4) + lo); 640 i += 3; 641 continue; 642 } 643 644 String rawCharacter; 645 646 if (Character.isHighSurrogate(c) && i + 1 < cookieValue.length() && Character.isLowSurrogate(cookieValue.charAt(i + 1))) { 647 rawCharacter = cookieValue.substring(i, i + 2); 648 i += 2; 649 } else { 650 rawCharacter = Character.toString(c); 651 i++; 652 } 653 654 byte[] encoded = rawCharacter.getBytes(StandardCharsets.UTF_8); 655 out.write(encoded, 0, encoded.length); 656 } 657 658 return out.toString(StandardCharsets.UTF_8); 659 } 660 661 /** 662 * Splits a Cookie header string into components on ';' but ONLY when not inside a quoted value. 663 * Supports backslash-escaped quotes within quoted strings. 664 */ 665 private static List<@NonNull String> splitCookieHeaderRespectingQuotes(@NonNull String headerValue) { 666 List<String> parts = new ArrayList<>(); 667 StringBuilder cur = new StringBuilder(headerValue.length()); 668 boolean inQuotes = false; 669 boolean escape = false; 670 671 for (int i = 0; i < headerValue.length(); i++) { 672 char c = headerValue.charAt(i); 673 674 if (escape) { 675 // keep escaped char literally (e.g., \" \; \\) 676 cur.append(c); 677 escape = false; 678 continue; 679 } 680 681 if (c == '\\') { 682 escape = true; 683 // keep the backslash for now; unquote step will handle unescaping 684 cur.append(c); 685 continue; 686 } 687 688 if (c == '"') { 689 inQuotes = !inQuotes; 690 cur.append(c); 691 continue; 692 } 693 694 if (c == ';' && !inQuotes) { 695 parts.add(cur.toString()); 696 cur.setLength(0); 697 continue; 698 } 699 700 cur.append(c); 701 } 702 703 if (cur.length() > 0) 704 parts.add(cur.toString()); 705 706 return parts; 707 } 708 709 /** 710 * If the cookie value is a quoted-string, remove surrounding quotes and unescape \" \\ and \; . 711 * Otherwise returns the input as-is. 712 */ 713 @NonNull 714 private static String unquoteCookieValueIfNeeded(@NonNull String rawValue) { 715 requireNonNull(rawValue); 716 717 if (rawValue.length() >= 2 && rawValue.charAt(0) == '"' && rawValue.charAt(rawValue.length() - 1) == '"') { 718 // Strip the surrounding quotes 719 String inner = rawValue.substring(1, rawValue.length() - 1); 720 721 // Unescape \" \\ and \; (common patterns seen in the wild) 722 // Order matters: unescape backslash-escape sequences, then leave other chars intact. 723 StringBuilder sb = new StringBuilder(inner.length()); 724 boolean escape = false; 725 726 for (int i = 0; i < inner.length(); i++) { 727 char c = inner.charAt(i); 728 if (escape) { 729 // Keep the escaped character literally (liberal in what we accept). 730 sb.append(c); 731 732 escape = false; 733 } else if (c == '\\') { 734 escape = true; 735 } else { 736 sb.append(c); 737 } 738 } 739 740 // If string ended with a dangling backslash, keep it literally 741 if (escape) 742 sb.append('\\'); 743 744 return sb.toString(); 745 } 746 747 return rawValue; 748 } 749 750 /** 751 * Normalizes a URL or path into a canonical request path and optionally performs percent-decoding on the path. 752 * <p> 753 * For example, {@code "https://www.soklet.com/ab%20c?one=two"} would be normalized to {@code "/ab c"}. 754 * <p> 755 * The {@code OPTIONS *} special case returns {@code "*"}. 756 * <p> 757 * Behavior: 758 * <ul> 759 * <li>If input starts with {@code http://} or {@code https://}, the path portion is extracted.</li> 760 * <li>Ensures the result begins with {@code '/'}.</li> 761 * <li>Removes any trailing {@code '/'} (except for the root path {@code '/'}).</li> 762 * <li>Safely normalizes path traversals, e.g. path {@code '/a/../b'} would be normalized to {@code '/b'}</li> 763 * <li>Strips any query string.</li> 764 * <li>Applies aggressive trimming of Unicode whitespace.</li> 765 * <li>Rejects malformed percent-encoding when decoding is enabled.</li> 766 * </ul> 767 * 768 * @param url a URL or path to normalize 769 * @param performDecoding {@code true} if decoding should be performed on the path (e.g. replace {@code %20} with a space character), {@code false} otherwise 770 * @return the normalized path, {@code "/"} for empty input 771 */ 772 @NonNull 773 public static String extractPathFromUrl(@NonNull String url, 774 @NonNull Boolean performDecoding) { 775 requireNonNull(url); 776 777 url = trimAggressivelyToEmpty(url); 778 779 // Special case for OPTIONS * requests 780 if (url.equals("*")) 781 return "*"; 782 783 // Parse with java.net.URI to isolate raw path; then percent-decode only the path 784 try { 785 URI uri = new URI(url); 786 787 String rawPath = uri.getRawPath(); // null => "/" 788 789 if (rawPath == null || rawPath.isEmpty()) 790 rawPath = "/"; 791 792 if (!performDecoding) 793 return rawPath; 794 795 String decodedPath = percentDecode(rawPath, StandardCharsets.UTF_8); 796 797 // Sanitize path traversal (e.g. /a/../b -> /b) 798 decodedPath = removeDotSegments(decodedPath); 799 800 // Normalize trailing slashes like normalizedPathForUrl currently does 801 if (!decodedPath.startsWith("/")) 802 decodedPath = "/" + decodedPath; 803 804 if (!"/".equals(decodedPath)) 805 while (decodedPath.endsWith("/")) 806 decodedPath = decodedPath.substring(0, decodedPath.length() - 1); 807 808 return decodedPath; 809 } catch (URISyntaxException e) { 810 // If it's not an absolute URL, treat the whole string as a path and percent-decode 811 String path = url; 812 int q = path.indexOf('?'); 813 814 if (q != -1) 815 path = path.substring(0, q); 816 817 if (path.isEmpty()) 818 path = "/"; 819 820 if (!performDecoding) 821 return path; 822 823 String decodedPath = percentDecode(path, StandardCharsets.UTF_8); 824 825 // Sanitize path traversal (e.g. /a/../b -> /b) 826 decodedPath = removeDotSegments(decodedPath); 827 828 if (!decodedPath.startsWith("/")) 829 decodedPath = "/" + decodedPath; 830 831 if (!"/".equals(decodedPath)) 832 while (decodedPath.endsWith("/")) 833 decodedPath = decodedPath.substring(0, decodedPath.length() - 1); 834 835 return decodedPath; 836 } 837 } 838 839 /** 840 * Extracts the raw (un-decoded) query component from a URL. 841 * <p> 842 * For example, {@code "/path?a=b&c=d%20e"} would return {@code "a=b&c=d%20e"}. 843 * 844 * @param url a raw URL or path 845 * @return the raw query component, or {@link Optional#empty()} if none 846 */ 847 @NonNull 848 public static Optional<String> extractRawQueryFromUrl(@NonNull String url) { 849 requireNonNull(url); 850 851 url = trimAggressivelyToEmpty(url); 852 853 if ("*".equals(url)) 854 return Optional.empty(); 855 856 try { 857 URI uri = new URI(url); 858 return Optional.ofNullable(trimAggressivelyToNull(uri.getRawQuery())); 859 } catch (URISyntaxException e) { 860 // Not a valid URI, try to extract query manually 861 int q = url.indexOf('?'); 862 if (q == -1) 863 return Optional.empty(); 864 865 String query = trimAggressivelyToNull(url.substring(q + 1)); 866 return Optional.ofNullable(query); 867 } 868 } 869 870 @NonNull 871 static Optional<String> extractRawQueryFromUrlStrict(@NonNull String url) { 872 requireNonNull(url); 873 874 url = trimAggressivelyToEmpty(url); 875 876 if ("*".equals(url)) 877 return Optional.empty(); 878 879 try { 880 URI uri = new URI(url); 881 return Optional.ofNullable(trimAggressivelyToNull(uri.getRawQuery())); 882 } catch (URISyntaxException e) { 883 throw new IllegalRequestException(format("Invalid URL '%s'", url), e); 884 } 885 } 886 887 /** 888 * Encodes decoded query parameters into a raw query string. 889 * <p> 890 * For example, given {@code {a=[b], c=[d e]}} and {@link QueryFormat#RFC_3986_STRICT}, 891 * returns {@code "a=b&c=d%20e"}. 892 * 893 * @param queryParameters the decoded query parameters 894 * @param queryFormat the encoding strategy 895 * @return the encoded query string, or the empty string if no parameters 896 */ 897 @NonNull 898 public static String encodeQueryParameters(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> queryParameters, 899 @NonNull QueryFormat queryFormat) { 900 requireNonNull(queryParameters); 901 requireNonNull(queryFormat); 902 903 if (queryParameters.isEmpty()) 904 return ""; 905 906 StringBuilder sb = new StringBuilder(); 907 boolean first = true; 908 909 for (Entry<String, Set<String>> entry : queryParameters.entrySet()) { 910 String encodedName = encodeQueryComponent(entry.getKey(), queryFormat); 911 912 for (String value : entry.getValue()) { 913 if (!first) 914 sb.append('&'); 915 916 sb.append(encodedName); 917 sb.append('='); 918 sb.append(encodeQueryComponent(value, queryFormat)); 919 920 first = false; 921 } 922 } 923 924 return sb.toString(); 925 } 926 927 @NonNull 928 static String encodeQueryComponent(@NonNull String queryComponent, 929 @NonNull QueryFormat queryFormat) { 930 requireNonNull(queryComponent); 931 requireNonNull(queryFormat); 932 933 String encoded = URLEncoder.encode(queryComponent, StandardCharsets.UTF_8); 934 935 if (queryFormat == QueryFormat.RFC_3986_STRICT) 936 encoded = encoded.replace("+", "%20"); 937 938 return encoded; 939 } 940 941 @NonNull 942 static String encodePath(@NonNull String path) { 943 requireNonNull(path); 944 945 if ("*".equals(path)) 946 return path; 947 948 // Encode each path segment individually, preserving '/' separators. 949 // RFC 3986 is used for path encoding (spaces as %20, not +). 950 return Arrays.stream(path.split("/", -1)) 951 .map(segment -> URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20")) 952 .collect(Collectors.joining("/")); 953 } 954 955 /** 956 * Parses an {@code Accept-Language} header value into a best-effort ordered list of {@link Locale}s. 957 * <p> 958 * Quality weights are honored by {@link Locale.LanguageRange#parse(String)}; results are then converted to 959 * {@link Locale} instances that represent the client-supplied language tags. Wildcard ranges are ignored unless 960 * they include a language component (e.g. {@code en-*} becomes {@code en}). On parse failure, an empty list is 961 * returned. 962 * 963 * @param acceptLanguageHeaderValue the raw header value (must be non-{@code null}) 964 * @return locales in descending preference order; empty if none could be resolved 965 */ 966 @NonNull 967 public static List<@NonNull Locale> extractLocalesFromAcceptLanguageHeaderValue(@NonNull String acceptLanguageHeaderValue) { 968 requireNonNull(acceptLanguageHeaderValue); 969 970 try { 971 List<LanguageRange> languageRanges = LanguageRange.parse(acceptLanguageHeaderValue); 972 List<Locale> locales = new ArrayList<>(languageRanges.size()); 973 974 for (LanguageRange languageRange : languageRanges) { 975 if (!(languageRange.getWeight() > 0.0)) 976 continue; 977 978 String range = languageRange.getRange(); 979 String languageTag = range; 980 981 if (range.indexOf('*') != -1) { 982 int wildcardIndex = range.indexOf('*'); 983 984 if (wildcardIndex == 0) 985 continue; 986 987 int languageEndIndex = range.indexOf('-'); 988 989 if (languageEndIndex == -1 || languageEndIndex > wildcardIndex) 990 languageEndIndex = wildcardIndex; 991 992 languageTag = range.substring(0, languageEndIndex); 993 } 994 995 if (languageTag.isBlank()) 996 continue; 997 998 Locale locale = Locale.forLanguageTag(languageTag); 999 1000 if (!locale.getLanguage().isBlank() && !locales.contains(locale)) 1001 locales.add(locale); 1002 } 1003 1004 return Collections.unmodifiableList(locales); 1005 } catch (Exception ignored) { 1006 return List.of(); 1007 } 1008 } 1009 1010 /** 1011 * Parses an {@code Accept} header value into a best-effort ordered list of {@link MediaRange}s. 1012 * <p> 1013 * Media ranges are ordered by descending {@code q} weight, then by descending specificity: 1014 * a concrete {@code type/subtype} outranks {@code type/*}, which outranks {@code *}{@code /*}; 1015 * within the same wildcard specificity, ranges with more media-type parameters outrank ranges with fewer. 1016 * When both are equal, original header order is preserved (the sort is stable). See 1017 * <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.1">RFC 9110, Section 12.5.1</a>. 1018 * Malformed media ranges are skipped. 1019 * 1020 * @param acceptHeaderValue the raw header value (must be non-{@code null}) 1021 * @return media ranges in descending preference order; empty if none could be resolved 1022 */ 1023 @NonNull 1024 public static List<@NonNull MediaRange> extractMediaRangesFromAcceptHeaderValue(@NonNull String acceptHeaderValue) { 1025 requireNonNull(acceptHeaderValue); 1026 1027 List<MediaRange> mediaRanges = new ArrayList<>(4); 1028 1029 for (String fragment : splitCommaAware(acceptHeaderValue)) { 1030 MediaRange mediaRange = MediaRange.fromHeaderRepresentation(fragment).orElse(null); 1031 1032 if (mediaRange != null) 1033 mediaRanges.add(mediaRange); 1034 } 1035 1036 // Stable sort: q weight descending, then specificity descending; original order breaks ties 1037 mediaRanges.sort(Comparator 1038 .comparing(MediaRange::getQuality, Comparator.reverseOrder()) 1039 .thenComparing(Utilities::mediaRangeWildcardSpecificity, Comparator.reverseOrder()) 1040 .thenComparing(Utilities::mediaRangeParameterSpecificity, Comparator.reverseOrder())); 1041 1042 return Collections.unmodifiableList(mediaRanges); 1043 } 1044 1045 @NonNull 1046 private static Integer mediaRangeWildcardSpecificity(@NonNull MediaRange mediaRange) { 1047 requireNonNull(mediaRange); 1048 1049 if (mediaRange.isWildcardType()) 1050 return 0; 1051 1052 if (mediaRange.isWildcardSubtype()) 1053 return 1; 1054 1055 return 2; 1056 } 1057 1058 @NonNull 1059 private static Integer mediaRangeParameterSpecificity(@NonNull MediaRange mediaRange) { 1060 requireNonNull(mediaRange); 1061 return mediaRange.getParameters().size(); 1062 } 1063 1064 @Nullable 1065 private static String firstHeaderValue(@Nullable Set<String> headerValues) { 1066 if (headerValues == null || headerValues.isEmpty()) 1067 return null; 1068 1069 for (String value : headerValues) { 1070 String trimmed = trimAggressivelyToNull(value); 1071 if (trimmed == null) 1072 continue; 1073 1074 for (String part : splitCommaAware(trimmed)) { 1075 String candidate = trimAggressivelyToNull(part); 1076 if (candidate != null) 1077 return candidate; 1078 } 1079 } 1080 1081 return null; 1082 } 1083 1084 /** 1085 * Best-effort attempt to determine a client's effective origin by examining request headers. 1086 * <p> 1087 * An effective origin in this context is defined as {@code <scheme>://host<:optional port>}, but no path or query components. 1088 * <p> 1089 * Soklet is generally the "last hop" behind a load balancer/reverse proxy but may also be accessed directly by clients. 1090 * <p> 1091 * Normally a load balancer/reverse proxy/other upstream proxies will provide information about the true source of the 1092 * request through headers like the following: 1093 * <ul> 1094 * <li>{@code Host}</li> 1095 * <li>{@code Forwarded}</li> 1096 * <li>{@code Origin}</li> 1097 * <li>{@code X-Forwarded-Proto}</li> 1098 * <li>{@code X-Forwarded-Protocol}</li> 1099 * <li>{@code X-Url-Scheme}</li> 1100 * <li>{@code Front-End-Https}</li> 1101 * <li>{@code X-Forwarded-Ssl}</li> 1102 * <li>{@code X-Forwarded-Host}</li> 1103 * <li>{@code X-Forwarded-Port}</li> 1104 * </ul> 1105 * <p> 1106 * This method may take these and other headers into account when determining an effective origin. 1107 * <p> 1108 * For example, the following would be legal effective origins returned from this method: 1109 * <ul> 1110 * <li>{@code https://www.soklet.com}</li> 1111 * <li>{@code http://www.fake.com:1234}</li> 1112 * </ul> 1113 * <p> 1114 * The following would NOT be legal effective origins: 1115 * <ul> 1116 * <li>{@code www.soklet.com} (missing protocol) </li> 1117 * <li>{@code https://www.soklet.com/} (trailing slash)</li> 1118 * <li>{@code https://www.soklet.com/test} (trailing slash, path)</li> 1119 * <li>{@code https://www.soklet.com/test?abc=1234} (trailing slash, path, query)</li> 1120 * </ul> 1121 * <p> 1122 * {@code Origin} is treated as a fallback signal only and will not override a conflicting {@code Host} or forwarded host value. 1123 * <p> 1124 * Forwarded headers are only used when permitted by {@link EffectiveOriginResolver.TrustPolicy}. When using 1125 * {@link EffectiveOriginResolver.TrustPolicy#TRUST_PROXY_ALLOWLIST}, you must provide a trusted proxy predicate or allowlist. 1126 * If the remote address is missing or not trusted, forwarded headers are ignored. 1127 * <p> 1128 * Extraction order is: trusted forwarded headers → {@code Host} → (optional) {@code Origin} fallback. 1129 * If {@link EffectiveOriginResolver#allowOriginFallback(Boolean)} is unset, {@code Origin} fallback is enabled only for 1130 * {@link EffectiveOriginResolver.TrustPolicy#TRUST_ALL}. 1131 * 1132 * @param effectiveOriginResolver request headers and trust settings 1133 * @return the effective origin, or {@link Optional#empty()} if it could not be determined 1134 */ 1135 @NonNull 1136 static Optional<String> extractEffectiveOrigin(@NonNull EffectiveOriginResolver effectiveOriginResolver) { 1137 requireNonNull(effectiveOriginResolver); 1138 requireNonNull(effectiveOriginResolver.getHeaders()); 1139 requireNonNull(effectiveOriginResolver.getTrustPolicy()); 1140 1141 if (effectiveOriginResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_PROXY_ALLOWLIST 1142 && effectiveOriginResolver.getTrustedProxyPredicate() == null) { 1143 throw new IllegalStateException(format("%s policy requires a trusted proxy predicate or allowlist.", 1144 EffectiveOriginResolver.TrustPolicy.TRUST_PROXY_ALLOWLIST)); 1145 } 1146 1147 Map<String, Set<String>> headers = effectiveOriginResolver.getHeaders(); 1148 boolean trustForwardedHeaders = shouldTrustForwardedHeaders(effectiveOriginResolver); 1149 boolean allowOriginFallback = effectiveOriginResolver.getAllowOriginFallback() != null 1150 ? effectiveOriginResolver.getAllowOriginFallback() 1151 : effectiveOriginResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_ALL; 1152 1153 // Host developer.mozilla.org OR developer.mozilla.org:443 OR [2001:db8::1]:8443 1154 // Forwarded by=<identifier>;for=<identifier>;host=<host>;proto=<http|https> (can be repeated if comma-separated, e.g. for=12.34.56.78;host=example.com;proto=https, for=23.45.67.89) 1155 // Origin null OR <scheme>://<hostname> OR <scheme>://<hostname>:<port> 1156 // X-Forwarded-Proto https 1157 // X-Forwarded-Protocol https (Microsoft's alternate name) 1158 // X-Url-Scheme https (Microsoft's alternate name) 1159 // Front-End-Https on (Microsoft's alternate name) 1160 // X-Forwarded-Ssl on (Microsoft's alternate name) 1161 // X-Forwarded-Host id42.example-cdn.com 1162 // X-Forwarded-Port 443 1163 1164 String protocol = null; 1165 String host = null; 1166 String portAsString = null; 1167 Boolean portExplicit = false; 1168 1169 // Forwarded: by=<identifier>;for=<identifier>;host=<host>;proto=<http|https> 1170 if (trustForwardedHeaders) { 1171 Set<String> forwardedHeaders = headers.get("Forwarded"); 1172 if (forwardedHeaders != null) { 1173 forwardedHeaderLoop: 1174 for (String forwardedHeader : forwardedHeaders) { 1175 String trimmed = trimAggressivelyToNull(forwardedHeader); 1176 if (trimmed == null) 1177 continue; 1178 1179 for (String forwardedEntry : splitCommaAware(trimmed)) { 1180 String entry = trimAggressivelyToNull(forwardedEntry); 1181 if (entry == null) 1182 continue; 1183 1184 String entryHost = null; 1185 String entryProtocol = null; 1186 String entryPortAsString = null; 1187 Boolean entryPortExplicit = false; 1188 1189 // Each field component might look like "by=<identifier>" 1190 List<String> forwardedHeaderFieldComponents = splitSemicolonAware(entry); 1191 for (String forwardedHeaderFieldComponent : forwardedHeaderFieldComponents) { 1192 forwardedHeaderFieldComponent = trimAggressivelyToNull(forwardedHeaderFieldComponent); 1193 if (forwardedHeaderFieldComponent == null) 1194 continue; 1195 1196 // Break "by=<identifier>" into "by" and "<identifier>" pieces 1197 String[] forwardedHeaderFieldNameAndValue = forwardedHeaderFieldComponent.split(Pattern.quote("=" /* escape special Regex char */), 2); 1198 if (forwardedHeaderFieldNameAndValue.length != 2) 1199 continue; 1200 1201 String name = trimAggressivelyToNull(forwardedHeaderFieldNameAndValue[0]); 1202 String value = trimAggressivelyToNull(forwardedHeaderFieldNameAndValue[1]); 1203 if (name == null || value == null) 1204 continue; 1205 1206 if ("host".equalsIgnoreCase(name)) { 1207 if (entryHost == null) { 1208 HostPort hostPort = parseForwardedHostPort(value).orElse(null); 1209 1210 if (hostPort != null) { 1211 entryHost = hostPort.getHost(); 1212 1213 if (hostPort.getPort().isPresent()) { 1214 entryPortAsString = String.valueOf(hostPort.getPort().get()); 1215 entryPortExplicit = true; 1216 } 1217 } 1218 } 1219 } else if ("proto".equalsIgnoreCase(name)) { 1220 if (entryProtocol == null) 1221 entryProtocol = stripOptionalQuotes(value); 1222 } 1223 } 1224 1225 if (entryHost != null || entryProtocol != null) { 1226 host = entryHost; 1227 protocol = entryProtocol; 1228 if (entryPortAsString != null) { 1229 portAsString = entryPortAsString; 1230 portExplicit = entryPortExplicit; 1231 } 1232 break forwardedHeaderLoop; 1233 } 1234 } 1235 } 1236 } 1237 } 1238 1239 // X-Forwarded-Proto: https 1240 if (trustForwardedHeaders && protocol == null) { 1241 String xForwardedProtoHeader = firstHeaderValue(headers.get("X-Forwarded-Proto")); 1242 if (xForwardedProtoHeader != null) 1243 protocol = stripOptionalQuotes(xForwardedProtoHeader); 1244 } 1245 1246 // X-Forwarded-Protocol: https (Microsoft's alternate name) 1247 if (trustForwardedHeaders && protocol == null) { 1248 String xForwardedProtocolHeader = firstHeaderValue(headers.get("X-Forwarded-Protocol")); 1249 if (xForwardedProtocolHeader != null) 1250 protocol = stripOptionalQuotes(xForwardedProtocolHeader); 1251 } 1252 1253 // X-Url-Scheme: https (Microsoft's alternate name) 1254 if (trustForwardedHeaders && protocol == null) { 1255 String xUrlSchemeHeader = firstHeaderValue(headers.get("X-Url-Scheme")); 1256 if (xUrlSchemeHeader != null) 1257 protocol = stripOptionalQuotes(xUrlSchemeHeader); 1258 } 1259 1260 // Front-End-Https: on (Microsoft's alternate name) 1261 if (trustForwardedHeaders && protocol == null) { 1262 String frontEndHttpsHeader = firstHeaderValue(headers.get("Front-End-Https")); 1263 if (frontEndHttpsHeader != null) 1264 protocol = "on".equalsIgnoreCase(frontEndHttpsHeader) ? "https" : "http"; 1265 } 1266 1267 // X-Forwarded-Ssl: on (Microsoft's alternate name) 1268 if (trustForwardedHeaders && protocol == null) { 1269 String xForwardedSslHeader = firstHeaderValue(headers.get("X-Forwarded-Ssl")); 1270 if (xForwardedSslHeader != null) 1271 protocol = "on".equalsIgnoreCase(xForwardedSslHeader) ? "https" : "http"; 1272 } 1273 1274 // X-Forwarded-Host: id42.example-cdn.com (or with port / IPv6) 1275 if (trustForwardedHeaders && host == null) { 1276 String xForwardedHostHeader = firstHeaderValue(headers.get("X-Forwarded-Host")); 1277 if (xForwardedHostHeader != null) { 1278 HostPort hostPort = parseForwardedHostPort(xForwardedHostHeader).orElse(null); 1279 1280 if (hostPort != null) { 1281 host = hostPort.getHost(); 1282 1283 if (hostPort.getPort().isPresent() && portAsString == null) { 1284 portAsString = String.valueOf(hostPort.getPort().get()); 1285 portExplicit = true; 1286 } 1287 } 1288 } 1289 } 1290 1291 // X-Forwarded-Port: 443 1292 if (trustForwardedHeaders && portAsString == null) { 1293 String xForwardedPortHeader = firstHeaderValue(headers.get("X-Forwarded-Port")); 1294 if (xForwardedPortHeader != null) { 1295 portAsString = stripOptionalQuotes(xForwardedPortHeader); 1296 portExplicit = true; 1297 } 1298 } 1299 1300 // Host: developer.mozilla.org OR developer.mozilla.org:443 OR [2001:db8::1]:8443 1301 if (host == null) { 1302 String hostHeader = firstHeaderValue(headers.get("Host")); 1303 1304 if (hostHeader != null) { 1305 HostPort hostPort = parseHostPort(hostHeader).orElse(null); 1306 1307 if (hostPort != null) { 1308 host = hostPort.getHost(); 1309 1310 if (hostPort.getPort().isPresent() && portAsString == null) { 1311 portAsString = String.valueOf(hostPort.getPort().get()); 1312 portExplicit = true; 1313 } 1314 } 1315 } 1316 } 1317 1318 // Origin: null OR <scheme>://<hostname> OR <scheme>://<hostname>:<port> (IPv6 supported) 1319 // Use Origin only when host is missing or when it matches the Host-derived value. 1320 if (allowOriginFallback && (protocol == null || host == null || portAsString == null)) { 1321 String originHeader = firstHeaderValue(headers.get("Origin")); 1322 1323 if (originHeader != null) { 1324 try { 1325 URI o = new URI(originHeader); 1326 String originProtocol = trimAggressivelyToNull(o.getScheme()); 1327 String originHost = o.getHost(); // may be bracketed already on some JDKs 1328 int originPort = o.getPort(); // -1 if absent 1329 1330 if (originHost != null) { 1331 boolean alreadyBracketed = originHost.startsWith("[") && originHost.endsWith("]"); 1332 boolean isIpv6Like = originHost.indexOf(':') >= 0; // contains colon(s) 1333 originHost = (isIpv6Like && !alreadyBracketed) ? "[" + originHost + "]" : originHost; 1334 } 1335 1336 boolean hostMatchesOrigin = host != null && originHost != null && host.equalsIgnoreCase(originHost); 1337 1338 if (host == null) { 1339 if (originHost != null) 1340 host = originHost; 1341 if (originProtocol != null) 1342 protocol = originProtocol; 1343 if (originPort >= 0) { 1344 portAsString = String.valueOf(originPort); 1345 portExplicit = true; 1346 } 1347 } else if (hostMatchesOrigin) { 1348 if (protocol == null && originProtocol != null) 1349 protocol = originProtocol; 1350 if (portAsString == null && originPort >= 0) { 1351 portAsString = String.valueOf(originPort); 1352 portExplicit = true; 1353 } 1354 } 1355 } catch (URISyntaxException ignored) { 1356 // no-op 1357 } 1358 } 1359 } 1360 1361 Integer port = null; 1362 1363 if (portAsString != null) { 1364 try { 1365 int parsedPort = Integer.parseInt(portAsString, 10); 1366 if (parsedPort >= 1 && parsedPort <= 65535) 1367 port = parsedPort; 1368 } catch (Exception ignored) { 1369 // Not an integer; ignore it 1370 } 1371 } 1372 1373 if (protocol != null && host != null && port == null) { 1374 return Optional.of(format("%s://%s", protocol, host)); 1375 } 1376 1377 if (protocol != null && host != null && port != null) { 1378 boolean usingDefaultPort = 1379 ("http".equalsIgnoreCase(protocol) && port.equals(80)) || 1380 ("https".equalsIgnoreCase(protocol) && port.equals(443)); 1381 1382 // Keep default ports if the client/proxy explicitly sent them 1383 String effectiveOrigin = (usingDefaultPort && !portExplicit) 1384 ? format("%s://%s", protocol, host) 1385 : format("%s://%s:%s", protocol, host, port); 1386 1387 return Optional.of(effectiveOrigin); 1388 } 1389 1390 return Optional.empty(); 1391 } 1392 1393 /** 1394 * Best-effort attempt to determine a client's effective IP address by examining request headers. 1395 * <p> 1396 * The socket peer is always used as the fallback. Forwarded headers are only used when permitted by 1397 * {@link EffectiveOriginResolver.TrustPolicy}. When using 1398 * {@link EffectiveOriginResolver.TrustPolicy#TRUST_PROXY_ALLOWLIST}, you must provide a trusted proxy predicate or allowlist. 1399 * If the remote address is missing or not trusted, forwarded headers are ignored. 1400 * 1401 * @param effectiveClientIpResolver request headers and trust settings 1402 * @return the effective client IP address, or {@link Optional#empty()} if it could not be determined 1403 */ 1404 @NonNull 1405 static Optional<InetAddress> extractEffectiveClientIp(@NonNull EffectiveClientIpResolver effectiveClientIpResolver) { 1406 requireNonNull(effectiveClientIpResolver); 1407 requireNonNull(effectiveClientIpResolver.getHeaders()); 1408 requireNonNull(effectiveClientIpResolver.getTrustPolicy()); 1409 1410 if (effectiveClientIpResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_PROXY_ALLOWLIST 1411 && effectiveClientIpResolver.getTrustedProxyPredicate() == null) { 1412 throw new IllegalStateException(format("%s policy requires a trusted proxy predicate or allowlist.", 1413 EffectiveOriginResolver.TrustPolicy.TRUST_PROXY_ALLOWLIST)); 1414 } 1415 1416 InetSocketAddress remoteAddress = effectiveClientIpResolver.getRemoteAddress(); 1417 InetAddress remoteInetAddress = remoteAddress == null ? null : remoteAddress.getAddress(); 1418 1419 if (!shouldTrustForwardedHeaders(effectiveClientIpResolver)) 1420 return Optional.ofNullable(remoteInetAddress); 1421 1422 List<InetAddress> forwardedForAddresses = forwardedForAddresses(effectiveClientIpResolver.getHeaders()); 1423 Optional<InetAddress> effectiveClientIp = effectiveClientIpResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_ALL 1424 ? leftmostAddress(forwardedForAddresses) 1425 : firstUntrustedAddressFromRight(forwardedForAddresses, effectiveClientIpResolver.getTrustedProxyPredicate()); 1426 1427 if (effectiveClientIp.isPresent()) 1428 return effectiveClientIp; 1429 1430 List<InetAddress> xForwardedForAddresses = xForwardedForAddresses(effectiveClientIpResolver.getHeaders()); 1431 effectiveClientIp = effectiveClientIpResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_ALL 1432 ? leftmostAddress(xForwardedForAddresses) 1433 : firstUntrustedAddressFromRight(xForwardedForAddresses, effectiveClientIpResolver.getTrustedProxyPredicate()); 1434 1435 if (effectiveClientIp.isPresent()) 1436 return effectiveClientIp; 1437 1438 return Optional.ofNullable(remoteInetAddress); 1439 } 1440 1441 private static boolean shouldTrustForwardedHeaders(@NonNull EffectiveOriginResolver effectiveOriginResolver) { 1442 if (effectiveOriginResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_ALL) 1443 return true; 1444 1445 if (effectiveOriginResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_NONE) 1446 return false; 1447 1448 var remoteAddress = effectiveOriginResolver.getRemoteAddress(); 1449 var trustedProxyPredicate = effectiveOriginResolver.getTrustedProxyPredicate(); 1450 1451 if (remoteAddress == null || trustedProxyPredicate == null) 1452 return false; 1453 1454 return trustedProxyPredicate.test(remoteAddress); 1455 } 1456 1457 private static boolean shouldTrustForwardedHeaders(@NonNull EffectiveClientIpResolver effectiveClientIpResolver) { 1458 if (effectiveClientIpResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_ALL) 1459 return true; 1460 1461 if (effectiveClientIpResolver.getTrustPolicy() == EffectiveOriginResolver.TrustPolicy.TRUST_NONE) 1462 return false; 1463 1464 var remoteAddress = effectiveClientIpResolver.getRemoteAddress(); 1465 var trustedProxyPredicate = effectiveClientIpResolver.getTrustedProxyPredicate(); 1466 1467 if (remoteAddress == null || trustedProxyPredicate == null) 1468 return false; 1469 1470 return trustedProxyPredicate.test(remoteAddress); 1471 } 1472 1473 @NonNull 1474 private static Optional<InetAddress> leftmostAddress(@NonNull List<@NonNull InetAddress> addresses) { 1475 requireNonNull(addresses); 1476 return addresses.isEmpty() ? Optional.empty() : Optional.of(addresses.get(0)); 1477 } 1478 1479 @NonNull 1480 private static Optional<InetAddress> firstUntrustedAddressFromRight(@NonNull List<@NonNull InetAddress> addresses, 1481 @Nullable Predicate<InetSocketAddress> trustedProxyPredicate) { 1482 requireNonNull(addresses); 1483 1484 if (trustedProxyPredicate == null) 1485 return Optional.empty(); 1486 1487 InetAddress leftmostAddress = null; 1488 1489 for (int i = addresses.size() - 1; i >= 0; i--) { 1490 InetAddress address = addresses.get(i); 1491 leftmostAddress = address; 1492 1493 if (!trustedProxyPredicate.test(new InetSocketAddress(address, 0))) 1494 return Optional.of(address); 1495 } 1496 1497 return Optional.ofNullable(leftmostAddress); 1498 } 1499 1500 @NonNull 1501 private static List<@NonNull InetAddress> forwardedForAddresses(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) { 1502 requireNonNull(headers); 1503 Set<String> forwardedHeaders = headers.get("Forwarded"); 1504 1505 if (forwardedHeaders == null || forwardedHeaders.isEmpty()) 1506 return List.of(); 1507 1508 List<InetAddress> addresses = new ArrayList<>(); 1509 1510 for (String forwardedHeader : forwardedHeaders) { 1511 String trimmed = trimAggressivelyToNull(forwardedHeader); 1512 if (trimmed == null) 1513 continue; 1514 1515 for (String forwardedEntry : splitCommaAware(trimmed)) { 1516 String entry = trimAggressivelyToNull(forwardedEntry); 1517 if (entry == null) 1518 continue; 1519 1520 for (String forwardedHeaderFieldComponent : splitSemicolonAware(entry)) { 1521 forwardedHeaderFieldComponent = trimAggressivelyToNull(forwardedHeaderFieldComponent); 1522 if (forwardedHeaderFieldComponent == null) 1523 continue; 1524 1525 String[] forwardedHeaderFieldNameAndValue = forwardedHeaderFieldComponent.split(Pattern.quote("="), 2); 1526 if (forwardedHeaderFieldNameAndValue.length != 2) 1527 continue; 1528 1529 String name = trimAggressivelyToNull(forwardedHeaderFieldNameAndValue[0]); 1530 String value = trimAggressivelyToNull(forwardedHeaderFieldNameAndValue[1]); 1531 if (name == null || value == null || !"for".equalsIgnoreCase(name)) 1532 continue; 1533 1534 parseForwardedIpLiteral(value).ifPresent(addresses::add); 1535 break; 1536 } 1537 } 1538 } 1539 1540 return addresses.isEmpty() ? List.of() : Collections.unmodifiableList(addresses); 1541 } 1542 1543 @NonNull 1544 private static List<@NonNull InetAddress> xForwardedForAddresses(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) { 1545 requireNonNull(headers); 1546 Set<String> xForwardedForHeaders = headers.get("X-Forwarded-For"); 1547 1548 if (xForwardedForHeaders == null || xForwardedForHeaders.isEmpty()) 1549 return List.of(); 1550 1551 List<InetAddress> addresses = new ArrayList<>(); 1552 1553 for (String xForwardedForHeader : xForwardedForHeaders) { 1554 String trimmed = trimAggressivelyToNull(xForwardedForHeader); 1555 if (trimmed == null) 1556 continue; 1557 1558 for (String part : splitCommaAware(trimmed)) 1559 parseForwardedIpLiteral(part).ifPresent(addresses::add); 1560 } 1561 1562 return addresses.isEmpty() ? List.of() : Collections.unmodifiableList(addresses); 1563 } 1564 1565 @NonNull 1566 private static Optional<InetAddress> parseForwardedIpLiteral(@Nullable String value) { 1567 String trimmed = trimAggressivelyToNull(value); 1568 1569 if (trimmed == null) 1570 return Optional.empty(); 1571 1572 trimmed = trimAggressivelyToNull(stripOptionalQuotes(trimmed)); 1573 1574 if (trimmed == null || "unknown".equalsIgnoreCase(trimmed) || trimmed.startsWith("_")) 1575 return Optional.empty(); 1576 1577 if (trimmed.startsWith("[")) { 1578 int closeIndex = trimmed.indexOf(']'); 1579 1580 if (closeIndex <= 1) 1581 return Optional.empty(); 1582 1583 String suffix = trimmed.substring(closeIndex + 1); 1584 1585 if (!suffix.isEmpty()) { 1586 if (!suffix.startsWith(":") || !isValidPort(suffix.substring(1))) 1587 return Optional.empty(); 1588 } 1589 1590 return parseIpLiteral(trimmed.substring(1, closeIndex)); 1591 } 1592 1593 int colonCount = countOccurrences(trimmed, ':'); 1594 1595 if (colonCount == 1 && trimmed.contains(".")) { 1596 int colonIndex = trimmed.indexOf(':'); 1597 String addressPart = trimmed.substring(0, colonIndex); 1598 String portPart = trimmed.substring(colonIndex + 1); 1599 1600 if (isValidPort(portPart)) 1601 return parseIpv4Literal(addressPart); 1602 1603 return Optional.empty(); 1604 } 1605 1606 return parseIpLiteral(trimmed); 1607 } 1608 1609 @NonNull 1610 private static Optional<InetAddress> parseIpLiteral(@Nullable String value) { 1611 String trimmed = trimAggressivelyToNull(value); 1612 1613 if (trimmed == null) 1614 return Optional.empty(); 1615 1616 Optional<InetAddress> ipv4Address = parseIpv4Literal(trimmed); 1617 1618 if (ipv4Address.isPresent()) 1619 return ipv4Address; 1620 1621 return parseIpv6Literal(trimmed); 1622 } 1623 1624 @NonNull 1625 private static Optional<InetAddress> parseIpv4Literal(@Nullable String value) { 1626 String trimmed = trimAggressivelyToNull(value); 1627 1628 if (trimmed == null) 1629 return Optional.empty(); 1630 1631 String[] parts = trimmed.split(Pattern.quote("."), -1); 1632 1633 if (parts.length != 4) 1634 return Optional.empty(); 1635 1636 byte[] bytes = new byte[4]; 1637 1638 for (int i = 0; i < parts.length; i++) { 1639 String part = parts[i]; 1640 1641 if (part.isEmpty() || part.length() > 3) 1642 return Optional.empty(); 1643 1644 if (part.length() > 1 && part.startsWith("0")) 1645 return Optional.empty(); 1646 1647 for (int j = 0; j < part.length(); j++) { 1648 if (!Character.isDigit(part.charAt(j))) 1649 return Optional.empty(); 1650 } 1651 1652 int octet; 1653 try { 1654 octet = Integer.parseInt(part, 10); 1655 } catch (NumberFormatException e) { 1656 return Optional.empty(); 1657 } 1658 1659 if (octet < 0 || octet > 255) 1660 return Optional.empty(); 1661 1662 bytes[i] = (byte) octet; 1663 } 1664 1665 try { 1666 return Optional.of(InetAddress.getByAddress(bytes)); 1667 } catch (UnknownHostException e) { 1668 return Optional.empty(); 1669 } 1670 } 1671 1672 @NonNull 1673 private static Optional<InetAddress> parseIpv6Literal(@Nullable String value) { 1674 String trimmed = trimAggressivelyToNull(value); 1675 1676 if (trimmed == null || !trimmed.contains(":")) 1677 return Optional.empty(); 1678 1679 if (trimmed.indexOf('[') >= 0 || trimmed.indexOf(']') >= 0 || trimmed.indexOf('"') >= 0 || trimmed.indexOf('%') >= 0) 1680 return Optional.empty(); 1681 1682 try { 1683 return Optional.of(InetAddress.getByName(trimmed)); 1684 } catch (Exception e) { 1685 return Optional.empty(); 1686 } 1687 } 1688 1689 private static boolean isValidPort(@Nullable String value) { 1690 String trimmed = trimAggressivelyToNull(value); 1691 1692 if (trimmed == null) 1693 return false; 1694 1695 try { 1696 int port = Integer.parseInt(trimmed, 10); 1697 return port >= 1 && port <= 65535; 1698 } catch (NumberFormatException e) { 1699 return false; 1700 } 1701 } 1702 1703 private static int countOccurrences(@NonNull String value, 1704 char character) { 1705 requireNonNull(value); 1706 int count = 0; 1707 1708 for (int i = 0; i < value.length(); i++) { 1709 if (value.charAt(i) == character) 1710 count++; 1711 } 1712 1713 return count; 1714 } 1715 1716 /** 1717 * Extracts the media type (without parameters) from the first {@code Content-Type} header. 1718 * <p> 1719 * For example, {@code "text/html; charset=UTF-8"} → {@code "text/html"}. 1720 * 1721 * @param headers request/response headers (must be non-{@code null}) 1722 * @return the media type if present; otherwise {@link Optional#empty()} 1723 * @see #extractContentTypeFromHeaderValue(String) 1724 */ 1725 @NonNull 1726 public static Optional<String> extractContentTypeFromHeaders(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) { 1727 requireNonNull(headers); 1728 1729 Set<String> contentTypeHeaderValues = headers.get("Content-Type"); 1730 1731 if (contentTypeHeaderValues == null || contentTypeHeaderValues.size() == 0) 1732 return Optional.empty(); 1733 1734 return extractContentTypeFromHeaderValue(contentTypeHeaderValues.stream().findFirst().get()); 1735 } 1736 1737 /** 1738 * Extracts the media type (without parameters) from a {@code Content-Type} header value. 1739 * <p> 1740 * For example, {@code "application/json; charset=UTF-8"} → {@code "application/json"}. 1741 * 1742 * @param contentTypeHeaderValue the raw header value; may be {@code null} or blank 1743 * @return the media type if present; otherwise {@link Optional#empty()} 1744 */ 1745 @NonNull 1746 public static Optional<String> extractContentTypeFromHeaderValue(@Nullable String contentTypeHeaderValue) { 1747 contentTypeHeaderValue = trimAggressivelyToNull(contentTypeHeaderValue); 1748 1749 if (contentTypeHeaderValue == null) 1750 return Optional.empty(); 1751 1752 // Examples 1753 // Content-Type: text/html; charset=UTF-8 1754 // Content-Type: multipart/form-data; boundary=something 1755 1756 int indexOfSemicolon = contentTypeHeaderValue.indexOf(";"); 1757 1758 // Simple case, e.g. "text/html" 1759 if (indexOfSemicolon == -1) 1760 return Optional.ofNullable(trimAggressivelyToNull(contentTypeHeaderValue)); 1761 1762 // More complex case, e.g. "text/html; charset=UTF-8" 1763 return Optional.ofNullable(trimAggressivelyToNull(contentTypeHeaderValue.substring(0, indexOfSemicolon))); 1764 } 1765 1766 /** 1767 * Extracts the {@link Charset} from the first {@code Content-Type} header, if present and valid. 1768 * <p> 1769 * Tolerates additional parameters and arbitrary whitespace. Invalid or unknown charset tokens yield {@link Optional#empty()}. 1770 * 1771 * @param headers request/response headers (must be non-{@code null}) 1772 * @return the charset declared by the header; otherwise {@link Optional#empty()} 1773 * @see #extractCharsetFromHeaderValue(String) 1774 */ 1775 @NonNull 1776 public static Optional<Charset> extractCharsetFromHeaders(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers) { 1777 requireNonNull(headers); 1778 1779 Set<String> contentTypeHeaderValues = headers.get("Content-Type"); 1780 1781 if (contentTypeHeaderValues == null || contentTypeHeaderValues.size() == 0) 1782 return Optional.empty(); 1783 1784 return extractCharsetFromHeaderValue(contentTypeHeaderValues.stream().findFirst().get()); 1785 } 1786 1787 /** 1788 * Extracts the {@code charset=...} parameter from a {@code Content-Type} header value. 1789 * <p> 1790 * Parsing is forgiving: parameters may appear in any order and with arbitrary spacing. If a charset is found, 1791 * it is validated via {@link Charset#forName(String)}; invalid names result in {@link Optional#empty()}. 1792 * 1793 * @param contentTypeHeaderValue the raw header value; may be {@code null} or blank 1794 * @return the resolved charset if present and valid; otherwise {@link Optional#empty()} 1795 */ 1796 @NonNull 1797 public static Optional<Charset> extractCharsetFromHeaderValue(@Nullable String contentTypeHeaderValue) { 1798 contentTypeHeaderValue = trimAggressivelyToNull(contentTypeHeaderValue); 1799 1800 if (contentTypeHeaderValue == null) 1801 return Optional.empty(); 1802 1803 // Examples 1804 // Content-Type: text/html; charset=UTF-8 1805 // Content-Type: multipart/form-data; boundary=something 1806 1807 int indexOfSemicolon = contentTypeHeaderValue.indexOf(";"); 1808 1809 // Simple case, e.g. "text/html" 1810 if (indexOfSemicolon == -1) 1811 return Optional.empty(); 1812 1813 // More complex case, e.g. "text/html; charset=UTF-8" or "multipart/form-data; charset=UTF-8; boundary=something" 1814 boolean finishedContentType = false; 1815 boolean finishedCharsetName = false; 1816 StringBuilder buffer = new StringBuilder(); 1817 String charsetName = null; 1818 1819 for (int i = 0; i < contentTypeHeaderValue.length(); i++) { 1820 char c = contentTypeHeaderValue.charAt(i); 1821 1822 if (Character.isWhitespace(c)) 1823 continue; 1824 1825 if (c == ';') { 1826 // No content type yet? This just be it... 1827 if (!finishedContentType) { 1828 finishedContentType = true; 1829 buffer = new StringBuilder(); 1830 } else if (!finishedCharsetName) { 1831 if (buffer.indexOf("charset=") == 0) { 1832 charsetName = buffer.toString(); 1833 finishedCharsetName = true; 1834 break; 1835 } 1836 } 1837 } else { 1838 buffer.append(Character.toLowerCase(c)); 1839 } 1840 } 1841 1842 // Handle case where charset is the end of the string, e.g. "whatever;charset=UTF-8" 1843 if (!finishedCharsetName) { 1844 String potentialCharset = trimAggressivelyToNull(buffer.toString()); 1845 if (potentialCharset != null && potentialCharset.startsWith("charset=")) { 1846 finishedCharsetName = true; 1847 charsetName = potentialCharset; 1848 } 1849 } 1850 1851 if (finishedCharsetName) { 1852 String specifiedCharsetName = charsetName; 1853 if (specifiedCharsetName == null) 1854 return Optional.empty(); 1855 1856 // e.g. charset=UTF-8 or charset="UTF-8" or charset='UTF-8' 1857 String possibleCharsetName = trimAggressivelyToNull(specifiedCharsetName.replace("charset=", "")); 1858 1859 if (possibleCharsetName != null) { 1860 // strip optional surrounding quotes 1861 if ((possibleCharsetName.length() >= 2) && 1862 ((possibleCharsetName.charAt(0) == '"' && possibleCharsetName.charAt(possibleCharsetName.length() - 1) == '"') || 1863 (possibleCharsetName.charAt(0) == '\'' && possibleCharsetName.charAt(possibleCharsetName.length() - 1) == '\''))) { 1864 possibleCharsetName = possibleCharsetName.substring(1, possibleCharsetName.length() - 1); 1865 possibleCharsetName = trimAggressivelyToNull(possibleCharsetName); 1866 } 1867 1868 if (possibleCharsetName != null) { 1869 try { 1870 return Optional.of(Charset.forName(possibleCharsetName)); 1871 } catch (IllegalCharsetNameException | UnsupportedCharsetException ignored) { 1872 return Optional.empty(); 1873 } 1874 } 1875 } 1876 } 1877 1878 return Optional.empty(); 1879 } 1880 1881 /** 1882 * A "stronger" version of {@link String#trim()} which discards leading and trailing Unicode space-separator characters ({@code \p{Z}}). 1883 * <p> 1884 * In a web environment with user-supplied inputs, this is the behavior we want the vast majority of the time. 1885 * For example, users copy-paste URLs from Microsoft Word or Outlook and it's easy to accidentally include a {@code U+202F 1886 * "Narrow No-Break Space (NNBSP)"} character at the end, which might break parsing. 1887 * <p> 1888 * Note that this does not remove other whitespace characters such as tabs, carriage returns, or line feeds. 1889 * <p> 1890 * See <a href="https://www.compart.com/en/unicode/U+202F">https://www.compart.com/en/unicode/U+202F</a> for details. 1891 * 1892 * @param string the string to trim 1893 * @return the trimmed string, or {@code null} if the input string is {@code null} or the trimmed representation is of length {@code 0} 1894 */ 1895 @Nullable 1896 public static String trimAggressively(@Nullable String string) { 1897 if (string == null) 1898 return null; 1899 1900 string = HEAD_WHITESPACE_PATTERN.matcher(string).replaceAll(""); 1901 1902 if (string.length() == 0) 1903 return string; 1904 1905 string = TAIL_WHITESPACE_PATTERN.matcher(string).replaceAll(""); 1906 1907 return string; 1908 } 1909 1910 /** 1911 * Aggressively trims leading and trailing Unicode space-separator characters from the given string and returns {@code null} if the result is empty. 1912 * <p> 1913 * See {@link #trimAggressively(String)} for details on which code points are removed. 1914 * 1915 * @param string the input string; may be {@code null} 1916 * @return a trimmed, non-empty string; or {@code null} if input was {@code null} or trimmed to empty 1917 */ 1918 @Nullable 1919 public static String trimAggressivelyToNull(@Nullable String string) { 1920 if (string == null) 1921 return null; 1922 1923 string = trimAggressively(string); 1924 if (string == null) 1925 return null; 1926 return string.length() == 0 ? null : string; 1927 } 1928 1929 /** 1930 * Aggressively trims leading and trailing Unicode space-separator characters from the given string and returns {@code ""} if the input is {@code null}. 1931 * <p> 1932 * See {@link #trimAggressively(String)} for details on which code points are removed. 1933 * 1934 * @param string the input string; may be {@code null} 1935 * @return a trimmed string (never {@code null}); {@code ""} if input was {@code null} 1936 */ 1937 @NonNull 1938 public static String trimAggressivelyToEmpty(@Nullable String string) { 1939 if (string == null) 1940 return ""; 1941 1942 String trimmed = trimAggressively(string); 1943 return trimmed == null ? "" : trimmed; 1944 } 1945 1946 static void validateHeaderNameAndValue(@Nullable String name, 1947 @Nullable String value) { 1948 // First, validate name: 1949 name = trimAggressivelyToNull(name); 1950 1951 if (name == null) 1952 throw new IllegalArgumentException("Header name is blank"); 1953 1954 for (int i = 0; i < name.length(); i++) { 1955 char c = name.charAt(i); 1956 // RFC 9110 tchar: "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA 1957 if (c > 0x7F || !(c == '!' || c == '#' || c == '$' || c == '%' || c == '&' || c == '\'' || c == '*' || c == '+' || 1958 c == '-' || c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~' || 1959 Character.isLetterOrDigit(c))) { 1960 throw new IllegalArgumentException(format("Illegal header name '%s'. Offending character: '%s'", name, printableChar(c))); 1961 } 1962 } 1963 1964 // Then, validate value: 1965 if (value == null) 1966 return; 1967 1968 for (int i = 0; i < value.length(); i++) { 1969 char c = value.charAt(i); 1970 if (c == '\r' || c == '\n' || c == 0x00 || c > 0xFF || (c < 0x20 && c != '\t')) { 1971 throw new IllegalArgumentException(format("Illegal header value '%s' for header name '%s'. Offending character: '%s'", value, name, printableChar(c))); 1972 } 1973 } 1974 1975 // Percent-encoded control sequence checks 1976 Matcher m = HEADER_PERCENT_ENCODING_PATTERN.matcher(value); 1977 1978 while (m.find()) { 1979 int b = Integer.parseInt(m.group(1), 16); 1980 if (b == 0x0D || b == 0x0A || b == 0x00 || (b >= 0x00 && b < 0x20 && b != 0x09)) { 1981 throw new IllegalArgumentException(format( 1982 "Illegal (percent-encoded) header value '%s' for header name '%s'. Offending octet: 0x%02X", 1983 value, name, b)); 1984 } 1985 } 1986 } 1987 1988 @NonNull 1989 static String printableString(@NonNull String input) { 1990 requireNonNull(input); 1991 1992 StringBuilder out = new StringBuilder(input.length() + 16); 1993 1994 for (int i = 0; i < input.length(); i++) 1995 out.append(printableChar(input.charAt(i))); 1996 1997 return out.toString(); 1998 } 1999 2000 @NonNull 2001 static String printableChar(char c) { 2002 if (c == '\r') return "\\r"; 2003 if (c == '\n') return "\\n"; 2004 if (c == '\t') return "\\t"; 2005 if (c == '\f') return "\\f"; 2006 if (c == '\b') return "\\b"; 2007 if (c == '\\') return "\\\\"; 2008 if (c == '\'') return "\\'"; 2009 if (c == '\"') return "\\\""; 2010 if (c == 0) return "\\0"; 2011 2012 if (c < 0x20 || c == 0x7F) // control chars 2013 return String.format("\\u%04X", (int) c); 2014 2015 if (Character.isISOControl(c) || Character.getType(c) == Character.FORMAT) 2016 return String.format("\\u%04X", (int) c); 2017 2018 return String.valueOf(c); 2019 } 2020 2021 @NonNull 2022 private static final Set<String> COMMA_JOINABLE_HEADER_NAMES = Set.of( 2023 // Common list-type headers (RFC 7230/9110) 2024 "accept", 2025 "accept-encoding", 2026 "accept-language", 2027 "cache-control", 2028 "pragma", 2029 "vary", 2030 "connection", 2031 "transfer-encoding", 2032 "upgrade", 2033 "allow", 2034 "via", 2035 "warning" 2036 // intentionally NOT: set-cookie, authorization, cookie, content-disposition, location 2037 ); 2038 2039 /** 2040 * Given a list of raw HTTP header lines, convert them into a normalized case-insensitive, order-preserving map which "inflates" comma-separated headers into distinct values where permitted according to RFC 7230/9110. 2041 * <p> 2042 * For example, given these raw header lines: 2043 * <pre>{@code List<String> lines = List.of( 2044 * "Cache-Control: no-cache, no-store", 2045 * "Set-Cookie: a=b; Path=/; HttpOnly", 2046 * "Set-Cookie: c=d; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/" 2047 * );}</pre> 2048 * The result of parsing would look like this: 2049 * <pre>{@code result.get("cache-control") -> [ 2050 * "no-cache", 2051 * "no-store" 2052 * ] 2053 * result.get("set-cookie") -> [ 2054 * "a=b; Path=/; HttpOnly", 2055 * "c=d; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/" 2056 * ]}</pre> 2057 * <p> 2058 * Keys in the returned map are case-insensitive and are guaranteed to be in the same order as encountered in {@code rawHeaderLines}. 2059 * <p> 2060 * Values in the returned map are guaranteed to be in the same order as encountered in {@code rawHeaderLines}. 2061 * 2062 * @param rawHeaderLines the raw HTTP header lines to parse 2063 * @return a normalized mapping of header name keys to values 2064 */ 2065 @NonNull 2066 public static Map<@NonNull String, @NonNull Set<@NonNull String>> extractHeadersFromRawHeaderLines(@NonNull List<@NonNull String> rawHeaderLines) { 2067 requireNonNull(rawHeaderLines); 2068 2069 // 1) Unfold obsolete folded lines (obs-fold): lines beginning with SP/HT are continuations 2070 List<String> lines = unfold(rawHeaderLines); 2071 2072 // 2) Parse into map 2073 Map<String, Set<String>> headers = new LinkedCaseInsensitiveMap<>(); 2074 2075 for (String raw : lines) { 2076 String line = trimAggressivelyToNull(raw); 2077 2078 if (line == null) 2079 continue; 2080 2081 int idx = line.indexOf(':'); 2082 2083 if (idx <= 0) 2084 continue; // skip malformed 2085 2086 addParsedHeader(headers, line.substring(0, idx), line.substring(idx + 1)); 2087 } 2088 2089 freezeStringValueSets(headers); 2090 return headers; 2091 } 2092 2093 static void addParsedHeader(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> headers, 2094 @Nullable String name, 2095 @Nullable String value) { 2096 requireNonNull(headers); 2097 2098 String key = trimAggressivelyToEmpty(name); // keep original case for display 2099 String trimmedValue = trimAggressivelyToNull(value); 2100 if (trimmedValue == null) 2101 return; 2102 2103 if (COMMA_JOINABLE_HEADER_NAMES.contains(key.toLowerCase(Locale.ROOT))) { 2104 for (String part : splitCommaAware(trimmedValue)) { 2105 String v = trimAggressivelyToNull(part); 2106 if (v != null) 2107 addStringValue(headers, key, v); 2108 } 2109 } else { 2110 addStringValue(headers, key, trimmedValue.trim()); 2111 } 2112 } 2113 2114 static void addParsedHeaderValues(@NonNull Set<@NonNull String> values, 2115 @Nullable String name, 2116 @Nullable String value) { 2117 requireNonNull(values); 2118 2119 String key = trimAggressivelyToEmpty(name); 2120 String keyLowercase = key.toLowerCase(Locale.ROOT); 2121 value = trimAggressivelyToNull(value); 2122 2123 if (value == null) 2124 return; 2125 2126 if (COMMA_JOINABLE_HEADER_NAMES.contains(keyLowercase)) { 2127 for (String part : splitCommaAware(value)) { 2128 String v = trimAggressivelyToNull(part); 2129 if (v != null) 2130 values.add(v); 2131 } 2132 } else { 2133 values.add(value.trim()); 2134 } 2135 } 2136 2137 static void freezeStringValueSets(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> valuesByName) { 2138 requireNonNull(valuesByName); 2139 2140 for (Entry<String, Set<String>> entry : valuesByName.entrySet()) { 2141 Set<String> values = entry.getValue(); 2142 2143 if (values == null || values.isEmpty()) { 2144 entry.setValue(Set.of()); 2145 } else if (values instanceof LinkedHashSet) { 2146 entry.setValue(Collections.unmodifiableSet(values)); 2147 } 2148 } 2149 } 2150 2151 private static void addStringValue(@NonNull Map<@NonNull String, @NonNull Set<@NonNull String>> valuesByName, 2152 @NonNull String name, 2153 @NonNull String value) { 2154 requireNonNull(valuesByName); 2155 requireNonNull(name); 2156 requireNonNull(value); 2157 2158 Set<String> values = valuesByName.get(name); 2159 2160 if (values == null || values.isEmpty()) { 2161 valuesByName.put(name, Set.of(value)); 2162 return; 2163 } 2164 2165 if (values.contains(value)) 2166 return; 2167 2168 if (values instanceof LinkedHashSet) { 2169 values.add(value); 2170 return; 2171 } 2172 2173 Set<String> promotedValues = new LinkedHashSet<>(values); 2174 promotedValues.add(value); 2175 valuesByName.put(name, promotedValues); 2176 } 2177 2178 /** 2179 * Header parsing helper 2180 */ 2181 @NonNull 2182 private static List<String> unfold(@NonNull List<String> raw) { 2183 requireNonNull(raw); 2184 if (raw.isEmpty()) return List.of(); 2185 2186 List<String> out = new ArrayList<>(raw.size()); 2187 StringBuilder cur = null; 2188 boolean curIsHeader = false; 2189 2190 for (String line : raw) { 2191 if (line == null) continue; 2192 2193 boolean isContinuation = !line.isEmpty() && (line.charAt(0) == ' ' || line.charAt(0) == '\t'); 2194 if (isContinuation) { 2195 if (cur != null && curIsHeader) { 2196 cur.append(' ').append(line.trim()); 2197 } else { 2198 // Do not fold into a non-header; flush previous and start anew 2199 if (cur != null) out.add(cur.toString()); 2200 cur = new StringBuilder(line); 2201 curIsHeader = line.indexOf(':') > 0; // almost certainly false for leading-space lines 2202 } 2203 } else { 2204 if (cur != null) out.add(cur.toString()); 2205 cur = new StringBuilder(line); 2206 curIsHeader = line.indexOf(':') > 0; 2207 } 2208 } 2209 if (cur != null) out.add(cur.toString()); 2210 return out; 2211 } 2212 2213 /** 2214 * Header parsing helper: split on commas that are not inside a quoted-string; supports \" escapes inside quotes. 2215 */ 2216 @NonNull 2217 private static List<String> splitCommaAware(@NonNull String string) { 2218 requireNonNull(string); 2219 2220 List<String> out = new ArrayList<>(4); 2221 StringBuilder cur = new StringBuilder(); 2222 boolean inQuotes = false; 2223 boolean escaped = false; 2224 2225 for (int i = 0; i < string.length(); i++) { 2226 char c = string.charAt(i); 2227 2228 if (escaped) { 2229 // Preserve the escaped char as-is 2230 cur.append(c); 2231 escaped = false; 2232 } else if (c == '\\') { 2233 if (inQuotes) { 2234 // Preserve the backslash itself, then mark next char as escaped 2235 cur.append('\\'); // ← keep the backslash 2236 escaped = true; 2237 } else { 2238 cur.append('\\'); // literal backslash outside quotes 2239 } 2240 } else if (c == '"') { 2241 inQuotes = !inQuotes; 2242 cur.append('"'); 2243 } else if (c == ',' && !inQuotes) { 2244 out.add(cur.toString()); 2245 cur.setLength(0); 2246 } else { 2247 cur.append(c); 2248 } 2249 } 2250 out.add(cur.toString()); 2251 return out; 2252 } 2253 2254 /** 2255 * Header parsing helper: split on semicolons that are not inside a quoted-string; supports \" escapes inside quotes. 2256 */ 2257 @NonNull 2258 static List<String> splitSemicolonAware(@NonNull String string) { 2259 requireNonNull(string); 2260 2261 List<String> out = new ArrayList<>(4); 2262 StringBuilder cur = new StringBuilder(); 2263 boolean inQuotes = false; 2264 boolean escaped = false; 2265 2266 for (int i = 0; i < string.length(); i++) { 2267 char c = string.charAt(i); 2268 2269 if (escaped) { 2270 cur.append(c); 2271 escaped = false; 2272 } else if (c == '\\') { 2273 if (inQuotes) { 2274 cur.append('\\'); 2275 escaped = true; 2276 } else { 2277 cur.append('\\'); 2278 } 2279 } else if (c == '"') { 2280 inQuotes = !inQuotes; 2281 cur.append('"'); 2282 } else if (c == ';' && !inQuotes) { 2283 out.add(cur.toString()); 2284 cur.setLength(0); 2285 } else { 2286 cur.append(c); 2287 } 2288 } 2289 2290 out.add(cur.toString()); 2291 return out; 2292 } 2293 2294 /** 2295 * Remove a single pair of surrounding quotes if present. 2296 */ 2297 @NonNull 2298 private static String stripOptionalQuotes(@NonNull String string) { 2299 requireNonNull(string); 2300 2301 if (string.length() >= 2) { 2302 char first = string.charAt(0), last = string.charAt(string.length() - 1); 2303 2304 if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) 2305 return string.substring(1, string.length() - 1); 2306 } 2307 2308 return string; 2309 } 2310 2311 /** 2312 * Parse host[:port] with IPv6 support: "[v6](:port)?" or "host(:port)?". 2313 * Returns host (with brackets for v6) and port (nullable). 2314 */ 2315 @ThreadSafe 2316 private static final class HostPort { 2317 @NonNull 2318 private final String host; 2319 @Nullable 2320 private final Integer port; 2321 2322 HostPort(@NonNull String host, 2323 @Nullable Integer port) { 2324 this.host = host; 2325 this.port = port; 2326 } 2327 2328 @NonNull 2329 public String getHost() { 2330 return this.host; 2331 } 2332 2333 @NonNull 2334 public Optional<Integer> getPort() { 2335 return Optional.ofNullable(this.port); 2336 } 2337 } 2338 2339 @NonNull 2340 private static Optional<HostPort> parseForwardedHostPort(@Nullable String input) { 2341 input = trimAggressivelyToNull(input); 2342 2343 if (input == null) 2344 return Optional.empty(); 2345 2346 input = stripOptionalQuotes(input); 2347 2348 if (!HostHeaderValidator.isValidHostHeaderValue(input)) 2349 return Optional.empty(); 2350 2351 return parseHostPort(input); 2352 } 2353 2354 @NonNull 2355 private static Optional<HostPort> parseHostPort(@Nullable String input) { 2356 input = trimAggressivelyToNull(input); 2357 2358 if (input == null) 2359 return Optional.empty(); 2360 2361 input = stripOptionalQuotes(input); 2362 2363 if (input.startsWith("[")) { 2364 int close = input.indexOf(']'); 2365 2366 if (close > 0) { 2367 String core = input.substring(1, close); // IPv6 literal without brackets 2368 String rest = input.substring(close + 1); // maybe ":port" 2369 String host = "[" + core + "]"; 2370 Integer port = null; 2371 2372 if (rest.startsWith(":")) { 2373 String ps = trimAggressivelyToNull(rest.substring(1)); 2374 if (ps != null) { 2375 try { 2376 port = Integer.parseInt(ps, 10); 2377 } catch (Exception ignored) { 2378 // Nothing to do 2379 } 2380 } 2381 } 2382 2383 return Optional.of(new HostPort(host, port)); 2384 } 2385 } 2386 2387 int colon = input.indexOf(':'); 2388 2389 if (colon > 0 && input.indexOf(':', colon + 1) == -1) { 2390 // exactly one ':' -> host:port (IPv4/hostname) 2391 String h = trimAggressivelyToNull(input.substring(0, colon)); 2392 String ps = trimAggressivelyToNull(input.substring(colon + 1)); 2393 Integer p = null; 2394 2395 if (ps != null) { 2396 try { 2397 p = Integer.parseInt(ps, 10); 2398 } catch (Exception ignored) { 2399 // Nothing to do 2400 } 2401 } 2402 if (h != null) 2403 return Optional.of(new HostPort(h, p)); 2404 } 2405 2406 // no port 2407 return Optional.of(new HostPort(input, null)); 2408 } 2409 2410 @NonNull 2411 private static String removeDotSegments(@NonNull String path) { 2412 requireNonNull(path); 2413 2414 Deque<String> stack = new ArrayDeque<>(); 2415 2416 for (String seg : path.split("/", -1)) { 2417 if (seg.isEmpty() || ".".equals(seg)) 2418 continue; 2419 2420 if ("..".equals(seg)) { 2421 if (!stack.isEmpty()) 2422 stack.removeLast(); 2423 } else { 2424 stack.addLast(seg); 2425 } 2426 } 2427 2428 return "/" + String.join("/", stack); 2429 } 2430}