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