001/* 002 * Copyright 2022-2025 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 javax.annotation.Nonnull; 020import javax.annotation.Nullable; 021import javax.annotation.concurrent.NotThreadSafe; 022import javax.annotation.concurrent.ThreadSafe; 023import java.net.HttpCookie; 024import java.time.Duration; 025import java.util.ArrayList; 026import java.util.List; 027import java.util.Objects; 028import java.util.Optional; 029import java.util.stream.Collectors; 030 031import static com.soklet.Utilities.trimAggressivelyToNull; 032import static java.lang.String.format; 033import static java.util.Objects.requireNonNull; 034 035/** 036 * HTTP "response" Cookie representation which supports {@code Set-Cookie} header encoding. 037 * <p> 038 * Threadsafe instances can be acquired via these builder factory methods: 039 * <ul> 040 * <li>{@link #with(String, String)} (builder primed with name and value)</li> 041 * <li>{@link #withName(String)} (builder primed with name only)</li> 042 * </ul> 043 * 044 * @author <a href="https://www.revetkn.com">Mark Allen</a> 045 */ 046@ThreadSafe 047public final class ResponseCookie { 048 @Nonnull 049 private final String name; 050 @Nullable 051 private final String value; 052 @Nullable 053 private final Duration maxAge; 054 @Nullable 055 private final String domain; 056 @Nullable 057 private final String path; 058 @Nonnull 059 private final Boolean secure; 060 @Nonnull 061 private final Boolean httpOnly; 062 @Nullable 063 private final SameSite sameSite; 064 065 /** 066 * Acquires a builder for {@link ResponseCookie} instances. 067 * 068 * @param name the cookie name 069 * @param value the cookie value 070 * @return the builder 071 */ 072 @Nonnull 073 public static Builder with(@Nonnull String name, 074 @Nullable String value) { 075 requireNonNull(name); 076 return new Builder(name, value); 077 } 078 079 /** 080 * Acquires a builder for {@link ResponseCookie} instances without specifying the cookie's value. 081 * 082 * @param name the cookie name 083 * @return the builder 084 */ 085 @Nonnull 086 public static Builder withName(@Nonnull String name) { 087 requireNonNull(name); 088 return new Builder(name); 089 } 090 091 /** 092 * Vends a mutable copier seeded with this instance's data, suitable for building new instances. 093 * 094 * @return a copier for this instance 095 */ 096 @Nonnull 097 public Copier copy() { 098 return new Copier(this); 099 } 100 101 /** 102 * Given a {@code Set-Cookie} header representation, provide a {@link ResponseCookie} that matches it. 103 * <p> 104 * An example of a {@code Set-Cookie} header representation is {@code Set-Cookie: <cookie-name>=<cookie-value>; Domain=<domain-value>; Secure; HttpOnly} 105 * <p> 106 * 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. 107 * <p> 108 * 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. 109 * 110 * @param setCookieHeaderRepresentation a {@code Set-Cookie} header representation 111 * @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 112 * @throws IllegalArgumentException if the {@code Set-Cookie} header representation is malformed 113 */ 114 @Nonnull 115 public static Optional<ResponseCookie> fromSetCookieHeaderRepresentation(@Nullable String setCookieHeaderRepresentation) { 116 setCookieHeaderRepresentation = setCookieHeaderRepresentation == null ? null : setCookieHeaderRepresentation.trim(); 117 118 if (setCookieHeaderRepresentation == null || setCookieHeaderRepresentation.length() == 0) 119 return Optional.empty(); 120 121 List<HttpCookie> cookies = HttpCookie.parse(setCookieHeaderRepresentation); 122 123 if (cookies.size() == 0) 124 return Optional.empty(); 125 126 // Technically OK per the spec to "fold" multiple cookie name/value pairs into the same header but this is 127 // unusual, and we don't support it here. Pick the first cookie and use it. 128 HttpCookie httpCookie = cookies.get(0); 129 130 // Handle session cookies (maxAge = -1) by passing null instead of negative Duration 131 long maxAge = httpCookie.getMaxAge(); 132 Duration maxAgeDuration = (maxAge >= 0) ? Duration.ofSeconds(maxAge) : null; 133 134 return Optional.of(ResponseCookie.with(httpCookie.getName(), httpCookie.getValue()) 135 .maxAge(maxAgeDuration) 136 .domain(httpCookie.getDomain()) 137 .httpOnly(httpCookie.isHttpOnly()) 138 .secure(httpCookie.getSecure()) 139 .path(httpCookie.getPath()) 140 .build()); 141 } 142 143 private ResponseCookie(@Nonnull Builder builder) { 144 requireNonNull(builder); 145 146 this.name = builder.name; 147 this.value = builder.value; 148 this.maxAge = builder.maxAge; 149 this.domain = builder.domain; 150 this.path = trimAggressivelyToNull(builder.path); // Can't specify blank paths. Must be null (omitted) or start with "/" 151 this.secure = builder.secure == null ? false : builder.secure; 152 this.httpOnly = builder.httpOnly == null ? false : builder.httpOnly; 153 this.sameSite = builder.sameSite; 154 155 // Extra validation to ensure we are not creating cookies that are illegal according to the spec 156 157 Rfc6265Utils.validateCookieName(getName()); 158 Rfc6265Utils.validateCookieValue(getValue().orElse(null)); 159 Rfc6265Utils.validateDomain(getDomain().orElse(null)); 160 Rfc6265Utils.validatePath(getPath().orElse(null)); 161 162 if (this.sameSite == SameSite.NONE && (this.secure == null || !this.secure)) 163 throw new IllegalArgumentException(format("Specifying %s value %s.%s requires that you also set secure=true, otherwise browsers will likely discard the cookie", 164 ResponseCookie.class.getSimpleName(), SameSite.class.getSimpleName(), SameSite.NONE.name())); 165 } 166 167 /** 168 * 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} 169 * <p> 170 * 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. 171 * 172 * @return this response cookie in {@code Set-Cookie} header format 173 */ 174 @Nonnull 175 public String toSetCookieHeaderRepresentation() { 176 List<String> components = new ArrayList<>(8); 177 178 components.add(format("%s=%s", getName(), getValue().orElse(""))); 179 180 if (getPath().isPresent()) 181 components.add(format("Path=%s", getPath().get())); 182 183 if (getDomain().isPresent()) 184 components.add(format("Domain=%s", getDomain().get())); 185 186 long maxAge = getMaxAge().isPresent() ? getMaxAge().get().toSeconds() : -1; 187 188 if (maxAge >= 0) 189 components.add(format("Max-Age=%d", maxAge)); 190 191 if (getSecure()) 192 components.add("Secure"); 193 194 if (getHttpOnly()) 195 components.add("HttpOnly"); 196 197 if (getSameSite().isPresent()) 198 components.add(format("SameSite=%s", getSameSite().get().getHeaderRepresentation())); 199 200 return components.stream().collect(Collectors.joining("; ")); 201 } 202 203 @Override 204 public int hashCode() { 205 return Objects.hash( 206 getName(), 207 getValue(), 208 getMaxAge(), 209 getDomain(), 210 getPath(), 211 getSecure(), 212 getHttpOnly(), 213 getSameSite() 214 ); 215 } 216 217 @Override 218 public boolean equals(@Nullable Object object) { 219 if (this == object) 220 return true; 221 222 if (!(object instanceof ResponseCookie responseCookie)) 223 return false; 224 225 return Objects.equals(getName(), responseCookie.getName()) 226 && Objects.equals(getValue(), responseCookie.getValue()) 227 && Objects.equals(getMaxAge(), responseCookie.getMaxAge()) 228 && Objects.equals(getDomain(), responseCookie.getDomain()) 229 && Objects.equals(getPath(), responseCookie.getPath()) 230 && Objects.equals(getSecure(), responseCookie.getSecure()) 231 && Objects.equals(getHttpOnly(), responseCookie.getHttpOnly()) 232 && Objects.equals(getSameSite(), responseCookie.getSameSite()); 233 } 234 235 @Override 236 @Nonnull 237 public String toString() { 238 return toSetCookieHeaderRepresentation(); 239 } 240 241 /** 242 * Gets the cookie's name. 243 * 244 * @return the name of the cookie 245 */ 246 @Nonnull 247 public String getName() { 248 return this.name; 249 } 250 251 /** 252 * Gets the cookie's value, if present. 253 * 254 * @return the value of the cookie, or {@link Optional#empty()} if there is none 255 */ 256 @Nonnull 257 public Optional<String> getValue() { 258 return Optional.ofNullable(this.value); 259 } 260 261 /** 262 * Gets the cookie's {@code Max-Age} value expressed as a {@link Duration}, if present. 263 * 264 * @return the {@code Max-Age} value of the cookie, or {@link Optional#empty()} if there is none 265 */ 266 @Nonnull 267 public Optional<Duration> getMaxAge() { 268 return Optional.ofNullable(this.maxAge); 269 } 270 271 /** 272 * Gets the cookie's {@code Domain} value, if present. 273 * 274 * @return the {@code Domain} value of the cookie, or {@link Optional#empty()} if there is none 275 */ 276 @Nonnull 277 public Optional<String> getDomain() { 278 return Optional.ofNullable(this.domain); 279 } 280 281 /** 282 * Gets the cookie's {@code Path} value, if present. 283 * 284 * @return the {@code Path} value of the cookie, or {@link Optional#empty()} if there is none 285 */ 286 @Nonnull 287 public Optional<String> getPath() { 288 return Optional.ofNullable(this.path); 289 } 290 291 /** 292 * Gets the cookie's {@code Secure} flag, if present. 293 * 294 * @return {@code true} if the {@code Secure} flag of the cookie is present, {@code false} otherwise 295 */ 296 @Nonnull 297 public Boolean getSecure() { 298 return this.secure; 299 } 300 301 /** 302 * Gets the cookie's {@code HttpOnly} flag, if present. 303 * 304 * @return {@code true} if the {@code HttpOnly} flag of the cookie is present, {@code false} otherwise 305 */ 306 @Nonnull 307 public Boolean getHttpOnly() { 308 return this.httpOnly; 309 } 310 311 /** 312 * Gets the cookie's {@code SameSite} value, if present. 313 * 314 * @return the {@code SameSite} value of the cookie, or {@link Optional#empty()} if there is none 315 */ 316 @Nonnull 317 public Optional<SameSite> getSameSite() { 318 return Optional.ofNullable(this.sameSite); 319 } 320 321 /** 322 * Values which control whether a response cookie is sent with cross-site requests, providing some protection against cross-site request forgery attacks (CSRF). 323 * <p> 324 * 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. 325 * 326 * @author <a href="https://www.revetkn.com">Mark Allen</a> 327 */ 328 public enum SameSite { 329 /** 330 * Means that the browser sends the cookie only for same-site requests, that is, requests originating from the same site that set the cookie. 331 * 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. 332 */ 333 STRICT("Strict"), 334 /** 335 * 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). 336 * This is the default behavior if the {@code SameSite} attribute is not specified. 337 */ 338 LAX("Lax"), 339 /** 340 * Means that the browser sends the cookie with both cross-site and same-site requests. 341 * 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. 342 */ 343 NONE("None"); 344 345 @Nonnull 346 private final String headerRepresentation; 347 348 SameSite(@Nonnull String headerRepresentation) { 349 requireNonNull(headerRepresentation); 350 this.headerRepresentation = headerRepresentation; 351 } 352 353 /** 354 * 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). 355 * 356 * @param headerRepresentation a case-insensitive HTTP header value - one of {@code Strict}, {@code Lax}, or {@code None} 357 * @return the enum value that corresponds to the given the header representation, or {@link Optional#empty()} if none matches 358 */ 359 @Nonnull 360 public static Optional<SameSite> fromHeaderRepresentation(@Nonnull String headerRepresentation) { 361 requireNonNull(headerRepresentation); 362 363 headerRepresentation = headerRepresentation.trim(); 364 365 for (SameSite sameSite : values()) 366 if (headerRepresentation.equalsIgnoreCase(sameSite.getHeaderRepresentation())) 367 return Optional.of(sameSite); 368 369 return Optional.empty(); 370 } 371 372 /** 373 * The HTTP header value that corresponds to this enum value - one of {@code Strict}, {@code Lax}, or {@code None}. 374 * 375 * @return the HTTP header value for this enum 376 */ 377 @Nonnull 378 public String getHeaderRepresentation() { 379 return this.headerRepresentation; 380 } 381 } 382 383 /** 384 * Builder used to construct instances of {@link ResponseCookie} via {@link ResponseCookie#withName(String)} 385 * or {@link ResponseCookie#with(String, String)}. 386 * <p> 387 * This class is intended for use by a single thread. 388 * 389 * @author <a href="https://www.revetkn.com">Mark Allen</a> 390 */ 391 @NotThreadSafe 392 public static final class Builder { 393 @Nonnull 394 private String name; 395 @Nullable 396 private String value; 397 @Nullable 398 private Duration maxAge; 399 @Nullable 400 private String domain; 401 @Nullable 402 private String path; 403 @Nullable 404 private Boolean secure; 405 @Nullable 406 private Boolean httpOnly; 407 @Nullable 408 private SameSite sameSite; 409 410 protected Builder(@Nonnull String name) { 411 requireNonNull(name); 412 this.name = name; 413 } 414 415 protected Builder(@Nonnull String name, 416 @Nullable String value) { 417 requireNonNull(name); 418 this.name = name; 419 this.value = value; 420 } 421 422 @Nonnull 423 public Builder name(@Nonnull String name) { 424 requireNonNull(name); 425 this.name = name; 426 return this; 427 } 428 429 @Nonnull 430 public Builder value(@Nullable String value) { 431 this.value = value; 432 return this; 433 } 434 435 @Nonnull 436 public Builder maxAge(@Nullable Duration maxAge) { 437 this.maxAge = maxAge; 438 return this; 439 } 440 441 @Nonnull 442 public Builder domain(@Nullable String domain) { 443 this.domain = domain; 444 return this; 445 } 446 447 @Nonnull 448 public Builder path(@Nullable String path) { 449 this.path = path; 450 return this; 451 } 452 453 @Nonnull 454 public Builder secure(@Nullable Boolean secure) { 455 this.secure = secure; 456 return this; 457 } 458 459 @Nonnull 460 public Builder httpOnly(@Nullable Boolean httpOnly) { 461 this.httpOnly = httpOnly; 462 return this; 463 } 464 465 @Nonnull 466 public Builder sameSite(@Nullable SameSite sameSite) { 467 this.sameSite = sameSite; 468 return this; 469 } 470 471 @Nonnull 472 public ResponseCookie build() { 473 return new ResponseCookie(this); 474 } 475 } 476 477 /** 478 * Builder used to copy instances of {@link ResponseCookie} via {@link ResponseCookie#copy()}. 479 * <p> 480 * This class is intended for use by a single thread. 481 * 482 * @author <a href="https://www.revetkn.com">Mark Allen</a> 483 */ 484 @NotThreadSafe 485 public static final class Copier { 486 @Nonnull 487 private final Builder builder; 488 489 Copier(@Nonnull ResponseCookie responseCookie) { 490 requireNonNull(responseCookie); 491 492 this.builder = new Builder(responseCookie.getName()) 493 .value(responseCookie.getValue().orElse(null)) 494 .maxAge(responseCookie.getMaxAge().orElse(null)) 495 .domain(responseCookie.getDomain().orElse(null)) 496 .path(responseCookie.getPath().orElse(null)) 497 .secure(responseCookie.getSecure()) 498 .httpOnly(responseCookie.getHttpOnly()) 499 .sameSite(responseCookie.getSameSite().orElse(null)); 500 } 501 502 @Nonnull 503 public Copier name(@Nonnull String name) { 504 requireNonNull(name); 505 this.builder.name(name); 506 return this; 507 } 508 509 @Nonnull 510 public Copier value(@Nullable String value) { 511 this.builder.value(value); 512 return this; 513 } 514 515 @Nonnull 516 public Copier maxAge(@Nullable Duration maxAge) { 517 this.builder.maxAge(maxAge); 518 return this; 519 } 520 521 @Nonnull 522 public Copier domain(@Nullable String domain) { 523 this.builder.domain(domain); 524 return this; 525 } 526 527 @Nonnull 528 public Copier path(@Nullable String path) { 529 this.builder.path(path); 530 return this; 531 } 532 533 @Nonnull 534 public Copier secure(@Nullable Boolean secure) { 535 this.builder.secure(secure); 536 return this; 537 } 538 539 @Nonnull 540 public Copier httpOnly(@Nullable Boolean httpOnly) { 541 this.builder.httpOnly(httpOnly); 542 return this; 543 } 544 545 @Nonnull 546 public Copier sameSite(@Nullable SameSite sameSite) { 547 this.builder.sameSite(sameSite); 548 return this; 549 } 550 551 @Nonnull 552 public ResponseCookie finish() { 553 return this.builder.build(); 554 } 555 } 556 557 /** 558 * See https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/http/ResponseCookie.java 559 * <p> 560 * Copyright 2002-2023 the original author or authors. 561 * <p> 562 * Licensed under the Apache License, Version 2.0 (the "License"); 563 * you may not use this file except in compliance with the License. 564 * You may obtain a copy of the License at 565 * <p> 566 * https://www.apache.org/licenses/LICENSE-2.0 567 * <p> 568 * Unless required by applicable law or agreed to in writing, software 569 * distributed under the License is distributed on an "AS IS" BASIS, 570 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 571 * See the License for the specific language governing permissions and 572 * limitations under the License. 573 * <p> 574 * 575 * @author Rossen Stoyanchev 576 * @author Brian Clozel 577 */ 578 @ThreadSafe 579 private static class Rfc6265Utils { 580 @Nonnull 581 private static final String SEPARATOR_CHARS = new String(new char[]{ 582 '(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=', '{', '}', ' ' 583 }); 584 585 @Nonnull 586 private static final String DOMAIN_CHARS = 587 "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ.-"; 588 589 public static void validateCookieName(@Nonnull String name) { 590 requireNonNull(name); 591 592 for (int i = 0; i < name.length(); i++) { 593 char c = name.charAt(i); 594 // CTL = <US-ASCII control chars (octets 0 - 31) and DEL (127)> 595 if (c <= 0x1F || c == 0x7F) { 596 throw new IllegalArgumentException( 597 name + ": RFC2616 token cannot have control chars"); 598 } 599 if (SEPARATOR_CHARS.indexOf(c) >= 0) { 600 throw new IllegalArgumentException( 601 name + ": RFC2616 token cannot have separator chars such as '" + c + "'"); 602 } 603 if (c >= 0x80) { 604 throw new IllegalArgumentException( 605 name + ": RFC2616 token can only have US-ASCII: 0x" + Integer.toHexString(c)); 606 } 607 } 608 } 609 610 public static void validateCookieValue(@Nullable String value) { 611 if (value == null) { 612 return; 613 } 614 int start = 0; 615 int end = value.length(); 616 if (end > 1 && value.charAt(0) == '"' && value.charAt(end - 1) == '"') { 617 start = 1; 618 end--; 619 } 620 for (int i = start; i < end; i++) { 621 char c = value.charAt(i); 622 if (c < 0x21 || c == 0x22 || c == 0x2c || c == 0x3b || c == 0x5c || c == 0x7f) { 623 throw new IllegalArgumentException( 624 "RFC2616 cookie value cannot have '" + c + "'"); 625 } 626 if (c >= 0x80) { 627 throw new IllegalArgumentException( 628 "RFC2616 cookie value can only have US-ASCII chars: 0x" + Integer.toHexString(c)); 629 } 630 } 631 } 632 633 public static void validateDomain(@Nullable String domain) { 634 if (domain == null || domain.trim().length() == 0) { 635 return; 636 } 637 int char1 = domain.charAt(0); 638 int charN = domain.charAt(domain.length() - 1); 639 if (char1 == '-' || charN == '.' || charN == '-') { 640 throw new IllegalArgumentException("Invalid first/last char in cookie domain: " + domain); 641 } 642 for (int i = 0, c = -1; i < domain.length(); i++) { 643 int p = c; 644 c = domain.charAt(i); 645 if (DOMAIN_CHARS.indexOf(c) == -1 || (p == '.' && (c == '.' || c == '-')) || (p == '-' && c == '.')) { 646 throw new IllegalArgumentException(domain + ": invalid cookie domain char '" + (char) c + "'"); 647 } 648 } 649 } 650 651 public static void validatePath(@Nullable String path) { 652 if (path == null) 653 return; 654 655 // Check for path traversal attempts 656 if (path.contains("..")) { 657 throw new IllegalArgumentException( 658 "Cookie path must not contain '..' segments (path traversal): " + path 659 ); 660 } 661 662 // Check for query strings 663 if (path.contains("?")) { 664 throw new IllegalArgumentException( 665 "Cookie path must not contain query strings: " + path 666 ); 667 } 668 669 // Check for fragments 670 if (path.contains("#")) { 671 throw new IllegalArgumentException( 672 "Cookie path must not contain fragments: " + path 673 ); 674 } 675 676 // Check for backslashes (Windows-style paths) 677 if (path.contains("\\")) { 678 throw new IllegalArgumentException( 679 "Cookie path must not contain backslashes: " + path 680 ); 681 } 682 683 // Check for URL-encoded dots (potential bypass attempt) 684 if (path.contains("%2E") || path.contains("%2e")) { 685 throw new IllegalArgumentException( 686 "Cookie path must not contain encoded dots: " + path 687 ); 688 } 689 690 // Must start with forward slash 691 if (!path.startsWith("/")) { 692 throw new IllegalArgumentException( 693 "Cookie path must start with '/': " + path 694 ); 695 } 696 697 // Check for null bytes 698 if (path.contains("\0") || path.contains("%00")) { 699 throw new IllegalArgumentException( 700 "Cookie path must not contain null bytes: " + path 701 ); 702 } 703 704 for (int i = 0; i < path.length(); i++) { 705 char c = path.charAt(i); 706 if (c < 0x20 || c > 0x7E || c == ';') { 707 throw new IllegalArgumentException(path + ": Invalid cookie path char '" + c + "'"); 708 } 709 } 710 } 711 } 712}