Repository navigation
fix(netty): keep caller Cookie headers as written on redirects and auth retries #2360
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
feac411
3f3b8de
0bac96e
d1d8552
0e4d781
68c2746
21a2e1d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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. | ||
| Set<String> setByResponse = new HashSet<>(); | ||
| List<Cookie> nextCookies = null; | ||
| for (String header : response.headers().getAll(SET_COOKIE)) { | ||
| Cookie cookie; | ||
| try { | ||
|
|
@@ -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)); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Raw pairs no longer go through
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in 0bac96e with your example: |
||
| 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()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We build
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The comment above only describes the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
|
|
||
| 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()); | ||
|
|
||
|
|
@@ -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); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -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. | ||
|
|
@@ -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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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() <= 0branch below. It adds the name without checking Domain or Path, soSet-Cookie: SID=; Path=/elsewhere; Max-Age=0on a same origin 302 cuts the caller'sSID=minewhile the store keeps its ownSID. Same on a 401 retry, same foraddCookie. We should only add the name when the deletion's path and domain matchnext, 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.holdsSameValuebelow ignores path as well.There was a problem hiding this comment.
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
nextor the request it answered, matched as the store matches them. Cookie objects and header pairs follow the same rule.Matching only
nextwould break a common case:Set-Cookie: S=; Path=/app; Max-Age=0on a logout that redirects to/login. The deletedSwas 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: aSet-Cookiethat is not a deletion now also has to apply tonext. A cookie set for/elsewherewith the value of a stored cookie fornextno longer counts.appliesTorepeats 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 onmain. The description lists that.The tests cover:
SID;Each of these variants fails at least one test: every deletion counted (as on
main), onlynextmatched, the Domain not checked, and a non-deletingSet-Cookiecounted without checking its Path.