diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/BasicCookieStore.java b/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/BasicCookieStore.java index 01c0901558..ad797cb912 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/BasicCookieStore.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/BasicCookieStore.java @@ -34,6 +34,7 @@ import java.util.Date; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.TreeSet; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; @@ -79,9 +80,29 @@ private void readObject(final ObjectInputStream stream) throws IOException, Clas */ @Override public void addCookie(final Cookie cookie) { + addCookie(cookie, true); + } + + /** + * Adds an {@link Cookie HTTP cookie} received over a connection whose security is described by + * {@code secureConnection}, replacing any existing equivalent cookie. A cookie received over a + * non-secure connection does not replace an existing secure cookie of the same identity. If the + * given cookie has already expired it will not be added, but an existing equivalent cookie will + * still be removed. + * + * @param cookie the {@link Cookie cookie} to be added + * @param secureConnection whether the cookie was received over a secure connection + * + * @since 5.7 + */ + @Override + public void addCookie(final Cookie cookie, final boolean secureConnection) { if (cookie != null) { lock.writeLock().lock(); try { + if (!secureConnection && overlaysSecureCookie(cookie)) { + return; + } final Cookie oldCookie = cookies.ceiling(cookie); if (oldCookie != null && CookieIdentityComparator.INSTANCE.compare(oldCookie, cookie) == 0) { if (cookie instanceof SetCookie) { @@ -103,6 +124,48 @@ public void addCookie(final Cookie cookie) { } } + private boolean overlaysSecureCookie(final Cookie cookie) { + for (final Cookie existing : cookies) { + if (existing.isSecure() + && namesMatch(existing.getName(), cookie.getName()) + && (domainMatch(cookie.getDomain(), existing.getDomain()) + || domainMatch(existing.getDomain(), cookie.getDomain())) + && pathMatch(cookie.getPath(), existing.getPath())) { + return true; + } + } + return false; + } + + private static boolean namesMatch(final String a, final String b) { + return a == null ? b == null : a.equals(b); + } + + private static boolean domainMatch(final String host, final String domain) { + if (host == null || domain == null) { + return false; + } + final String h = host.toLowerCase(Locale.ROOT); + String d = domain.toLowerCase(Locale.ROOT); + if (d.startsWith(".")) { + d = d.substring(1); + } + return h.equals(d) + || h.length() > d.length() && h.endsWith(d) && h.charAt(h.length() - d.length() - 1) == '.'; + } + + private static boolean pathMatch(final String path, final String cookiePath) { + final String p = path == null ? "/" : path; + final String cp = cookiePath == null ? "/" : cookiePath; + if (p.equals(cp)) { + return true; + } + if (p.startsWith(cp)) { + return cp.endsWith("/") || p.charAt(cp.length()) == '/'; + } + return false; + } + /** * Adds an array of {@link Cookie HTTP cookies}. Cookies are added individually and * in the given array order. If any of the given cookies has already expired it will diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/Cookie.java b/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/Cookie.java index 25d7293b58..f1d7c72aa8 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/Cookie.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/Cookie.java @@ -47,6 +47,11 @@ public interface Cookie { String EXPIRES_ATTR = "expires"; String HTTP_ONLY_ATTR = "httponly"; + /** + * @since 5.7 + */ + String SAME_SITE_ATTR = "samesite"; + /** * @since 5.0 */ @@ -181,5 +186,25 @@ default boolean isHttpOnly() { return false; } + /** + * Returns the value of the {@code SameSite} attribute, or {@code null} if the attribute is + * absent or unrecognized. + * + * @since 5.7 + */ + default SameSite getSameSite() { + return SameSite.fromString(getAttribute(SAME_SITE_ATTR)); + } + + /** + * Indicates whether this cookie is host-only, meaning it was set without a {@code Domain} + * attribute and therefore applies only to the exact host that set it. + * + * @since 5.7 + */ + default boolean isHostOnly() { + return !containsAttribute(DOMAIN_ATTR); + } + } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/CookieIdentityComparator.java b/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/CookieIdentityComparator.java index f4893a2669..4708c57fc6 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/CookieIdentityComparator.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/CookieIdentityComparator.java @@ -80,6 +80,9 @@ public int compare(final Cookie c1, final Cookie c2) { } res = p1.compareTo(p2); } + if (res == 0) { + res = Boolean.compare(c1.isHostOnly(), c2.isHostOnly()); + } return res; } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/CookieStore.java b/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/CookieStore.java index 260e91528c..6211d9a5a4 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/CookieStore.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/CookieStore.java @@ -47,6 +47,20 @@ public interface CookieStore { */ void addCookie(Cookie cookie); + /** + * Adds an {@link Cookie} received over a connection whose security is described by + * {@code secureConnection}, replacing any existing equivalent cookie. A cookie received over a + * non-secure connection must not replace an existing secure cookie of the same identity. The + * default implementation ignores the security context and delegates to {@link #addCookie(Cookie)}. + * + * @param cookie the {@link Cookie cookie} to be added + * @param secureConnection whether the cookie was received over a secure connection + * @since 5.7 + */ + default void addCookie(final Cookie cookie, final boolean secureConnection) { + addCookie(cookie); + } + /** * Returns all cookies contained in this store. * diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/SameSite.java b/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/SameSite.java new file mode 100644 index 0000000000..14bd5a63c1 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/cookie/SameSite.java @@ -0,0 +1,87 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package org.apache.hc.client5.http.cookie; + +import java.util.Locale; + +/** + * Enumeration of the values of the {@code SameSite} cookie attribute. + * + * @since 5.7 + */ +public enum SameSite { + + /** + * The cookie is only sent for same-site requests. + */ + STRICT("Strict"), + + /** + * The cookie is sent for same-site requests and top-level cross-site navigations. + */ + LAX("Lax"), + + /** + * The cookie is sent for all requests. A cookie with this value must also be secure. + */ + NONE("None"); + + private final String attributeValue; + + SameSite(final String attributeValue) { + this.attributeValue = attributeValue; + } + + /** + * Returns the canonical attribute value as it appears in a {@code Set-Cookie} header. + */ + public String getAttributeValue() { + return attributeValue; + } + + /** + * Resolves a {@code SameSite} value from a raw attribute value using a case-insensitive match, + * returning {@code null} when the value is absent or unrecognized. + */ + public static SameSite fromString(final String value) { + if (value == null) { + return null; + } + switch (value.trim().toLowerCase(Locale.ROOT)) { + case "strict": + return STRICT; + case "lax": + return LAX; + case "none": + return NONE; + default: + return null; + } + } + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/BasicExpiresHandler.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/BasicExpiresHandler.java index af67fb90c2..cced0d263f 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/BasicExpiresHandler.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/BasicExpiresHandler.java @@ -86,7 +86,7 @@ public void parse(final SetCookie cookie, final String value) throw new MalformedCookieException("Invalid 'expires' attribute: " + value); } - cookie.setExpiryDate(expiry); + cookie.setExpiryDate(CookieExpiryPolicy.cap(expiry, Instant.now())); } @Override diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/BasicMaxAgeHandler.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/BasicMaxAgeHandler.java index 08ebc1d01a..46a922545a 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/BasicMaxAgeHandler.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/BasicMaxAgeHandler.java @@ -26,6 +26,7 @@ */ package org.apache.hc.client5.http.impl.cookie; +import java.math.BigInteger; import java.time.Instant; import org.apache.hc.client5.http.cookie.CommonCookieAttributeHandler; @@ -62,19 +63,19 @@ public void parse(final SetCookie cookie, final String value) if (value == null) { throw new MalformedCookieException("Missing value for 'max-age' attribute"); } - final int age; + final BigInteger age; try { - age = Integer.parseInt(value); + age = new BigInteger(value); } catch (final NumberFormatException e) { - throw new MalformedCookieException ("Invalid 'max-age' attribute: " - + value); + throw new MalformedCookieException("Invalid 'max-age' attribute: " + value); } - if (age <= 0) { + if (age.signum() <= 0) { // RFC 6265 user-agent processing: delta-seconds <= 0 means immediate expiry. cookie.setExpiryDate(Instant.EPOCH); return; } - cookie.setExpiryDate(Instant.now().plusSeconds(age)); + final BigInteger maxSeconds = BigInteger.valueOf(CookieExpiryPolicy.MAX_LIFETIME.getSeconds()); + cookie.setExpiryDate(Instant.now().plusSeconds(age.min(maxSeconds).longValueExact())); } @Override diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/BasicSameSiteHandler.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/BasicSameSiteHandler.java new file mode 100644 index 0000000000..b1889725ea --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/BasicSameSiteHandler.java @@ -0,0 +1,78 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package org.apache.hc.client5.http.impl.cookie; + +import org.apache.hc.client5.http.cookie.CommonCookieAttributeHandler; +import org.apache.hc.client5.http.cookie.Cookie; +import org.apache.hc.client5.http.cookie.CookieOrigin; +import org.apache.hc.client5.http.cookie.MalformedCookieException; +import org.apache.hc.client5.http.cookie.SameSite; +import org.apache.hc.client5.http.cookie.SetCookie; +import org.apache.hc.core5.annotation.Contract; +import org.apache.hc.core5.annotation.ThreadingBehavior; +import org.apache.hc.core5.util.Args; + +/** + * Cookie {@code SameSite} attribute handler. The raw attribute value is retained by the cookie + * specification and exposed through {@link Cookie#getSameSite()}; this handler enforces that a + * {@code SameSite=None} cookie must also be secure. + * + * @since 5.7 + */ +@Contract(threading = ThreadingBehavior.STATELESS) +public class BasicSameSiteHandler extends AbstractCookieAttributeHandler implements CommonCookieAttributeHandler { + + /** + * Default instance of {@link BasicSameSiteHandler}. + */ + public static final BasicSameSiteHandler INSTANCE = new BasicSameSiteHandler(); + + public BasicSameSiteHandler() { + super(); + } + + @Override + public void parse(final SetCookie cookie, final String value) throws MalformedCookieException { + Args.notNull(cookie, "Cookie"); + } + + @Override + public void validate(final Cookie cookie, final CookieOrigin origin) throws MalformedCookieException { + Args.notNull(cookie, "Cookie"); + if (SameSite.NONE == cookie.getSameSite() && !cookie.isSecure()) { + throw new MalformedCookieException("Cookie '" + cookie.getName() + + "' has SameSite=None but is not marked secure"); + } + } + + @Override + public String getAttributeName() { + return Cookie.SAME_SITE_ATTR; + } + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/BasicSecureHandler.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/BasicSecureHandler.java index a3867eb0c3..5d2fe06313 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/BasicSecureHandler.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/BasicSecureHandler.java @@ -61,6 +61,17 @@ public void parse(final SetCookie cookie, final String value) cookie.setSecure(true); } + @Override + public void validate(final Cookie cookie, final CookieOrigin origin) + throws MalformedCookieException { + Args.notNull(cookie, "Cookie"); + Args.notNull(origin, "Cookie origin"); + if (cookie.isSecure() && !origin.isSecure()) { + throw new MalformedCookieException("Cookie '" + cookie.getName() + + "' is marked secure but was received over a non-secure connection"); + } + } + @Override public boolean match(final Cookie cookie, final CookieOrigin origin) { Args.notNull(cookie, "Cookie"); diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/CookieExpiryPolicy.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/CookieExpiryPolicy.java new file mode 100644 index 0000000000..fe1d39cf89 --- /dev/null +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/CookieExpiryPolicy.java @@ -0,0 +1,52 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package org.apache.hc.client5.http.impl.cookie; + +import java.time.Duration; +import java.time.Instant; + +/** + * Applies an upper bound on cookie lifetime. An expiry more than 400 days after the time the + * cookie was received is reduced to that limit. + */ +final class CookieExpiryPolicy { + + static final Duration MAX_LIFETIME = Duration.ofDays(400); + + static Instant cap(final Instant expiry, final Instant now) { + if (expiry == null) { + return null; + } + final Instant limit = now.plus(MAX_LIFETIME); + return expiry.isAfter(limit) ? limit : expiry; + } + + private CookieExpiryPolicy() { + } + +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/LaxExpiresHandler.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/LaxExpiresHandler.java index 2e66745c87..1ea4009d5d 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/LaxExpiresHandler.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/LaxExpiresHandler.java @@ -186,7 +186,7 @@ public void parse(final SetCookie cookie, final String value) throws MalformedCo final Instant expiryDate = ZonedDateTime.of(year, month.getValue(), day, hour, minute, second, 0, ZoneId.of("UTC")).toInstant(); - cookie.setExpiryDate(expiryDate); + cookie.setExpiryDate(CookieExpiryPolicy.cap(expiryDate, Instant.now())); } private void skipDelims(final CharSequence buf, final Tokenizer.Cursor cursor) { diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/LaxMaxAgeHandler.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/LaxMaxAgeHandler.java index eceb51240b..9d2a9b4286 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/LaxMaxAgeHandler.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/LaxMaxAgeHandler.java @@ -26,6 +26,7 @@ */ package org.apache.hc.client5.http.impl.cookie; +import java.math.BigInteger; import java.time.Instant; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -69,14 +70,14 @@ public void parse(final SetCookie cookie, final String value) throws MalformedCo } final Matcher matcher = MAX_AGE_PATTERN.matcher(value); if (matcher.matches()) { - final int age; - try { - age = Integer.parseInt(value); - } catch (final NumberFormatException e) { - return; + final BigInteger age = new BigInteger(value); + final Instant expiryDate; + if (age.signum() >= 0) { + final BigInteger maxSeconds = BigInteger.valueOf(CookieExpiryPolicy.MAX_LIFETIME.getSeconds()); + expiryDate = Instant.now().plusSeconds(age.min(maxSeconds).longValueExact()); + } else { + expiryDate = Instant.EPOCH; } - final Instant expiryDate = age >= 0 ? Instant.now().plusSeconds(age) : - Instant.EPOCH; cookie.setExpiryDate(expiryDate); } } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/RFC6265CookieSpec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/RFC6265CookieSpec.java index c15c78808d..4ee0c2b05c 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/RFC6265CookieSpec.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/RFC6265CookieSpec.java @@ -27,6 +27,7 @@ package org.apache.hc.client5.http.impl.cookie; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; import java.util.Collections; @@ -67,6 +68,12 @@ public class RFC6265CookieSpec implements CookieSpec { private final static char DQUOTE_CHAR = '"'; private final static char ESCAPE_CHAR = '\\'; + private final static String SECURE_PREFIX = "__Secure-"; + private final static String HOST_PREFIX = "__Host-"; + + private final static int MAX_NAME_VALUE_LENGTH = 4096; + private final static int MAX_ATTRIBUTE_VALUE_LENGTH = 1024; + // IMPORTANT! // These private static variables must be treated as immutable and never exposed outside this class private static final Tokenizer.Delimiter TOKEN_DELIMS = Tokenizer.delimiters(EQUAL_CHAR, PARAM_DELIMITER); @@ -126,21 +133,28 @@ public final List parse(final Header header, final CookieOrigin origin) buffer.append(s); cursor = new Tokenizer.Cursor(0, buffer.length()); } - final String name = tokenParser.parseToken(buffer, cursor, TOKEN_DELIMS); - if (name.isEmpty()) { - return Collections.emptyList(); + final String token = tokenParser.parseToken(buffer, cursor, TOKEN_DELIMS); + final String name; + final String value; + if (!cursor.atEnd() && buffer.charAt(cursor.getPos()) == '=') { + cursor.updatePos(cursor.getPos() + 1); + name = token; + value = tokenParser.parseValue(buffer, cursor, VALUE_DELIMS); + if (!cursor.atEnd()) { + cursor.updatePos(cursor.getPos() + 1); + } + } else { + name = ""; + value = token; + if (!cursor.atEnd()) { + cursor.updatePos(cursor.getPos() + 1); + } } - if (cursor.atEnd()) { + if (name.isEmpty() && value.isEmpty()) { return Collections.emptyList(); } - final int valueDelim = buffer.charAt(cursor.getPos()); - cursor.updatePos(cursor.getPos() + 1); - if (valueDelim != '=') { - throw new MalformedCookieException("Cookie value is invalid: '" + header + "'"); - } - final String value = tokenParser.parseValue(buffer, cursor, VALUE_DELIMS); - if (!cursor.atEnd()) { - cursor.updatePos(cursor.getPos() + 1); + if (byteLength(name) + byteLength(value) > MAX_NAME_VALUE_LENGTH) { + return Collections.emptyList(); } final BasicClientCookie cookie = new BasicClientCookie(name, value); cookie.setPath(getDefaultPath(origin)); @@ -162,6 +176,9 @@ public final List parse(final Header header, final CookieOrigin origin) } } } + if (paramValue != null && byteLength(paramValue) > MAX_ATTRIBUTE_VALUE_LENGTH) { + continue; + } cookie.setAttribute(paramName, paramValue); attribMap.put(paramName, paramValue); } @@ -190,6 +207,40 @@ public final void validate(final Cookie cookie, final CookieOrigin origin) for (final CookieAttributeHandler handler: this.attribHandlers) { handler.validate(cookie, origin); } + validateNamePrefix(cookie); + } + + private static void validateNamePrefix(final Cookie cookie) throws MalformedCookieException { + final String name = cookie.getName(); + if (name == null || name.isEmpty()) { + final String value = cookie.getValue(); + if (value != null + && (value.regionMatches(true, 0, HOST_PREFIX, 0, HOST_PREFIX.length()) + || value.regionMatches(true, 0, SECURE_PREFIX, 0, SECURE_PREFIX.length()))) { + throw new MalformedCookieException( + "Nameless cookie value '" + value + "' uses a reserved name prefix"); + } + return; + } + if (name.regionMatches(true, 0, HOST_PREFIX, 0, HOST_PREFIX.length())) { + if (!cookie.isSecure()) { + throw new MalformedCookieException("Cookie '" + name + + "' uses the \"__Host-\" name prefix but is not secure"); + } + if (cookie.containsAttribute(Cookie.DOMAIN_ATTR)) { + throw new MalformedCookieException("Cookie '" + name + + "' uses the \"__Host-\" name prefix but carries a domain attribute"); + } + if (!cookie.containsAttribute(Cookie.PATH_ATTR) || !"/".equals(cookie.getPath())) { + throw new MalformedCookieException("Cookie '" + name + + "' uses the \"__Host-\" name prefix but does not specify an explicit path of \"/\""); + } + } else if (name.regionMatches(true, 0, SECURE_PREFIX, 0, SECURE_PREFIX.length())) { + if (!cookie.isSecure()) { + throw new MalformedCookieException("Cookie '" + name + + "' uses the \"__Secure-\" name prefix but is not secure"); + } + } } @Override @@ -224,10 +275,16 @@ public List
formatCookies(final List cookies) { buffer.append(PARAM_DELIMITER); buffer.append(' '); } - buffer.append(cookie.getName()); + final String cookieName = cookie.getName(); + final boolean named = cookieName != null && !cookieName.isEmpty(); + if (named) { + buffer.append(cookieName); + } final String s = cookie.getValue(); if (s != null) { - buffer.append(EQUAL_CHAR); + if (named) { + buffer.append(EQUAL_CHAR); + } if (containsSpecialChar(s)) { buffer.append(DQUOTE_CHAR); for (int i = 0; i < s.length(); i++) { @@ -266,4 +323,8 @@ boolean containsChars(final CharSequence s, final Tokenizer.Delimiter chars) { return false; } + private static int byteLength(final String s) { + return s != null ? s.getBytes(StandardCharsets.UTF_8).length : 0; + } + } diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/RFC6265LaxSpec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/RFC6265LaxSpec.java index 2a37b1c573..476ac3afec 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/RFC6265LaxSpec.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/RFC6265LaxSpec.java @@ -48,7 +48,8 @@ public RFC6265LaxSpec() { LaxMaxAgeHandler.INSTANCE, BasicSecureHandler.INSTANCE, BasicHttpOnlyHandler.INSTANCE, - LaxExpiresHandler.INSTANCE); + LaxExpiresHandler.INSTANCE, + BasicSameSiteHandler.INSTANCE); } RFC6265LaxSpec(final CommonCookieAttributeHandler... handlers) { diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/RFC6265StrictSpec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/RFC6265StrictSpec.java index 1f9baee302..4fab4c0644 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/RFC6265StrictSpec.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/cookie/RFC6265StrictSpec.java @@ -48,7 +48,8 @@ public RFC6265StrictSpec() { BasicMaxAgeHandler.INSTANCE, BasicSecureHandler.INSTANCE, BasicHttpOnlyHandler.INSTANCE, - new BasicExpiresHandler(DateUtils.STANDARD_PATTERNS)); + new BasicExpiresHandler(DateUtils.STANDARD_PATTERNS), + BasicSameSiteHandler.INSTANCE); } RFC6265StrictSpec(final CommonCookieAttributeHandler... handlers) { diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/ResponseProcessCookies.java b/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/ResponseProcessCookies.java index 25661e813d..5d9680a265 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/ResponseProcessCookies.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/protocol/ResponseProcessCookies.java @@ -121,7 +121,7 @@ private void processCookies( for (final Cookie cookie : cookies) { try { cookieSpec.validate(cookie, cookieOrigin); - cookieStore.addCookie(cookie); + cookieStore.addCookie(cookie, cookieOrigin.isSecure()); if (LOG.isDebugEnabled()) { LOG.debug("{} Cookie accepted [{}]", exchangeId, formatCookie(cookie)); diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientCookieNamePrefix.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientCookieNamePrefix.java new file mode 100644 index 0000000000..65b41ab393 --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientCookieNamePrefix.java @@ -0,0 +1,79 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.client5.http.examples; + +import java.util.List; + +import org.apache.hc.client5.http.cookie.Cookie; +import org.apache.hc.client5.http.cookie.CookieOrigin; +import org.apache.hc.client5.http.cookie.CookieSpec; +import org.apache.hc.client5.http.cookie.MalformedCookieException; +import org.apache.hc.client5.http.impl.cookie.RFC6265StrictSpec; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.message.BasicHeader; + +/** + * This example demonstrates the {@code __Secure-} and {@code __Host-} cookie name prefixes. A + * cookie whose name carries one of these prefixes but does not meet the corresponding requirements + * is rejected by the cookie specification instead of being accepted into the store. + */ +public class ClientCookieNamePrefix { + + public static void main(final String[] args) throws Exception { + final CookieSpec cookieSpec = new RFC6265StrictSpec(); + + // A secure (https) origin. + final CookieOrigin origin = new CookieOrigin("www.example.com", 443, "/", true); + + // A well-formed __Host- cookie: Secure, host-only (no Domain), Path=/. + process(cookieSpec, origin, "__Host-SID=sess-1; Secure; Path=/"); + + // A __Host- cookie that violates the prefix by carrying a Domain attribute. + process(cookieSpec, origin, "__Host-SID=sess-1; Secure; Path=/; Domain=example.com"); + + // A __Secure- cookie that is missing the required Secure attribute. + process(cookieSpec, origin, "__Secure-SID=sess-1; Path=/"); + + // An ordinary cookie is not subject to the prefix rules. + process(cookieSpec, origin, "SID=sess-1; Path=/"); + } + + private static void process( + final CookieSpec cookieSpec, final CookieOrigin origin, final String setCookie) throws Exception { + final Header header = new BasicHeader("Set-Cookie", setCookie); + final List cookies = cookieSpec.parse(header, origin); + for (final Cookie cookie : cookies) { + try { + cookieSpec.validate(cookie, origin); + System.out.println("ACCEPTED: " + setCookie); + } catch (final MalformedCookieException ex) { + System.out.println("REJECTED: " + setCookie + " -> " + ex.getMessage()); + } + } + } + +} diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/cookie/TestBasicCookieAttribHandlers.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/cookie/TestBasicCookieAttribHandlers.java index 3b0d53c843..0090390bf2 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/cookie/TestBasicCookieAttribHandlers.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/cookie/TestBasicCookieAttribHandlers.java @@ -27,6 +27,7 @@ package org.apache.hc.client5.http.impl.cookie; +import java.time.Duration; import java.time.Instant; import java.util.Arrays; @@ -323,6 +324,75 @@ void testBasicSecureMatch() { Assertions.assertTrue(h.match(cookie, origin2)); } + @Test + void testBasicSecureValidate() { + final BasicClientCookie cookie = new BasicClientCookie("name", "value"); + final CookieAttributeHandler h = BasicSecureHandler.INSTANCE; + + final CookieOrigin nonSecureOrigin = new CookieOrigin("somehost", 80, "/stuff", false); + final CookieOrigin secureOrigin = new CookieOrigin("somehost", 443, "/stuff", true); + + // A secure cookie must not be accepted over a non-secure connection. + cookie.setSecure(true); + Assertions.assertThrows(MalformedCookieException.class, () -> h.validate(cookie, nonSecureOrigin)); + Assertions.assertDoesNotThrow(() -> h.validate(cookie, secureOrigin)); + + // A non-secure cookie is unaffected by the origin's security. + cookie.setSecure(false); + Assertions.assertDoesNotThrow(() -> h.validate(cookie, nonSecureOrigin)); + } + + @Test + void testBasicSameSiteValidate() { + final CookieAttributeHandler h = BasicSameSiteHandler.INSTANCE; + final CookieOrigin origin = new CookieOrigin("somehost", 443, "/stuff", true); + + final BasicClientCookie none = new BasicClientCookie("name", "value"); + none.setAttribute(Cookie.SAME_SITE_ATTR, "None"); + // SameSite=None without Secure is rejected. + Assertions.assertThrows(MalformedCookieException.class, () -> h.validate(none, origin)); + // SameSite=None with Secure is accepted. + none.setSecure(true); + Assertions.assertDoesNotThrow(() -> h.validate(none, origin)); + + final BasicClientCookie lax = new BasicClientCookie("name", "value"); + lax.setAttribute(Cookie.SAME_SITE_ATTR, "Lax"); + Assertions.assertDoesNotThrow(() -> h.validate(lax, origin)); + } + + @Test + void testMaxAgeExpiryCappedAt400Days() throws Exception { + final BasicClientCookie cookie = new BasicClientCookie("name", "value"); + final Instant before = Instant.now(); + // A Max-Age of ~68 years must be capped to 400 days from now. + BasicMaxAgeHandler.INSTANCE.parse(cookie, Integer.toString(Integer.MAX_VALUE)); + final Instant expiry = cookie.getExpiryInstant(); + Assertions.assertNotNull(expiry); + Assertions.assertFalse(expiry.isAfter(before.plus(Duration.ofDays(400)).plusSeconds(5))); + Assertions.assertTrue(expiry.isAfter(before.plus(Duration.ofDays(399)))); + } + + @Test + void testMaxAgeLargerThanIntegerAcceptedAndCapped() throws Exception { + final Instant before = Instant.now(); + final Instant upper = before.plus(Duration.ofDays(400)).plusSeconds(5); + final Instant lower = before.plus(Duration.ofDays(399)); + + // Just beyond Integer.MAX_VALUE: must be accepted, not rejected, then capped. + final BasicClientCookie c1 = new BasicClientCookie("name", "value"); + BasicMaxAgeHandler.INSTANCE.parse(c1, "3000000000"); + Assertions.assertNotNull(c1.getExpiryInstant()); + Assertions.assertFalse(c1.getExpiryInstant().isAfter(upper)); + Assertions.assertTrue(c1.getExpiryInstant().isAfter(lower)); + + // Far beyond Long.MAX_VALUE: still accepted and capped rather than overflowing. + final BasicClientCookie c2 = new BasicClientCookie("name", "value"); + BasicMaxAgeHandler.INSTANCE.parse(c2, "99999999999999999999999999"); + Assertions.assertNotNull(c2.getExpiryInstant()); + Assertions.assertFalse(c2.getExpiryInstant().isAfter(upper)); + Assertions.assertTrue(c2.getExpiryInstant().isAfter(lower)); + } + @Test void testBasicSecureInvalidInput() { final CookieAttributeHandler h = new BasicSecureHandler(); diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/cookie/TestBasicCookieStore.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/cookie/TestBasicCookieStore.java index 61ec5f8078..5fede72fee 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/cookie/TestBasicCookieStore.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/cookie/TestBasicCookieStore.java @@ -121,4 +121,87 @@ void testSerialization() throws Exception { } } + private static BasicClientCookie cookie(final String name, final String value, final boolean secure) { + final BasicClientCookie cookie = new BasicClientCookie(name, value); + cookie.setDomain("example.com"); + cookie.setPath("/"); + cookie.setSecure(secure); + return cookie; + } + + @Test + void testSecureCookieNotOverwrittenByNonSecureConnection() { + final BasicCookieStore store = new BasicCookieStore(); + store.addCookie(cookie("SID", "secure-value", true), true); + // A cookie received over a non-secure connection must not overwrite the secure cookie. + store.addCookie(cookie("SID", "insecure-value", false), false); + + final List cookies = store.getCookies(); + Assertions.assertEquals(1, cookies.size()); + Assertions.assertEquals("secure-value", cookies.get(0).getValue()); + } + + @Test + void testSecureCookieOverwrittenBySecureConnection() { + final BasicCookieStore store = new BasicCookieStore(); + store.addCookie(cookie("SID", "old", true), true); + store.addCookie(cookie("SID", "new", true), true); + + final List cookies = store.getCookies(); + Assertions.assertEquals(1, cookies.size()); + Assertions.assertEquals("new", cookies.get(0).getValue()); + } + + @Test + void testSecureCookieNotOverlaidByNonSecureDeeperPath() { + final BasicCookieStore store = new BasicCookieStore(); + store.addCookie(cookie("SID", "secure-value", true), true); // path "/" + // Same name and domain but a deeper path "/app": overlays the secure cookie and is rejected, + // even though it is not an exact identity match. + final BasicClientCookie insecure = new BasicClientCookie("SID", "insecure-value"); + insecure.setDomain("example.com"); + insecure.setPath("/app"); + store.addCookie(insecure, false); + + final List cookies = store.getCookies(); + Assertions.assertEquals(1, cookies.size()); + Assertions.assertEquals("secure-value", cookies.get(0).getValue()); + } + + @Test + void testNonSecureCookieAllowedWhenItDoesNotOverlaySecure() { + final BasicCookieStore store = new BasicCookieStore(); + // Secure cookie at the deeper path "/app". + final BasicClientCookie secure = new BasicClientCookie("SID", "secure-value"); + secure.setDomain("example.com"); + secure.setPath("/app"); + secure.setSecure(true); + store.addCookie(secure, true); + // A non-secure cookie at "/" does not overlay the secure cookie at "/app". + final BasicClientCookie insecure = new BasicClientCookie("SID", "insecure-value"); + insecure.setDomain("example.com"); + insecure.setPath("/"); + store.addCookie(insecure, false); + + Assertions.assertEquals(2, store.getCookies().size()); + } + + @Test + void testHostOnlyAndDomainCookiesCoexist() { + final BasicCookieStore store = new BasicCookieStore(); + // Host-only cookie: no Domain attribute. + final BasicClientCookie hostOnly = new BasicClientCookie("SID", "host-only"); + hostOnly.setDomain("example.com"); + hostOnly.setPath("/"); + store.addCookie(hostOnly); + // Domain cookie: same name, domain and path, but a Domain attribute is present. + final BasicClientCookie domain = new BasicClientCookie("SID", "domain"); + domain.setDomain("example.com"); + domain.setPath("/"); + domain.setAttribute(Cookie.DOMAIN_ATTR, "example.com"); + store.addCookie(domain); + + Assertions.assertEquals(2, store.getCookies().size()); + } + } diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/cookie/TestCookieExpiryPolicy.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/cookie/TestCookieExpiryPolicy.java new file mode 100644 index 0000000000..4be9a22b6e --- /dev/null +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/cookie/TestCookieExpiryPolicy.java @@ -0,0 +1,57 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ + +package org.apache.hc.client5.http.impl.cookie; + +import java.time.Duration; +import java.time.Instant; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class TestCookieExpiryPolicy { + + private static final Instant NOW = Instant.parse("2026-01-01T00:00:00Z"); + + @Test + void testCapsExpiryBeyond400Days() { + final Instant capped = CookieExpiryPolicy.cap(NOW.plus(Duration.ofDays(1000)), NOW); + Assertions.assertEquals(NOW.plus(Duration.ofDays(400)), capped); + } + + @Test + void testLeavesEarlierExpiryUnchanged() { + final Instant near = NOW.plus(Duration.ofDays(10)); + Assertions.assertEquals(near, CookieExpiryPolicy.cap(near, NOW)); + } + + @Test + void testPreservesNull() { + Assertions.assertNull(CookieExpiryPolicy.cap(null, NOW)); + } + +} diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/cookie/TestRFC6265CookieSpec.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/cookie/TestRFC6265CookieSpec.java index 6bb9ad14f6..03e310c8a7 100644 --- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/cookie/TestRFC6265CookieSpec.java +++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/cookie/TestRFC6265CookieSpec.java @@ -35,6 +35,7 @@ import org.apache.hc.client5.http.cookie.Cookie; import org.apache.hc.client5.http.cookie.CookieOrigin; import org.apache.hc.client5.http.cookie.MalformedCookieException; +import org.apache.hc.client5.http.cookie.SameSite; import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.message.BasicHeader; import org.junit.jupiter.api.Assertions; @@ -96,33 +97,79 @@ void testParseCookieWrongHeader() { } @Test - void testParseCookieMissingName() throws Exception { + void testParseNamelessCookieWithLeadingEquals() throws Exception { final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); final Header header = new BasicHeader("Set-Cookie", "=blah ; this = stuff;"); final CookieOrigin origin = new CookieOrigin("host", 80, "/path/", true); final List cookies = cookiespec.parse(header, origin); - Assertions.assertEquals(0, cookies.size()); + Assertions.assertEquals(1, cookies.size()); + Assertions.assertEquals("", cookies.get(0).getName()); + Assertions.assertEquals("blah", cookies.get(0).getValue()); } @Test - void testParseCookieMissingValue1() throws Exception { + void testParseNamelessCookieWithoutEquals() throws Exception { final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); final Header header = new BasicHeader("Set-Cookie", "blah"); final CookieOrigin origin = new CookieOrigin("host", 80, "/path/", true); final List cookies = cookiespec.parse(header, origin); - Assertions.assertEquals(0, cookies.size()); + Assertions.assertEquals(1, cookies.size()); + Assertions.assertEquals("", cookies.get(0).getName()); + Assertions.assertEquals("blah", cookies.get(0).getValue()); } @Test - void testParseCookieMissingValue2() { + void testParseNamelessCookieWithoutEqualsTrailingSemicolon() throws Exception { final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); final Header header = new BasicHeader("Set-Cookie", "blah;"); final CookieOrigin origin = new CookieOrigin("host", 80, "/path/", true); - Assertions.assertThrows(MalformedCookieException.class, () -> - cookiespec.parse(header, origin)); + final List cookies = cookiespec.parse(header, origin); + Assertions.assertEquals(1, cookies.size()); + Assertions.assertEquals("", cookies.get(0).getName()); + Assertions.assertEquals("blah", cookies.get(0).getValue()); + } + + @Test + void testParseEmptyCookieIgnored() throws Exception { + final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); + final CookieOrigin origin = new CookieOrigin("host", 80, "/path/", true); + // Both name and value empty -> ignored. + Assertions.assertTrue(cookiespec.parse(new BasicHeader("Set-Cookie", "; Path=/"), origin).isEmpty()); + Assertions.assertTrue(cookiespec.parse(new BasicHeader("Set-Cookie", "="), origin).isEmpty()); + } + + @Test + void testNamelessCookieWithPrefixValueRejectedUnconditionally() throws Exception { + final RFC6265StrictSpec cookiespec = new RFC6265StrictSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 443, "/", true); + + // A nameless cookie whose value carries the __Host- prefix is rejected even though it is + // secure, host-only and has Path=/ -- i.e. it would satisfy the normal __Host- requirements. + final List host = cookiespec.parse( + new BasicHeader("Set-Cookie", "__Host-SID; Secure; Path=/"), origin); + Assertions.assertEquals(1, host.size()); + Assertions.assertEquals("", host.get(0).getName()); + Assertions.assertThrows(MalformedCookieException.class, + () -> cookiespec.validate(host.get(0), origin)); + + // Likewise for the __Secure- prefix, even when the cookie is secure. + final List secure = cookiespec.parse( + new BasicHeader("Set-Cookie", "__Secure-SID; Secure"), origin); + Assertions.assertThrows(MalformedCookieException.class, + () -> cookiespec.validate(secure.get(0), origin)); + } + + @Test + void testFormatNamelessCookie() { + final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); + final Cookie cookie = new BasicClientCookie("", "foo"); + final List
headers = cookiespec.formatCookies(Collections.singletonList(cookie)); + Assertions.assertEquals(1, headers.size()); + // A nameless cookie is sent as just its value, without a leading '='. + Assertions.assertEquals("foo", headers.get(0).getValue()); } @Test @@ -333,4 +380,194 @@ void testParseCookieMaxAgeOverExpires() throws Exception { Mockito.verify(h2).parse(ArgumentMatchers.any(), ArgumentMatchers.eq("otherstuff")); } + @Test + void testHostPrefixAcceptsSecureHostOnlyRootPathCookie() { + final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 443, "/", true); + final BasicClientCookie cookie = new BasicClientCookie("__Host-SID", "value"); + cookie.setSecure(true); + cookie.setPath("/"); + cookie.setAttribute(Cookie.PATH_ATTR, "/"); + Assertions.assertDoesNotThrow(() -> cookiespec.validate(cookie, origin)); + } + + @Test + void testHostPrefixRejectsInsecureCookie() { + final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 443, "/", true); + final BasicClientCookie cookie = new BasicClientCookie("__Host-SID", "value"); + cookie.setPath("/"); + Assertions.assertThrows(MalformedCookieException.class, + () -> cookiespec.validate(cookie, origin)); + } + + @Test + void testHostPrefixRejectsDomainAttribute() { + final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 443, "/", true); + final BasicClientCookie cookie = new BasicClientCookie("__Host-SID", "value"); + cookie.setSecure(true); + cookie.setPath("/"); + cookie.setAttribute(Cookie.PATH_ATTR, "/"); + cookie.setAttribute(Cookie.DOMAIN_ATTR, "example.com"); + Assertions.assertThrows(MalformedCookieException.class, + () -> cookiespec.validate(cookie, origin)); + } + + @Test + void testHostPrefixRejectsNonRootPath() { + final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 443, "/app", true); + final BasicClientCookie cookie = new BasicClientCookie("__Host-SID", "value"); + cookie.setSecure(true); + cookie.setPath("/app"); + cookie.setAttribute(Cookie.PATH_ATTR, "/app"); + Assertions.assertThrows(MalformedCookieException.class, + () -> cookiespec.validate(cookie, origin)); + } + + @Test + void testSecurePrefixRejectsInsecureCookie() { + final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 443, "/", true); + final BasicClientCookie cookie = new BasicClientCookie("__Secure-SID", "value"); + cookie.setPath("/"); + Assertions.assertThrows(MalformedCookieException.class, + () -> cookiespec.validate(cookie, origin)); + } + + @Test + void testSecurePrefixAcceptsSecureCookie() { + final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 443, "/", true); + final BasicClientCookie cookie = new BasicClientCookie("__Secure-SID", "value"); + cookie.setSecure(true); + // The __Secure- prefix only requires the Secure attribute; a Domain and a non-root Path are permitted. + cookie.setPath("/app"); + cookie.setAttribute(Cookie.DOMAIN_ATTR, "example.com"); + Assertions.assertDoesNotThrow(() -> cookiespec.validate(cookie, origin)); + } + + @Test + void testNamePrefixMatchIsCaseInsensitive() { + final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 443, "/", true); + final BasicClientCookie cookie = new BasicClientCookie("__HOST-SID", "value"); + cookie.setPath("/"); + Assertions.assertThrows(MalformedCookieException.class, + () -> cookiespec.validate(cookie, origin)); + } + + @Test + void testOrdinaryCookieNameNotSubjectToPrefixRules() { + final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 80, "/", false); + final BasicClientCookie cookie = new BasicClientCookie("SID", "value"); + Assertions.assertDoesNotThrow(() -> cookiespec.validate(cookie, origin)); + } + + @Test + void testHostPrefixRejectsDefaultedPath() throws Exception { + final RFC6265StrictSpec cookiespec = new RFC6265StrictSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 443, "/", true); + // No explicit Path attribute: the path is defaulted to "/", which the __Host- prefix must not accept. + final Header header = new BasicHeader("Set-Cookie", "__Host-SID=value; Secure"); + final List cookies = cookiespec.parse(header, origin); + Assertions.assertEquals(1, cookies.size()); + Assertions.assertThrows(MalformedCookieException.class, + () -> cookiespec.validate(cookies.get(0), origin)); + } + + @Test + void testSecurePrefixCookieRejectedFromNonSecureOrigin() throws Exception { + final RFC6265StrictSpec cookiespec = new RFC6265StrictSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 80, "/", false); + final Header header = new BasicHeader("Set-Cookie", "__Secure-SID=value; Secure"); + final List cookies = cookiespec.parse(header, origin); + Assertions.assertEquals(1, cookies.size()); + Assertions.assertThrows(MalformedCookieException.class, + () -> cookiespec.validate(cookies.get(0), origin)); + } + + @Test + void testHostPrefixCookieRejectedFromNonSecureOrigin() throws Exception { + final RFC6265StrictSpec cookiespec = new RFC6265StrictSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 80, "/", false); + final Header header = new BasicHeader("Set-Cookie", "__Host-SID=value; Secure; Path=/"); + final List cookies = cookiespec.parse(header, origin); + Assertions.assertEquals(1, cookies.size()); + Assertions.assertThrows(MalformedCookieException.class, + () -> cookiespec.validate(cookies.get(0), origin)); + } + + @Test + void testSameSiteParsedAndExposed() throws Exception { + final RFC6265StrictSpec cookiespec = new RFC6265StrictSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 443, "/", true); + final Header header = new BasicHeader("Set-Cookie", "SID=value; SameSite=lax"); + final List cookies = cookiespec.parse(header, origin); + Assertions.assertEquals(1, cookies.size()); + // Recognized case-insensitively and exposed as the canonical enumeration value. + Assertions.assertEquals(SameSite.LAX, cookies.get(0).getSameSite()); + } + + @Test + void testSameSiteAbsentOrUnrecognizedYieldsNull() throws Exception { + final RFC6265StrictSpec cookiespec = new RFC6265StrictSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 443, "/", true); + Assertions.assertNull(cookiespec.parse( + new BasicHeader("Set-Cookie", "SID=value"), origin).get(0).getSameSite()); + Assertions.assertNull(cookiespec.parse( + new BasicHeader("Set-Cookie", "SID=value; SameSite=bogus"), origin).get(0).getSameSite()); + } + + @Test + void testSameSiteNoneRequiresSecure() throws Exception { + final RFC6265StrictSpec cookiespec = new RFC6265StrictSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 443, "/", true); + + final List insecure = cookiespec.parse( + new BasicHeader("Set-Cookie", "SID=value; SameSite=None"), origin); + Assertions.assertThrows(MalformedCookieException.class, + () -> cookiespec.validate(insecure.get(0), origin)); + + final List secure = cookiespec.parse( + new BasicHeader("Set-Cookie", "SID=value; SameSite=None; Secure"), origin); + Assertions.assertDoesNotThrow(() -> cookiespec.validate(secure.get(0), origin)); + } + + @Test + void testCookieExceedingSizeLimitIsIgnored() throws Exception { + final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 80, "/", false); + final char[] chars = new char[4100]; + Arrays.fill(chars, 'a'); + final Header header = new BasicHeader("Set-Cookie", "SID=" + new String(chars)); + // name + value exceeds 4096 octets -> the cookie is ignored entirely. + Assertions.assertTrue(cookiespec.parse(header, origin).isEmpty()); + } + + @Test + void testAttributeExceedingSizeLimitIsIgnored() throws Exception { + final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 80, "/", false); + final char[] chars = new char[1100]; + Arrays.fill(chars, 'a'); + final Header header = new BasicHeader("Set-Cookie", "SID=value; Path=/" + new String(chars)); + final List cookies = cookiespec.parse(header, origin); + // The cookie is retained but the oversized attribute is dropped. + Assertions.assertEquals(1, cookies.size()); + Assertions.assertFalse(cookies.get(0).containsAttribute(Cookie.PATH_ATTR)); + } + + @Test + void testHostOnlyFlagReflectsDomainAttribute() throws Exception { + final RFC6265CookieSpec cookiespec = new RFC6265CookieSpec(); + final CookieOrigin origin = new CookieOrigin("host.example.com", 80, "/", false); + Assertions.assertTrue(cookiespec.parse( + new BasicHeader("Set-Cookie", "SID=value"), origin).get(0).isHostOnly()); + Assertions.assertFalse(cookiespec.parse( + new BasicHeader("Set-Cookie", "SID=value; Domain=example.com"), origin).get(0).isHostOnly()); + } + }