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 org.jspecify.annotations.NonNull; 020import org.jspecify.annotations.Nullable; 021 022import javax.annotation.concurrent.NotThreadSafe; 023import javax.annotation.concurrent.ThreadSafe; 024import java.net.HttpCookie; 025import java.time.Duration; 026import java.time.Instant; 027import java.time.ZoneId; 028import java.time.ZoneOffset; 029import java.time.format.DateTimeFormatter; 030import java.time.format.DateTimeFormatterBuilder; 031import java.time.format.SignStyle; 032import java.time.temporal.ChronoField; 033import java.util.ArrayList; 034import java.util.List; 035import java.util.Locale; 036import java.util.Objects; 037import java.util.Optional; 038import java.util.stream.Collectors; 039 040import static com.soklet.Utilities.trimAggressivelyToNull; 041import static java.lang.String.format; 042import static java.util.Objects.requireNonNull; 043 044/** 045 * HTTP "response" Cookie representation which supports {@code Set-Cookie} header encoding. 046 * <p> 047 * Threadsafe instances can be acquired via these builder factory methods: 048 * <ul> 049 * <li>{@link #with(String, String)} (builder primed with name and value)</li> 050 * <li>{@link #withName(String)} (builder primed with name only)</li> 051 * </ul> 052 * 053 * @author <a href="https://www.revetkn.com">Mark Allen</a> 054 */ 055@ThreadSafe 056public final class ResponseCookie { 057 @NonNull 058 private final String name; 059 @Nullable 060 private final String value; 061 @Nullable 062 private final Duration maxAge; 063 @Nullable 064 private final Instant expires; 065 @Nullable 066 private final String domain; 067 @Nullable 068 private final String path; 069 @NonNull 070 private final Boolean secure; 071 @NonNull 072 private final Boolean httpOnly; 073 @Nullable 074 private final SameSite sameSite; 075 @Nullable 076 private final Priority priority; 077 @NonNull 078 private final Boolean partitioned; 079 080 @NonNull 081 private static final DateTimeFormatter RFC_1123_FORMATTER; 082 @NonNull 083 private static final DateTimeFormatter RFC_1036_PARSER; 084 @NonNull 085 private static final DateTimeFormatter ASCTIME_PARSER; 086 087 static { 088 RFC_1123_FORMATTER = DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneId.of("GMT")); 089 RFC_1036_PARSER = new DateTimeFormatterBuilder() 090 .parseCaseInsensitive() 091 .appendPattern("EEE, dd MMM ") 092 .appendValueReduced(ChronoField.YEAR, 2, 2, 1900) 093 .appendPattern(" HH:mm:ss zzz") 094 .toFormatter(Locale.US) 095 .withZone(ZoneOffset.UTC); 096 ASCTIME_PARSER = new DateTimeFormatterBuilder() 097 .parseCaseInsensitive() 098 .appendPattern("EEE MMM") 099 .appendLiteral(' ') 100 .optionalStart().appendLiteral(' ').optionalEnd() 101 .appendValue(ChronoField.DAY_OF_MONTH, 1, 2, SignStyle.NOT_NEGATIVE) 102 .appendPattern(" HH:mm:ss yyyy") 103 .toFormatter(Locale.US) 104 .withZone(ZoneOffset.UTC); 105 } 106 107 /** 108 * Acquires a builder for {@link ResponseCookie} instances. 109 * 110 * @param name the cookie name 111 * @param value the cookie value 112 * @return the builder 113 */ 114 @NonNull 115 public static Builder with(@NonNull String name, 116 @Nullable String value) { 117 requireNonNull(name); 118 return new Builder(name, value); 119 } 120 121 /** 122 * Acquires a builder for {@link ResponseCookie} instances without specifying the cookie's value. 123 * 124 * @param name the cookie name 125 * @return the builder 126 */ 127 @NonNull 128 public static Builder withName(@NonNull String name) { 129 requireNonNull(name); 130 return new Builder(name); 131 } 132 133 /** 134 * Vends a mutable copier seeded with this instance's data, suitable for building new instances. 135 * 136 * @return a copier for this instance 137 */ 138 @NonNull 139 public Copier copy() { 140 return new Copier(this); 141 } 142 143 /** 144 * Given a {@code Set-Cookie} header representation, provide a {@link ResponseCookie} that matches it. 145 * <p> 146 * An example of a {@code Set-Cookie} header representation is {@code Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>; Secure; HttpOnly} 147 * <p> 148 * Note: while the spec does not forbid multiple cookie name/value pairs to be specified in the same {@code Set-Cookie} header, this format is unusual - Soklet does not currently support parsing these kinds of cookies. 149 * <p> 150 * See <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie">https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie</a> for details. 151 * 152 * @param setCookieHeaderRepresentation a {@code Set-Cookie} header representation 153 * @return a {@link ResponseCookie} representation of the {@code Set-Cookie} header, or {@link Optional#empty()} if the header is null, empty, or does not include cookie data 154 * @throws IllegalArgumentException if the {@code Set-Cookie} header representation is malformed 155 */ 156 @NonNull 157 public static Optional<ResponseCookie> fromSetCookieHeaderRepresentation(@Nullable String setCookieHeaderRepresentation) { 158 setCookieHeaderRepresentation = setCookieHeaderRepresentation == null ? null : setCookieHeaderRepresentation.trim(); 159 160 if (setCookieHeaderRepresentation == null || setCookieHeaderRepresentation.length() == 0) 161 return Optional.empty(); 162 163 String normalizedSetCookieHeaderRepresentation = stripSetCookiePrefix(setCookieHeaderRepresentation); 164 165 if (normalizedSetCookieHeaderRepresentation == null || normalizedSetCookieHeaderRepresentation.length() == 0) 166 return Optional.empty(); 167 168 List<HttpCookie> cookies = HttpCookie.parse(normalizedSetCookieHeaderRepresentation); 169 170 if (cookies.size() == 0) 171 return Optional.empty(); 172 173 // Technically OK per the spec to "fold" multiple cookie name/value pairs into the same header but this is 174 // unusual, and we don't support it here. Pick the first cookie and use it. 175 HttpCookie httpCookie = cookies.get(0); 176 177 // Handle session cookies (maxAge = -1) by passing null instead of negative Duration 178 long maxAge = httpCookie.getMaxAge(); 179 Duration maxAgeDuration = (maxAge >= 0) ? Duration.ofSeconds(maxAge) : null; 180 181 ParsedSetCookieAttributes attributes = parseSetCookieAttributes(normalizedSetCookieHeaderRepresentation); 182 183 return Optional.of(ResponseCookie.with(httpCookie.getName(), httpCookie.getValue()) 184 .maxAge(maxAgeDuration) 185 .expires(attributes.getExpires().orElse(null)) 186 .domain(httpCookie.getDomain()) 187 .httpOnly(httpCookie.isHttpOnly()) 188 .secure(httpCookie.getSecure()) 189 .path(httpCookie.getPath()) 190 .sameSite(attributes.getSameSite().orElse(null)) 191 .priority(attributes.getPriority().orElse(null)) 192 .partitioned(attributes.getPartitioned()) 193 .build()); 194 } 195 196 @Nullable 197 private static String stripSetCookiePrefix(@NonNull String setCookieHeaderRepresentation) { 198 requireNonNull(setCookieHeaderRepresentation); 199 200 String trimmed = trimAggressivelyToNull(setCookieHeaderRepresentation); 201 202 if (trimmed == null) 203 return null; 204 205 String prefix = "set-cookie:"; 206 207 if (trimmed.length() >= prefix.length() 208 && trimmed.regionMatches(true, 0, prefix, 0, prefix.length())) 209 return trimAggressivelyToNull(trimmed.substring(prefix.length())); 210 211 return trimmed; 212 } 213 214 @NonNull 215 private static ParsedSetCookieAttributes parseSetCookieAttributes(@NonNull String setCookieHeaderRepresentation) { 216 requireNonNull(setCookieHeaderRepresentation); 217 218 ParsedSetCookieAttributes attributes = new ParsedSetCookieAttributes(); 219 List<String> components = splitSetCookieHeaderRespectingQuotes(setCookieHeaderRepresentation); 220 221 if (components.size() <= 1) 222 return attributes; 223 224 for (int i = 1; i < components.size(); i++) { 225 String component = trimAggressivelyToNull(components.get(i)); 226 if (component == null) 227 continue; 228 229 int equalsIndex = component.indexOf('='); 230 String attributeName = trimAggressivelyToNull(equalsIndex == -1 ? component : component.substring(0, equalsIndex)); 231 232 if (attributeName == null) 233 continue; 234 235 String attributeValue = equalsIndex == -1 ? null : trimAggressivelyToNull(component.substring(equalsIndex + 1)); 236 237 if ("samesite".equalsIgnoreCase(attributeName)) { 238 if (attributes.sameSite == null && attributeValue != null) { 239 attributeValue = stripOptionalQuotes(attributeValue); 240 attributes.sameSite = SameSite.fromHeaderRepresentation(attributeValue).orElse(null); 241 } 242 continue; 243 } 244 245 if ("expires".equalsIgnoreCase(attributeName)) { 246 if (attributes.expires == null && attributeValue != null) { 247 try { 248 attributeValue = stripOptionalQuotes(attributeValue); 249 attributes.expires = parseHttpDate(attributeValue); 250 } catch (IllegalArgumentException ignored) { 251 // Ignore malformed Expires attribute 252 } 253 } 254 continue; 255 } 256 257 if ("priority".equalsIgnoreCase(attributeName)) { 258 if (attributes.priority == null && attributeValue != null) { 259 attributeValue = stripOptionalQuotes(attributeValue); 260 attributes.priority = Priority.fromHeaderRepresentation(attributeValue).orElse(null); 261 } 262 continue; 263 } 264 265 if ("partitioned".equalsIgnoreCase(attributeName)) 266 attributes.partitioned = true; 267 } 268 269 return attributes; 270 } 271 272 @NonNull 273 private static Instant parseHttpDate(@NonNull String value) { 274 requireNonNull(value); 275 276 String trimmed = trimAggressivelyToNull(value); 277 278 if (trimmed == null) 279 throw new IllegalArgumentException("Date value cannot be blank"); 280 281 for (DateTimeFormatter formatter : List.of(RFC_1123_FORMATTER, RFC_1036_PARSER, ASCTIME_PARSER)) { 282 try { 283 return Instant.from(formatter.parse(trimmed)); 284 } catch (Exception ignored) { 285 // try next formatter 286 } 287 } 288 289 throw new IllegalArgumentException(format("Unable to parse HTTP-date value '%s'", trimmed)); 290 } 291 292 @NonNull 293 private static List<@NonNull String> splitSetCookieHeaderRespectingQuotes(@NonNull String headerValue) { 294 requireNonNull(headerValue); 295 296 List<String> parts = new ArrayList<>(); 297 StringBuilder current = new StringBuilder(headerValue.length()); 298 boolean inQuotes = false; 299 boolean escaped = false; 300 301 for (int i = 0; i < headerValue.length(); i++) { 302 char c = headerValue.charAt(i); 303 304 if (escaped) { 305 current.append(c); 306 escaped = false; 307 continue; 308 } 309 310 if (c == '\\') { 311 escaped = true; 312 current.append(c); 313 continue; 314 } 315 316 if (c == '"') { 317 inQuotes = !inQuotes; 318 current.append(c); 319 continue; 320 } 321 322 if (c == ';' && !inQuotes) { 323 parts.add(current.toString()); 324 current.setLength(0); 325 continue; 326 } 327 328 current.append(c); 329 } 330 331 if (current.length() > 0) 332 parts.add(current.toString()); 333 334 return parts; 335 } 336 337 @NonNull 338 private static String stripOptionalQuotes(@NonNull String value) { 339 requireNonNull(value); 340 341 if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) 342 return value.substring(1, value.length() - 1); 343 344 return value; 345 } 346 347 private ResponseCookie(@NonNull Builder builder) { 348 requireNonNull(builder); 349 350 this.name = builder.name; 351 this.value = builder.value; 352 this.maxAge = builder.maxAge; 353 this.expires = builder.expires; 354 this.domain = builder.domain; 355 this.path = trimAggressivelyToNull(builder.path); // Can't specify blank paths. Must be null (omitted) or start with "/" 356 this.secure = builder.secure == null ? false : builder.secure; 357 this.httpOnly = builder.httpOnly == null ? false : builder.httpOnly; 358 this.sameSite = builder.sameSite; 359 this.priority = builder.priority; 360 this.partitioned = builder.partitioned == null ? false : builder.partitioned; 361 362 // Extra validation to ensure we are not creating cookies that are illegal according to the spec 363 364 Rfc6265Utils.validateCookieName(getName()); 365 Rfc6265Utils.validateCookieValue(getValue().orElse(null)); 366 Rfc6265Utils.validateDomain(getDomain().orElse(null)); 367 Rfc6265Utils.validatePath(getPath().orElse(null)); 368 369 if (this.maxAge != null && this.maxAge.isNegative()) 370 throw new IllegalArgumentException("Max-Age must be >= 0"); 371 372 if (this.sameSite == SameSite.NONE && (this.secure == null || !this.secure)) 373 throw new IllegalArgumentException(format("Specifying %s value %s.%s requires that you also set secure=true, otherwise browsers will likely discard the cookie", 374 ResponseCookie.class.getSimpleName(), SameSite.class.getSimpleName(), SameSite.NONE.name())); 375 376 if (this.partitioned) { 377 if (this.secure == null || !this.secure) 378 throw new IllegalArgumentException("Partitioned cookies require secure=true"); 379 380 String cookiePath = getPath().orElse(null); 381 if (cookiePath == null || !cookiePath.equals("/")) 382 throw new IllegalArgumentException("Partitioned cookies require path=\"/\""); 383 384 if (this.sameSite != SameSite.NONE) 385 throw new IllegalArgumentException("Partitioned cookies require SameSite=None"); 386 } 387 } 388 389 /** 390 * Generates a {@code Set-Cookie} header representation of this response cookie, for example {@code Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>; Secure; HttpOnly} 391 * <p> 392 * See <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie">https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie</a> for details. 393 * 394 * @return this response cookie in {@code Set-Cookie} header format 395 */ 396 @NonNull 397 public String toSetCookieHeaderRepresentation() { 398 List<String> components = new ArrayList<>(10); 399 400 components.add(format("%s=%s", getName(), getValue().orElse(""))); 401 402 if (getPath().isPresent()) 403 components.add(format("Path=%s", getPath().get())); 404 405 if (getDomain().isPresent()) 406 components.add(format("Domain=%s", getDomain().get())); 407 408 long maxAge = getMaxAge().isPresent() ? getMaxAge().get().toSeconds() : -1; 409 410 if (maxAge >= 0) 411 components.add(format("Max-Age=%d", maxAge)); 412 413 if (getExpires().isPresent()) 414 components.add(format("Expires=%s", RFC_1123_FORMATTER.format(getExpires().get()))); 415 416 if (getSecure()) 417 components.add("Secure"); 418 419 if (getHttpOnly()) 420 components.add("HttpOnly"); 421 422 if (getSameSite().isPresent()) 423 components.add(format("SameSite=%s", getSameSite().get().getHeaderRepresentation())); 424 425 if (getPriority().isPresent()) 426 components.add(format("Priority=%s", getPriority().get().getHeaderRepresentation())); 427 428 if (getPartitioned()) 429 components.add("Partitioned"); 430 431 return components.stream().collect(Collectors.joining("; ")); 432 } 433 434 @Override 435 public int hashCode() { 436 return Objects.hash( 437 getName(), 438 getValue(), 439 getMaxAge(), 440 getExpires(), 441 getDomain(), 442 getPath(), 443 getSecure(), 444 getHttpOnly(), 445 getSameSite(), 446 getPriority(), 447 getPartitioned() 448 ); 449 } 450 451 @Override 452 public boolean equals(@Nullable Object object) { 453 if (this == object) 454 return true; 455 456 if (!(object instanceof ResponseCookie responseCookie)) 457 return false; 458 459 return Objects.equals(getName(), responseCookie.getName()) 460 && Objects.equals(getValue(), responseCookie.getValue()) 461 && Objects.equals(getMaxAge(), responseCookie.getMaxAge()) 462 && Objects.equals(getExpires(), responseCookie.getExpires()) 463 && Objects.equals(getDomain(), responseCookie.getDomain()) 464 && Objects.equals(getPath(), responseCookie.getPath()) 465 && Objects.equals(getSecure(), responseCookie.getSecure()) 466 && Objects.equals(getHttpOnly(), responseCookie.getHttpOnly()) 467 && Objects.equals(getSameSite(), responseCookie.getSameSite()) 468 && Objects.equals(getPriority(), responseCookie.getPriority()) 469 && Objects.equals(getPartitioned(), responseCookie.getPartitioned()); 470 } 471 472 @Override 473 @NonNull 474 public String toString() { 475 return toSetCookieHeaderRepresentation(); 476 } 477 478 /** 479 * Gets the cookie's name. 480 * 481 * @return the name of the cookie 482 */ 483 @NonNull 484 public String getName() { 485 return this.name; 486 } 487 488 /** 489 * Gets the cookie's value, if present. 490 * 491 * @return the value of the cookie, or {@link Optional#empty()} if there is none 492 */ 493 @NonNull 494 public Optional<String> getValue() { 495 return Optional.ofNullable(this.value); 496 } 497 498 /** 499 * Gets the cookie's {@code Max-Age} value expressed as a {@link Duration}, if present. 500 * 501 * @return the {@code Max-Age} value of the cookie, or {@link Optional#empty()} if there is none 502 */ 503 @NonNull 504 public Optional<Duration> getMaxAge() { 505 return Optional.ofNullable(this.maxAge); 506 } 507 508 /** 509 * Gets the cookie's {@code Expires} value, if present. 510 * 511 * @return the {@code Expires} value of the cookie, or {@link Optional#empty()} if there is none 512 */ 513 @NonNull 514 public Optional<Instant> getExpires() { 515 return Optional.ofNullable(this.expires); 516 } 517 518 /** 519 * Gets the cookie's {@code Domain} value, if present. 520 * 521 * @return the {@code Domain} value of the cookie, or {@link Optional#empty()} if there is none 522 */ 523 @NonNull 524 public Optional<String> getDomain() { 525 return Optional.ofNullable(this.domain); 526 } 527 528 /** 529 * Gets the cookie's {@code Path} value, if present. 530 * 531 * @return the {@code Path} value of the cookie, or {@link Optional#empty()} if there is none 532 */ 533 @NonNull 534 public Optional<String> getPath() { 535 return Optional.ofNullable(this.path); 536 } 537 538 /** 539 * Gets the cookie's {@code Secure} flag, if present. 540 * 541 * @return {@code true} if the {@code Secure} flag of the cookie is present, {@code false} otherwise 542 */ 543 @NonNull 544 public Boolean getSecure() { 545 return this.secure; 546 } 547 548 /** 549 * Gets the cookie's {@code HttpOnly} flag, if present. 550 * 551 * @return {@code true} if the {@code HttpOnly} flag of the cookie is present, {@code false} otherwise 552 */ 553 @NonNull 554 public Boolean getHttpOnly() { 555 return this.httpOnly; 556 } 557 558 /** 559 * Gets the cookie's {@code SameSite} value, if present. 560 * 561 * @return the {@code SameSite} value of the cookie, or {@link Optional#empty()} if there is none 562 */ 563 @NonNull 564 public Optional<SameSite> getSameSite() { 565 return Optional.ofNullable(this.sameSite); 566 } 567 568 /** 569 * Gets the cookie's {@code Priority} value, if present. 570 * 571 * @return the {@code Priority} value of the cookie, or {@link Optional#empty()} if there is none 572 */ 573 @NonNull 574 public Optional<Priority> getPriority() { 575 return Optional.ofNullable(this.priority); 576 } 577 578 /** 579 * Gets the cookie's {@code Partitioned} flag. 580 * 581 * @return {@code true} if the {@code Partitioned} flag is present, {@code false} otherwise 582 */ 583 @NonNull 584 public Boolean getPartitioned() { 585 return this.partitioned; 586 } 587 588 /** 589 * Values which control whether a response cookie is sent with cross-site requests, providing some protection against cross-site request forgery attacks (CSRF). 590 * <p> 591 * See <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value">https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value</a> for details. 592 * 593 * @author <a href="https://www.revetkn.com">Mark Allen</a> 594 */ 595 public enum SameSite { 596 /** 597 * Means that the browser sends the cookie only for same-site requests, that is, requests originating from the same site that set the cookie. 598 * If a request originates from a different domain or scheme (even with the same domain), no cookies with the {@code SameSite=Strict} attribute are sent. 599 */ 600 STRICT("Strict"), 601 /** 602 * Means that the cookie is not sent on cross-site requests, such as on requests to load images or frames, but is sent when a user is navigating to the origin site from an external site (for example, when following a link). 603 * This is the default behavior if the {@code SameSite} attribute is not specified. 604 */ 605 LAX("Lax"), 606 /** 607 * Means that the browser sends the cookie with both cross-site and same-site requests. 608 * The {@code Secure} attribute must also be set when setting this value, like so {@code SameSite=None; Secure}. If {@code Secure} is missing, an error will be logged. 609 */ 610 NONE("None"); 611 612 @NonNull 613 private final String headerRepresentation; 614 615 SameSite(@NonNull String headerRepresentation) { 616 requireNonNull(headerRepresentation); 617 this.headerRepresentation = headerRepresentation; 618 } 619 620 /** 621 * Returns the {@link SameSite} enum value that matches the corresponding {@code SameSite} response header value representation (one of {@code Strict}, {@code Lax}, or {@code None} - case-insensitive). 622 * 623 * @param headerRepresentation a case-insensitive HTTP header value - one of {@code Strict}, {@code Lax}, or {@code None} 624 * @return the enum value that corresponds to the given the header representation, or {@link Optional#empty()} if none matches 625 */ 626 @NonNull 627 public static Optional<SameSite> fromHeaderRepresentation(@NonNull String headerRepresentation) { 628 requireNonNull(headerRepresentation); 629 630 headerRepresentation = headerRepresentation.trim(); 631 632 for (SameSite sameSite : values()) 633 if (headerRepresentation.equalsIgnoreCase(sameSite.getHeaderRepresentation())) 634 return Optional.of(sameSite); 635 636 return Optional.empty(); 637 } 638 639 /** 640 * The HTTP header value that corresponds to this enum value - one of {@code Strict}, {@code Lax}, or {@code None}. 641 * 642 * @return the HTTP header value for this enum 643 */ 644 @NonNull 645 public String getHeaderRepresentation() { 646 return this.headerRepresentation; 647 } 648 } 649 650 /** 651 * Represents the {@code Priority} response cookie attribute. 652 */ 653 public enum Priority { 654 LOW("Low"), 655 MEDIUM("Medium"), 656 HIGH("High"); 657 658 @NonNull 659 private final String headerRepresentation; 660 661 Priority(@NonNull String headerRepresentation) { 662 requireNonNull(headerRepresentation); 663 this.headerRepresentation = headerRepresentation; 664 } 665 666 @NonNull 667 public String getHeaderRepresentation() { 668 return this.headerRepresentation; 669 } 670 671 @NonNull 672 public static Optional<Priority> fromHeaderRepresentation(@NonNull String headerRepresentation) { 673 requireNonNull(headerRepresentation); 674 675 String normalized = headerRepresentation.trim(); 676 677 for (Priority priority : values()) 678 if (normalized.equalsIgnoreCase(priority.getHeaderRepresentation())) 679 return Optional.of(priority); 680 681 return Optional.empty(); 682 } 683 } 684 685 /** 686 * Builder used to construct instances of {@link ResponseCookie} via {@link ResponseCookie#withName(String)} 687 * or {@link ResponseCookie#with(String, String)}. 688 * <p> 689 * This class is intended for use by a single thread. 690 * 691 * @author <a href="https://www.revetkn.com">Mark Allen</a> 692 */ 693 @NotThreadSafe 694 public static final class Builder { 695 @NonNull 696 private String name; 697 @Nullable 698 private String value; 699 @Nullable 700 private Duration maxAge; 701 @Nullable 702 private Instant expires; 703 @Nullable 704 private String domain; 705 @Nullable 706 private String path; 707 @Nullable 708 private Boolean secure; 709 @Nullable 710 private Boolean httpOnly; 711 @Nullable 712 private SameSite sameSite; 713 @Nullable 714 private Priority priority; 715 @Nullable 716 private Boolean partitioned; 717 718 protected Builder(@NonNull String name) { 719 requireNonNull(name); 720 this.name = name; 721 } 722 723 protected Builder(@NonNull String name, 724 @Nullable String value) { 725 requireNonNull(name); 726 this.name = name; 727 this.value = value; 728 } 729 730 @NonNull 731 public Builder name(@NonNull String name) { 732 requireNonNull(name); 733 this.name = name; 734 return this; 735 } 736 737 @NonNull 738 public Builder value(@Nullable String value) { 739 this.value = value; 740 return this; 741 } 742 743 @NonNull 744 public Builder maxAge(@Nullable Duration maxAge) { 745 this.maxAge = maxAge; 746 return this; 747 } 748 749 @NonNull 750 public Builder expires(@Nullable Instant expires) { 751 this.expires = expires; 752 return this; 753 } 754 755 @NonNull 756 public Builder domain(@Nullable String domain) { 757 this.domain = domain; 758 return this; 759 } 760 761 @NonNull 762 public Builder path(@Nullable String path) { 763 this.path = path; 764 return this; 765 } 766 767 @NonNull 768 public Builder secure(@Nullable Boolean secure) { 769 this.secure = secure; 770 return this; 771 } 772 773 @NonNull 774 public Builder httpOnly(@Nullable Boolean httpOnly) { 775 this.httpOnly = httpOnly; 776 return this; 777 } 778 779 @NonNull 780 public Builder sameSite(@Nullable SameSite sameSite) { 781 this.sameSite = sameSite; 782 return this; 783 } 784 785 @NonNull 786 public Builder priority(@Nullable Priority priority) { 787 this.priority = priority; 788 return this; 789 } 790 791 @NonNull 792 public Builder partitioned(@Nullable Boolean partitioned) { 793 this.partitioned = partitioned; 794 return this; 795 } 796 797 @NonNull 798 public ResponseCookie build() { 799 return new ResponseCookie(this); 800 } 801 } 802 803 /** 804 * Builder used to copy instances of {@link ResponseCookie} via {@link ResponseCookie#copy()}. 805 * <p> 806 * This class is intended for use by a single thread. 807 * 808 * @author <a href="https://www.revetkn.com">Mark Allen</a> 809 */ 810 @NotThreadSafe 811 public static final class Copier { 812 @NonNull 813 private final Builder builder; 814 815 Copier(@NonNull ResponseCookie responseCookie) { 816 requireNonNull(responseCookie); 817 818 this.builder = new Builder(responseCookie.getName()) 819 .value(responseCookie.getValue().orElse(null)) 820 .maxAge(responseCookie.getMaxAge().orElse(null)) 821 .expires(responseCookie.getExpires().orElse(null)) 822 .domain(responseCookie.getDomain().orElse(null)) 823 .path(responseCookie.getPath().orElse(null)) 824 .secure(responseCookie.getSecure()) 825 .httpOnly(responseCookie.getHttpOnly()) 826 .sameSite(responseCookie.getSameSite().orElse(null)) 827 .priority(responseCookie.getPriority().orElse(null)) 828 .partitioned(responseCookie.getPartitioned()); 829 } 830 831 @NonNull 832 public Copier name(@NonNull String name) { 833 requireNonNull(name); 834 this.builder.name(name); 835 return this; 836 } 837 838 @NonNull 839 public Copier value(@Nullable String value) { 840 this.builder.value(value); 841 return this; 842 } 843 844 @NonNull 845 public Copier maxAge(@Nullable Duration maxAge) { 846 this.builder.maxAge(maxAge); 847 return this; 848 } 849 850 @NonNull 851 public Copier expires(@Nullable Instant expires) { 852 this.builder.expires(expires); 853 return this; 854 } 855 856 @NonNull 857 public Copier domain(@Nullable String domain) { 858 this.builder.domain(domain); 859 return this; 860 } 861 862 @NonNull 863 public Copier path(@Nullable String path) { 864 this.builder.path(path); 865 return this; 866 } 867 868 @NonNull 869 public Copier secure(@Nullable Boolean secure) { 870 this.builder.secure(secure); 871 return this; 872 } 873 874 @NonNull 875 public Copier httpOnly(@Nullable Boolean httpOnly) { 876 this.builder.httpOnly(httpOnly); 877 return this; 878 } 879 880 @NonNull 881 public Copier sameSite(@Nullable SameSite sameSite) { 882 this.builder.sameSite(sameSite); 883 return this; 884 } 885 886 @NonNull 887 public Copier priority(@Nullable Priority priority) { 888 this.builder.priority(priority); 889 return this; 890 } 891 892 @NonNull 893 public Copier partitioned(@Nullable Boolean partitioned) { 894 this.builder.partitioned(partitioned); 895 return this; 896 } 897 898 @NonNull 899 public ResponseCookie finish() { 900 return this.builder.build(); 901 } 902 } 903 904 @ThreadSafe 905 private static final class ParsedSetCookieAttributes { 906 @Nullable 907 private Instant expires; 908 @Nullable 909 private SameSite sameSite; 910 @Nullable 911 private Priority priority; 912 @NonNull 913 private Boolean partitioned = false; 914 915 @NonNull 916 private Optional<Instant> getExpires() { 917 return Optional.ofNullable(this.expires); 918 } 919 920 @NonNull 921 private Optional<SameSite> getSameSite() { 922 return Optional.ofNullable(this.sameSite); 923 } 924 925 @NonNull 926 private Optional<Priority> getPriority() { 927 return Optional.ofNullable(this.priority); 928 } 929 930 @NonNull 931 private Boolean getPartitioned() { 932 return this.partitioned; 933 } 934 } 935 936 /** 937 * See https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/http/ResponseCookie.java 938 * <p> 939 * Copyright 2002-2023 the original author or authors. 940 * <p> 941 * Licensed under the Apache License, Version 2.0 (the "License"); 942 * you may not use this file except in compliance with the License. 943 * You may obtain a copy of the License at 944 * <p> 945 * https://www.apache.org/licenses/LICENSE-2.0 946 * <p> 947 * Unless required by applicable law or agreed to in writing, software 948 * distributed under the License is distributed on an "AS IS" BASIS, 949 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 950 * See the License for the specific language governing permissions and 951 * limitations under the License. 952 * <p> 953 * 954 * @author Rossen Stoyanchev 955 * @author Brian Clozel 956 */ 957 @ThreadSafe 958 private static class Rfc6265Utils { 959 @NonNull 960 private static final String SEPARATOR_CHARS = new String(new char[]{ 961 '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' ' 962 }); 963 964 @NonNull 965 private static final String DOMAIN_CHARS = 966 "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-"; 967 968 public static void validateCookieName(@NonNull String name) { 969 requireNonNull(name); 970 971 for (int i = 0; i < name.length(); i++) { 972 char c = name.charAt(i); 973 // CTL = <US-ASCII control chars (octets 0 - 31) and DEL (127)> 974 if (c <= 0x1F || c == 0x7F) { 975 throw new IllegalArgumentException( 976 name + ": RFC2616 token cannot have control chars"); 977 } 978 if (SEPARATOR_CHARS.indexOf(c) >= 0) { 979 throw new IllegalArgumentException( 980 name + ": RFC2616 token cannot have separator chars such as '" + c + "'"); 981 } 982 if (c >= 0x80) { 983 throw new IllegalArgumentException( 984 name + ": RFC2616 token can only have US-ASCII: 0x" + Integer.toHexString(c)); 985 } 986 } 987 } 988 989 public static void validateCookieValue(@Nullable String value) { 990 if (value == null) { 991 return; 992 } 993 int start = 0; 994 int end = value.length(); 995 if (end > 1 && value.charAt(0) == '"' && value.charAt(end - 1) == '"') { 996 start = 1; 997 end--; 998 } 999 for (int i = start; i < end; i++) { 1000 char c = value.charAt(i); 1001 if (c < 0x21 || c == 0x22 || c == 0x2c || c == 0x3b || c == 0x5c || c == 0x7f) { 1002 throw new IllegalArgumentException( 1003 "RFC2616 cookie value cannot have '" + c + "'"); 1004 } 1005 if (c >= 0x80) { 1006 throw new IllegalArgumentException( 1007 "RFC2616 cookie value can only have US-ASCII chars: 0x" + Integer.toHexString(c)); 1008 } 1009 } 1010 } 1011 1012 public static void validateDomain(@Nullable String domain) { 1013 if (domain == null || domain.trim().length() == 0) { 1014 return; 1015 } 1016 int char1 = domain.charAt(0); 1017 int charN = domain.charAt(domain.length() - 1); 1018 if (char1 == '-' || charN == '.' || charN == '-') { 1019 throw new IllegalArgumentException("Invalid first/last char in cookie domain: " + domain); 1020 } 1021 for (int i = 0, c = -1; i < domain.length(); i++) { 1022 int p = c; 1023 c = domain.charAt(i); 1024 if (DOMAIN_CHARS.indexOf(c) == -1 || (p == '.' && (c == '.' || c == '-')) || (p == '-' && c == '.')) { 1025 throw new IllegalArgumentException(domain + ": invalid cookie domain char '" + (char) c + "'"); 1026 } 1027 } 1028 } 1029 1030 public static void validatePath(@Nullable String path) { 1031 if (path == null) 1032 return; 1033 1034 // Check for path traversal attempts 1035 if (path.contains("..")) { 1036 throw new IllegalArgumentException( 1037 "Cookie path must not contain '..' segments (path traversal): " + path 1038 ); 1039 } 1040 1041 // Check for query strings 1042 if (path.contains("?")) { 1043 throw new IllegalArgumentException( 1044 "Cookie path must not contain query strings: " + path 1045 ); 1046 } 1047 1048 // Check for fragments 1049 if (path.contains("#")) { 1050 throw new IllegalArgumentException( 1051 "Cookie path must not contain fragments: " + path 1052 ); 1053 } 1054 1055 // Check for backslashes (Windows-style paths) 1056 if (path.contains("\\")) { 1057 throw new IllegalArgumentException( 1058 "Cookie path must not contain backslashes: " + path 1059 ); 1060 } 1061 1062 // Check for URL-encoded dots (potential bypass attempt) 1063 if (path.contains("%2E") || path.contains("%2e")) { 1064 throw new IllegalArgumentException( 1065 "Cookie path must not contain encoded dots: " + path 1066 ); 1067 } 1068 1069 // Must start with forward slash 1070 if (!path.startsWith("/")) { 1071 throw new IllegalArgumentException( 1072 "Cookie path must start with '/': " + path 1073 ); 1074 } 1075 1076 // Check for null bytes 1077 if (path.contains("\0") || path.contains("%00")) { 1078 throw new IllegalArgumentException( 1079 "Cookie path must not contain null bytes: " + path 1080 ); 1081 } 1082 1083 for (int i = 0; i < path.length(); i++) { 1084 char c = path.charAt(i); 1085 if (c < 0x20 || c > 0x7E || c == ';') { 1086 throw new IllegalArgumentException(path + ": Invalid cookie path char '" + c + "'"); 1087 } 1088 } 1089 } 1090 } 1091}