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());
+ }
+
}