org.eclipse.jetty.http.HttpCookieStore Maven / Gradle / Ivy
//
// ========================================================================
// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0
// which is available at https://www.apache.org/licenses/LICENSE-2.0.
//
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
// ========================================================================
//
package org.eclipse.jetty.http;
import java.net.URI;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.function.Predicate;
import org.eclipse.jetty.util.NanoTime;
import org.eclipse.jetty.util.StringUtil;
import org.eclipse.jetty.util.thread.AutoLock;
/**
* A container for {@link HttpCookie}s.
* HTTP cookies are stored along with a {@link URI} via {@link #add(URI, HttpCookie)}
* and retrieved via {@link #match(URI)}, which implements the path matching algorithm
* defined by RFC 6265.
*/
public interface HttpCookieStore
{
/**
* Adds a cookie to this store, if possible.
* The cookie may not be added for various reasons; for example,
* it may be already expired, or its domain attribute does not
* match that of the URI, etc.
* The cookie is associated with the given {@code URI}, so that
* a call to {@link #match(URI)} returns the cookie only if the
* URIs match.
*
* @param uri the {@code URI} associated with the cookie
* @param cookie the cookie to add
* @return whether the cookie has been added
*/
public boolean add(URI uri, HttpCookie cookie);
/**
* @return all the cookies
*/
public List all();
/**
* Returns the cookies that match the given {@code URI}.
*
* @param uri the {@code URI} to match against
* @return a list of cookies that match the given {@code URI}
*/
public List match(URI uri);
/**
* Removes the cookie associated with the given {@code URI}.
*
* @param uri the {@code URI} associated with the cookie to remove
* @param cookie the cookie to remove
* @return whether the cookie has been removed
*/
public boolean remove(URI uri, HttpCookie cookie);
/**
* Removes all the cookies from this store.
*
* @return whether the store modified by this call
*/
public boolean clear();
/**
* An implementation of {@link HttpCookieStore} that does not store any cookie.
*/
public static class Empty implements HttpCookieStore
{
@Override
public boolean add(URI uri, HttpCookie cookie)
{
return false;
}
@Override
public List all()
{
return List.of();
}
@Override
public List match(URI uri)
{
return List.of();
}
@Override
public boolean remove(URI uri, HttpCookie cookie)
{
return false;
}
@Override
public boolean clear()
{
return false;
}
}
/**
* A default implementation of {@link HttpCookieStore}.
*/
public static class Default implements HttpCookieStore
{
private final AutoLock lock = new AutoLock();
private final Map> cookies = new HashMap<>();
@Override
public boolean add(URI uri, HttpCookie cookie)
{
// TODO: reject if cookie size is too big?
String resolvedDomain = resolveDomain(uri, cookie);
if (resolvedDomain == null)
return false;
String resolvedPath = resolvePath(uri, cookie);
// Cookies are stored under their resolved domain, so that:
// - add(sub.example.com, cookie[Domain]=null) => key=sub.example.com
// - add(sub.example.com, cookie[Domain]=example.com) => key=example.com
// This facilitates the matching algorithm.
boolean[] added = new boolean[1];
StoredHttpCookie storedCookie = new StoredHttpCookie(cookie, uri, resolvedDomain, resolvedPath);
try (AutoLock ignored = lock.lock())
{
String key = resolvedDomain.toLowerCase(Locale.ENGLISH);
cookies.compute(key, (k, v) ->
{
// RFC 6265, section 4.1.2.
// Evict an existing cookie with
// same name, domain and path.
if (v != null)
v.remove(storedCookie);
// Add only non-expired cookies.
if (cookie.isExpired())
return v == null || v.isEmpty() ? null : v;
added[0] = true;
if (v == null)
v = new ArrayList<>();
v.add(storedCookie);
return v;
});
}
return added[0];
}
private String resolveDomain(URI uri, HttpCookie cookie)
{
String uriDomain = uri.getHost();
if (uriDomain == null)
return null;
String cookieDomain = cookie.getDomain();
// No explicit cookie domain, use the origin domain.
if (cookieDomain == null)
return uriDomain;
String resolvedDomain = cookieDomain;
if (resolvedDomain.startsWith("."))
resolvedDomain = cookieDomain.substring(1);
// RFC 6265 section 4.1.2.3, ignore Domain if ends with ".".
if (resolvedDomain.endsWith("."))
resolvedDomain = uriDomain;
if (!allowDomain(resolvedDomain))
return null;
// Reject if the resolved domain is not either
// the same or a parent domain of the URI domain.
if (!isSameOrSubDomain(uriDomain, resolvedDomain))
return null;
return resolvedDomain;
}
private String resolvePath(URI uri, HttpCookie cookie)
{
// RFC 6265, section 5.1.4 and 5.2.4.
// Note that cookies with the Path attribute different from the
// URI path are accepted, as specified in sections 8.5 and 8.6.
String resolvedPath = cookie.getPath();
if (resolvedPath == null || !resolvedPath.startsWith("/"))
{
String uriPath = uri.getRawPath();
if (StringUtil.isBlank(uriPath) || !uriPath.startsWith("/"))
{
resolvedPath = "/";
}
else
{
int lastSlash = uriPath.lastIndexOf('/');
resolvedPath = uriPath.substring(0, lastSlash);
if (resolvedPath.isEmpty())
resolvedPath = "/";
}
}
return resolvedPath;
}
/**
* Returns whether the given domain should be allowed to associate cookies to.
* Currently rejects "top-level" domains such as "com" or "org", so that it will not be
* possible to associate cookies to those domains.
* Unfortunately, it allows for "top-level" domains that have multiple labels such as
* "co.uk" or "gov.au".
* RFC 6265 prohibits domains that are IP addresses, but this method supports them
* (both IPv4 and IPv6, the latter must be bracketed) for testing purposes.
*
* @param domain the domain to test
* @return whether the domain should be allowed to associate cookies to
*/
protected boolean allowDomain(String domain)
{
// Reject top-level domains such as "com", "net", etc. to
// disallow "super-cookies" that would apply to all domains.
// A precise rejection is really complicated because there are "top"
// level domains that look like subdomains, such as co.uk, gov.au, etc.
// See https://publicsuffix.org/.
if (domain.endsWith("."))
domain = domain.substring(0, domain.length() - 1);
// Allow normal domains such as example.com, IPv4 addresses,
// but unfortunately also "top" level domains such as co.uk.
if (domain.contains("."))
return true;
// Support localhost for testing.
if (domain.equals("localhost"))
return true;
// Support IPv6 for testing.
return domain.startsWith("[") && domain.endsWith("]");
}
@Override
public List all()
{
try (AutoLock ignored = lock.lock())
{
return cookies.values().stream()
.flatMap(Collection::stream)
.filter(Predicate.not(StoredHttpCookie::isExpired))
.map(HttpCookie.class::cast)
.toList();
}
}
@Override
public List match(URI uri)
{
String uriDomain = uri.getHost();
if (uriDomain == null)
return List.of();
String path = uri.getRawPath();
if (path == null || path.isBlank())
path = "/";
boolean secure = HttpScheme.isSecure(uri.getScheme());
List result = new ArrayList<>();
try (AutoLock ignored = lock.lock())
{
// Given the way cookies are stored in the Map, the matching
// algorithm starts with the URI domain and iterates chopping
// its subdomains, accumulating the results.
// For example, for uriDomain = sub.example.com, the cookies
// Map is accessed with the following Keys:
// - Key[domain=sub.example.com]
// - chop domain to example.com
// - Key[domain=example.com]
// - chop domain to com
// invalid domain, exit iteration.
String domain = uriDomain.toLowerCase(Locale.ENGLISH);
while (domain != null)
{
List stored = cookies.get(domain);
Iterator iterator = stored == null ? Collections.emptyIterator() : stored.iterator();
while (iterator.hasNext())
{
StoredHttpCookie cookie = iterator.next();
// Check and remove expired cookies.
if (cookie.isExpired())
{
iterator.remove();
continue;
}
// Check whether the cookie is secure.
if (cookie.isSecure() && !secure)
continue;
// Match the domain.
if (!domainMatches(uriDomain, cookie.domain, cookie.getWrapped().getDomain()))
continue;
// Match the path.
if (!pathMatches(path, cookie.path))
continue;
result.add(cookie);
}
domain = parentDomain(domain);
}
}
return result;
}
private static boolean domainMatches(String uriDomain, String domain, String cookieDomain)
{
// If the cookie has no domain, or ends with ".", it must only be sent to the origin domain.
if (cookieDomain == null || cookieDomain.endsWith("."))
return uriDomain.equalsIgnoreCase(domain);
return isSameOrSubDomain(uriDomain, cookieDomain);
}
private static boolean isSameOrSubDomain(String subDomain, String domain)
{
int subDomainLength = subDomain.length();
int domainLength = domain.length();
// Case-insensitive version of subDomain.endsWith(domain).
if (!subDomain.regionMatches(true, subDomainLength - domainLength, domain, 0, domainLength))
return false;
// Make sure it is a subdomain.
int beforeMatch = subDomainLength - domainLength - 1;
// Domains are the same.
if (beforeMatch < 0)
return true;
// Verify it is a proper subdomain such as bar.foo.com,
// not just a suffix of a domain such as bazfoo.com.
return subDomain.charAt(beforeMatch) == '.';
}
private static boolean pathMatches(String uriPath, String cookiePath)
{
if (cookiePath == null)
return true;
// RFC 6265, section 5.1.4, path matching algorithm.
if (uriPath.equals(cookiePath))
return true;
if (uriPath.startsWith(cookiePath))
return cookiePath.endsWith("/") || uriPath.charAt(cookiePath.length()) == '/';
return false;
}
@Override
public boolean remove(URI uri, HttpCookie cookie)
{
String uriDomain = uri.getHost();
if (uriDomain == null)
return false;
String resolvedPath = resolvePath(uri, cookie);
boolean[] removed = new boolean[1];
try (AutoLock ignored = lock.lock())
{
String domain = uriDomain.toLowerCase(Locale.ENGLISH);
while (domain != null)
{
cookies.compute(domain, (k, v) ->
{
if (v == null)
return null;
Iterator iterator = v.iterator();
while (iterator.hasNext())
{
StoredHttpCookie storedCookie = iterator.next();
if (uriDomain.equalsIgnoreCase(storedCookie.uri.getHost()))
{
if (storedCookie.path.equals(resolvedPath))
{
if (storedCookie.getWrapped().getName().equals(cookie.getName()))
{
iterator.remove();
removed[0] = true;
}
}
}
}
return v.isEmpty() ? null : v;
});
domain = parentDomain(domain);
}
}
return removed[0];
}
private String parentDomain(String domain)
{
int dot = domain.indexOf('.');
if (dot < 0)
return null;
// Remove one subdomain.
domain = domain.substring(dot + 1);
// Exit if the top-level domain was reached.
if (domain.indexOf('.') < 0)
return null;
return domain;
}
@Override
public boolean clear()
{
try (AutoLock ignored = lock.lock())
{
if (cookies.isEmpty())
return false;
cookies.clear();
return true;
}
}
private static class StoredHttpCookie extends HttpCookie.Wrapper
{
private final long creationNanoTime = NanoTime.now();
private final URI uri;
private final String domain;
private final String path;
private StoredHttpCookie(HttpCookie wrapped, URI uri, String domain, String path)
{
super(wrapped);
this.uri = Objects.requireNonNull(uri);
this.domain = Objects.requireNonNull(domain);
this.path = Objects.requireNonNull(path);
}
@Override
public boolean isExpired()
{
long maxAge = getMaxAge();
if (maxAge >= 0 && NanoTime.secondsSince(creationNanoTime) > maxAge)
return true;
Instant expires = getExpires();
return expires != null && Instant.now().isAfter(expires);
}
@Override
public int hashCode()
{
return Objects.hash(getWrapped().getName(), domain.toLowerCase(Locale.ENGLISH), path);
}
@Override
public boolean equals(Object obj)
{
if (this == obj)
return true;
if (!(obj instanceof StoredHttpCookie that))
return false;
return getName().equals(that.getName()) &&
domain.equalsIgnoreCase(that.domain) &&
path.equals(that.path);
}
}
}
}