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}