Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,21 @@
import io.netty.handler.codec.http.HttpResponse;
import io.netty.handler.codec.http.cookie.ClientCookieDecoder;
import io.netty.handler.codec.http.cookie.Cookie;
import io.netty.handler.codec.http.cookie.ServerCookieDecoder;
import org.asynchttpclient.Request;
import org.asynchttpclient.RequestBuilder;
import org.asynchttpclient.cookie.CookieStore;
import org.asynchttpclient.uri.Uri;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;

import static io.netty.handler.codec.http.HttpHeaderNames.COOKIE;
import static io.netty.handler.codec.http.HttpHeaderNames.SET_COOKIE;
import static org.asynchttpclient.util.HttpUtils.cookiePairName;
import static org.asynchttpclient.util.HttpUtils.cookiePairs;

/**
* Tells the cookies a caller put on a request from the ones the cookie store added, for the request a redirect
Expand All @@ -44,21 +44,32 @@ private CallerCookies() {
}

/**
* The request's cookies minus the ones the store put there: those this response set, rotated or deleted,
* and those the store still holds with the same value. A caller's cookie sharing only a name with a stored
* one stays the caller's.
* The request's cookie objects minus the ones the store put there: those this response set, rotated or
* deleted, and those the store still holds with the same value. A caller's cookie sharing only a name with
* a stored one stays the caller's. A Cookie header the caller set is not among these; see
* {@link #headerCookies}.
*/
static List<Cookie> of(Request request, HttpResponse response, CookieStore cookieStore, Uri next,
ClientCookieDecoder cookieDecoder) {
List<Cookie> cookies = withHeaderCookies(request);
private static List<Cookie> of(List<Cookie> cookies, List<Cookie> stored, Set<String> setByResponse) {
if (cookies.isEmpty()) {
return cookies;
}
// Only what the store took counts: a Set-Cookie it refused, or one for another path, leaves the
// caller's cookie of that name in place. Names folded as the store keys them, so a SID set here also
List<Cookie> callers = new ArrayList<>(cookies.size());
for (Cookie cookie : cookies) {
if (!setByResponse.contains(cookie.name().toLowerCase(Locale.ROOT)) && !holdsSameValue(stored, cookie)) {
callers.add(cookie);
}
}
return callers;
}

private static Set<String> replacedNames(HttpResponse response, List<Cookie> nextCookies, Uri current, Uri next,
ClientCookieDecoder cookieDecoder) {
// A Set-Cookie replaces the caller's cookie of that name only where its Domain and Path apply, matched as
// the store matches them, and one that does not delete only if the store now holds its value for the next
// request. The store's other checks are not repeated: a deletion it refuses for another reason, such as a
// public suffix as Domain, still counts. Names folded as the store keys them, so a SID set here also
// replaces a request's sid.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't hold for the maxAge() <= 0 branch below. It adds the name without checking Domain or Path, so Set-Cookie: SID=; Path=/elsewhere; Max-Age=0 on a same origin 302 cuts the caller's SID=mine while the store keeps its own SID. Same on a 401 retry, same for addCookie. We should only add the name when the deletion's path and domain match next, like the store does (RFC 6265 5.3 steps 6 and 11). Predates this PR, of() had the same branch, but now it hits raw headers directly. holdsSameValue below ignores path as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and fixed in 3f3b8de. A deletion now counts only if its Domain and Path match next or the request it answered, matched as the store matches them. Cookie objects and header pairs follow the same rule.

Matching only next would break a common case: Set-Cookie: S=; Path=/app; Max-Age=0 on a logout that redirects to /login. The deleted S was sent with the logout request, either added by the store or as a caller's pair that may be a copy of it. Once the store has deleted it, of() can no longer tell it from a caller's own cookie, so it would follow the redirect to /login. So a deletion that applies to the request it answered still counts.

holdsSameValue: a Set-Cookie that is not a deletion now also has to apply to next. A cookie set for /elsewhere with the value of a stored cookie for next no longer counts.

appliesTo repeats only the store's Domain and Path matching, not its other checks. A deletion that matches but that the store refuses, for example with a public suffix as Domain or as a Secure cookie over plaintext, still counts, as on main. The description lists that.

The tests cover:

  • your example on a 302 and on a 401 retry, for cookie objects and header pairs, with and without a stored SID;
  • a deletion for another Domain, which the store refuses;
  • the logout case for cookie objects and header pairs, also beside a second stored cookie of that name, which then follows the redirect.

Each of these variants fails at least one test: every deletion counted (as on main), only next matched, the Domain not checked, and a non-deleting Set-Cookie counted without checking its Path.

Set<String> setByResponse = new HashSet<>();
List<Cookie> nextCookies = null;
for (String header : response.headers().getAll(SET_COOKIE)) {
Cookie cookie;
try {
Expand All @@ -70,54 +81,141 @@ static List<Cookie> of(Request request, HttpResponse response, CookieStore cooki
if (cookie == null) {
continue;
}
String name = cookie.name().toLowerCase(Locale.ROOT);
if (cookie.maxAge() != Cookie.UNDEFINED_MAX_AGE && cookie.maxAge() <= 0) {
setByResponse.add(cookie.name().toLowerCase(Locale.ROOT));
// A deletion also counts if it applies only to the request it answered. The cookie it deleted
// there was sent with that request: once gone from the store, it can no longer be told from
// the caller's, and a caller's pair may be a copy of it.
if (appliesTo(cookie, current, current) || appliesTo(cookie, current, next)) {
setByResponse.add(name);
}
continue;
}
if (nextCookies == null) {
nextCookies = cookieStore.get(next);
}
if (holdsSameValue(nextCookies, cookie)) {
setByResponse.add(cookie.name().toLowerCase(Locale.ROOT));
if (appliesTo(cookie, current, next) && holdsSameValue(nextCookies, cookie)) {
setByResponse.add(name);
}
}
List<Cookie> stored = cookieStore.get(request.getUri());
List<Cookie> callers = new ArrayList<>(cookies.size());
for (Cookie cookie : cookies) {
if (!setByResponse.contains(cookie.name().toLowerCase(Locale.ROOT)) && !holdsSameValue(stored, cookie)) {
callers.add(cookie);
}
}
return callers;
return setByResponse;
}

/**
* For a retry of the same request: the caller's cookies, then the store's current ones.
*/
static void refresh(RequestBuilder retry, Request request, HttpResponse response, CookieStore cookieStore,
ClientCookieDecoder cookieDecoder) {
retry.setCookies(of(request, response, cookieStore, request.getUri(), cookieDecoder));
retry.setHeader(COOKIE, Collections.emptyList());
for (Cookie cookie : cookieStore.get(request.getUri())) {
refresh(retry, request, response, cookieStore, request.getUri(), cookieDecoder);
}

static void refresh(RequestBuilder retry, Request request, HttpResponse response, CookieStore cookieStore,
Uri next, ClientCookieDecoder cookieDecoder) {
List<Cookie> nextCookies = cookieStore.get(next);
Set<String> setByResponse = replacedNames(response, nextCookies, request.getUri(), next, cookieDecoder);
List<Cookie> cookies = request.getCookies();
// A retry goes to the same URI again, so one lookup serves both.
List<Cookie> stored = cookies.isEmpty() || next.equals(request.getUri())
? nextCookies : cookieStore.get(request.getUri());
retry.setCookies(of(cookies, stored, setByResponse));
retry.setHeader(COOKIE, headerCookies(request.getHeaders().getAll(COOKIE), setByResponse));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raw pairs no longer go through holdsSameValue(stored, ...). Before, a raw pair equal to a stored cookie counted as the store's and was dropped once the redirect left that cookie's Path. Now it follows as written. Seed P=scoped; Path=/p, GET /p/a with Cookie: P=scoped, 302 to /q/b: main sends no Cookie, this sends P=scoped. I think the new behaviour is the right one, but it needs a test and a line in the description.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 0bac96e with your example: aRawPairEqualToAStoredCookieFollowsARedirectOutOfItsPath. /q/b receives P=scoped; SID=old, since the seed also stores SID; on main it receives only SID=old. The description now covers it under Behavior changes.

for (Cookie cookie : nextCookies) {
retry.addCookieIfUnset(cookie);
}
}

/**
* The request's cookies plus the pairs of a Cookie header the caller set. A hop drops the raw header and
* sends these instead, reconciled against the response, or the header would outrank a cookie it rotated.
* Only the request the caller built sends the header as written.
* The caller's Cookie header values minus the pairs this response replaced, kept as text: a lenient decode
* followed by strict encoding can reject a value that was sent on the first attempt.
* <p>
* The pairs come from {@code HttpUtils.cookiePairs}, as in {@code NettyRequestFactory#mergeCookies} when it
* decides which names the caller set, so every ';' ends one. Honouring quotes here would let a stray '"' hide
* a replaced pair in its neighbour's value, to be resent stale while the merge still counts its name as the
* caller's and drops the store's new value. Unlike the merge, names are folded as the store folds them, so
* replacing SID also cuts a pair named sid. Only the replaced pairs are cut out, so the rest keeps its bytes
* and a header that loses none is returned as is.
*/
private static List<Cookie> withHeaderCookies(Request request) {
List<String> headers = request.getHeaders().getAll(COOKIE);
if (headers.isEmpty()) {
return request.getCookies();
private static List<String> headerCookies(List<String> headers, Set<String> replaced) {
if (replaced.isEmpty() || headers.isEmpty()) {
return headers;
}
List<Cookie> cookies = new ArrayList<>(request.getCookies());
List<String> remaining = new ArrayList<>(headers.size());
for (String header : headers) {
cookies.addAll(ServerCookieDecoder.LAX.decodeAll(header));
String[] pairs = cookiePairs(header);
if (!anyReplaced(pairs, replaced)) {
remaining.add(header);
continue;
}
StringBuilder kept = new StringBuilder(header.length());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We build kept and a trimmed name for every pair even when nothing gets removed and then throw it all away. Check the names first, or create the builder on the first removal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in d1d8552. The pair names are checked first, and a header without a replaced pair is returned as is, before anything is built.

for (String pair : pairs) {
if (!isReplaced(pair, replaced)) {
if (kept.length() > 0) {
kept.append(';');
}
kept.append(pair);
}
}
// Cutting the first or last pair leaves the whitespace or ';' that separated it at that end.
int from = 0;
int to = kept.length();
while (from < to && (kept.charAt(from) == ';' || kept.charAt(from) <= ' ')) {
from++;
}
while (to > from && (kept.charAt(to - 1) == ';' || kept.charAt(to - 1) <= ' ')) {
to--;
}
if (from < to) {
remaining.add(kept.substring(from, to));
}
}
return remaining;
}

private static boolean anyReplaced(String[] pairs, Set<String> replaced) {
for (String pair : pairs) {
if (isReplaced(pair, replaced)) {
return true;
}
}
return false;
}

private static boolean isReplaced(String pair, Set<String> replaced) {
String name = cookiePairName(pair);
return !name.isEmpty() && replaced.contains(name.toLowerCase(Locale.ROOT));
}

/**
* Whether the Domain and Path of a cookie in the response to {@code answered} match {@code uri}, as the store
* matches them (RFC 6265 sections 5.1.3 and 5.1.4). Only for a {@code uri} on the same host, the only kind a
* retry or a redirect that keeps the caller's cookies goes to.
*/
private static boolean appliesTo(Cookie cookie, Uri answered, Uri uri) {
String domain = cookie.domain() == null ? "" : canonicalHost(cookie.domain());
if (domain.startsWith(".")) {
domain = domain.substring(1);
}
if (!domain.isEmpty()) {
String host = canonicalHost(uri.getHost());
if (!host.equals(domain) && !host.endsWith('.' + domain)) {
return false;
}
}
String path = cookie.path();
if (path == null || path.isEmpty() || path.charAt(0) != '/') {
String answeredPath = answered.getPath();
int lastSlash = answeredPath.lastIndexOf('/');
path = answeredPath.startsWith("/") && lastSlash > 0 ? answeredPath.substring(0, lastSlash) : "/";
}
String uriPath = uri.getPath().isEmpty() ? "/" : uri.getPath();
return uriPath.startsWith(path) && (uriPath.length() == path.length()
|| path.charAt(path.length() - 1) == '/' || uriPath.charAt(path.length()) == '/');
}

private static String canonicalHost(String host) {
String lowered = host.toLowerCase(Locale.ROOT);
int end = lowered.length();
while (end > 0 && lowered.charAt(end - 1) == '.') {
end--;
}
return cookies;
return lowered.substring(0, end);
}

private static boolean holdsSameValue(List<Cookie> stored, Cookie cookie) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,19 +203,19 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture<?>
.setRangeOffset(request.getRangeOffset());
}
// A same-origin hop keeps the caller's cookies but not the store's: those are its values from
// before this response and would outrank what it just set. The store adds its current ones below.
// before this response and would outrank what it just set. Without a store, every cookie is the
// caller's. With one, CallerCookies.refresh tells them apart below and adds the store's current ones.
CookieStore cookieStore = config.getCookieStore();
if (stripAuth) {
requestBuilder.resetCookies();
} else {
requestBuilder.setCookies(cookieStore == null
? request.getCookies() : CallerCookies.of(request, response, cookieStore, newUri, cookieDecoder));
} else if (cookieStore == null) {
requestBuilder.setCookies(request.getCookies());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above only describes the cookieStore == null case now. With a store the split happens in CallerCookies.refresh further down.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in 68c2746. It now says that without a store every cookie is the caller's, and that with one CallerCookies.refresh tells them apart below.

}

requestBuilder.setMethod(switchToGet ? GET : originalMethod)
.setFollowRedirect(true)
.setRealm(stripAuth ? null : request.getRealm())
.setHeaders(propagatedHeaders(request, realm, keepBody, stripAuth, cookieStore != null))
.setHeaders(propagatedHeaders(request, realm, keepBody, stripAuth))
.setRequestTimeout(request.getRequestTimeout())
.setReadTimeout(request.getReadTimeout());

Expand Down Expand Up @@ -253,9 +253,13 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture<?>
final boolean initialConnectionKeepAlive = future.isKeepAlive();

if (cookieStore != null) {
// Update request's cookies assuming that cookie store is already updated by Interceptors
for (Cookie cookie : cookieStore.get(newUri)) {
requestBuilder.addCookieIfUnset(cookie);
if (stripAuth) {
// Only cookies scoped to the new URI may cross this security boundary.
for (Cookie cookie : cookieStore.get(newUri)) {
requestBuilder.addCookieIfUnset(cookie);
}
} else {
CallerCookies.refresh(requestBuilder, request, response, cookieStore, newUri, cookieDecoder);
}
}

Expand Down Expand Up @@ -456,8 +460,8 @@ private enum BodyRepresentation {
NONE
}

private static HttpHeaders propagatedHeaders(Request request, Realm realm, boolean keepBody, boolean stripAuthorization,
boolean cookiesReconciled) {
private static HttpHeaders propagatedHeaders(Request request, Realm realm, boolean keepBody,
boolean stripAuthorization) {
HttpHeaders headers = request.getHeaders().copy().remove(HOST);

// Preserve an explicit length when the selected stream representation cannot rebuild it.
Expand All @@ -480,10 +484,6 @@ private static HttpHeaders propagatedHeaders(Request request, Realm realm, boole
headers.remove(AUTHORIZATION)
.remove(PROXY_AUTHORIZATION);
}
if (cookiesReconciled) {
// CallerCookies carries its pairs in the cookie list.
headers.remove(COOKIE);
}
return headers;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
import static org.asynchttpclient.util.AuthenticatorUtils.perRequestProxyAuthorizationHeader;
import static org.asynchttpclient.util.HttpUtils.ACCEPT_ALL_HEADER_VALUE;
import static org.asynchttpclient.util.HttpUtils.GZIP_DEFLATE;
import static org.asynchttpclient.util.HttpUtils.cookiePairName;
import static org.asynchttpclient.util.HttpUtils.cookiePairs;
import static org.asynchttpclient.util.HttpUtils.filterOutBrotliFromAcceptEncoding;
import static org.asynchttpclient.util.HttpUtils.filterOutZstdFromAcceptEncoding;
import static org.asynchttpclient.util.HttpUtils.hostHeader;
Expand Down Expand Up @@ -203,10 +205,9 @@ private void mergeCookies(List<Cookie> cookies, HttpHeaders headers) {
merged.append("; ");
}
merged.append(callerHeader, start, end);
for (String pair : callerHeader.split(";")) {
int eq = pair.indexOf('=');
// Case-sensitive, but trimmed: "a = 1" still names "a".
String name = (eq >= 0 ? pair.substring(0, eq) : pair).trim();
for (String pair : cookiePairs(callerHeader)) {
// Case-sensitive, unlike CallerCookies, which folds names as the store does.
String name = cookiePairName(pair);
if (!name.isEmpty()) {
callerNames.add(name);
}
Expand Down
17 changes: 17 additions & 0 deletions client/src/main/java/org/asynchttpclient/util/HttpUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,23 @@ private static void encodeAndAppendFormField(StringBuilder sb, String field, Cha
}
}

/**
* The name=value pairs of a {@code Cookie} header value, as written. Every ';' ends a pair, inside quotes too:
* RFC 6265 does not allow one in a cookie value.
*/
public static String[] cookiePairs(String cookieHeader) {
return cookieHeader.split(";", -1);
}

/**
* The name of a pair from {@link #cookiePairs}: the text before its first '=', or all of it, trimmed, so
* "a = 1" names "a".
*/
public static String cookiePairName(String pair) {
int eq = pair.indexOf('=');
return (eq >= 0 ? pair.substring(0, eq) : pair).trim();
}

public static CharSequence filterOutBrotliFromAcceptEncoding(String acceptEncoding) {
// we don't support Brotly ATM
if (acceptEncoding.endsWith(BROTLY_ACCEPT_ENCODING_SUFFIX)) {
Expand Down
Loading
Loading