-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Implement custom redirect handling to fix lost Set-Cookie on redirects #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
Changes from 1 commit
4b77d2a
4586df8
8b93c49
19af2d4
a248fca
a866fe2
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 |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| // Copyright (c) .NET Foundation and Contributors | ||
| // | ||
| // Licensed 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. | ||
| // | ||
|
|
||
| namespace RestSharp; | ||
|
|
||
| /// <summary> | ||
| /// Options for controlling redirect behavior when RestSharp handles redirects. | ||
| /// </summary> | ||
| public class RedirectOptions { | ||
| /// <summary> | ||
| /// Whether to follow redirects. Default is true. | ||
| /// </summary> | ||
| public bool FollowRedirects { get; set; } = true; | ||
|
|
||
| /// <summary> | ||
| /// Whether to follow redirects from HTTPS to HTTP (insecure). Default is false. | ||
| /// </summary> | ||
| public bool FollowRedirectsToInsecure { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Whether to forward request headers on redirect. Default is true. | ||
| /// </summary> | ||
| public bool ForwardHeaders { get; set; } = true; | ||
|
|
||
| /// <summary> | ||
| /// Whether to forward the Authorization header on redirect. Default is false. | ||
| /// </summary> | ||
| public bool ForwardAuthorization { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Whether to forward cookies on redirect. Default is true. | ||
| /// Cookies from Set-Cookie headers are always stored in the CookieContainer regardless of this setting. | ||
| /// </summary> | ||
| public bool ForwardCookies { get; set; } = true; | ||
|
|
||
| /// <summary> | ||
| /// Whether to forward the request body on redirect when the HTTP verb is preserved. Default is true. | ||
| /// Body is always dropped when the verb changes to GET. | ||
| /// </summary> | ||
| public bool ForwardBody { get; set; } = true; | ||
|
|
||
| /// <summary> | ||
| /// Whether to forward original query string parameters on redirect. Default is true. | ||
| /// </summary> | ||
| public bool ForwardQuery { get; set; } = true; | ||
|
|
||
| /// <summary> | ||
| /// Maximum number of redirects to follow. Default is 50. | ||
| /// </summary> | ||
| public int MaxRedirects { get; set; } = 50; | ||
|
|
||
| /// <summary> | ||
| /// HTTP status codes that are considered redirects. | ||
| /// </summary> | ||
| public IReadOnlyList<HttpStatusCode> RedirectStatusCodes { get; set; } = [ | ||
| HttpStatusCode.MovedPermanently, // 301 | ||
| HttpStatusCode.Found, // 302 | ||
| HttpStatusCode.SeeOther, // 303 | ||
| HttpStatusCode.TemporaryRedirect, // 307 | ||
| (HttpStatusCode)308, // 308 Permanent Redirect | ||
| ]; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -111,24 +111,18 @@ async Task<HttpResponse> ExecuteRequestAsync(RestRequest request, CancellationTo | |
| await authenticator.Authenticate(this, request).ConfigureAwait(false); | ||
| } | ||
|
|
||
| using var requestContent = new RequestContent(this, request); | ||
| var contentToDispose = new List<RequestContent>(); | ||
| var initialContent = new RequestContent(this, request); | ||
| contentToDispose.Add(initialContent); | ||
|
|
||
| var httpMethod = AsHttpMethod(request.Method); | ||
| var urlString = this.BuildUriString(request); | ||
| var url = new Uri(urlString); | ||
|
|
||
| using var message = new HttpRequestMessage(httpMethod, urlString); | ||
| message.Content = requestContent.BuildContent(); | ||
| message.Headers.Host = Options.BaseHost; | ||
| message.Headers.CacheControl = request.CachePolicy ?? Options.CachePolicy; | ||
| message.Version = request.Version; | ||
| var url = new Uri(this.BuildUriString(request)); | ||
|
|
||
| using var timeoutCts = new CancellationTokenSource(request.Timeout ?? Options.Timeout ?? _defaultTimeout); | ||
| using var cts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken); | ||
|
|
||
| var ct = cts.Token; | ||
|
|
||
| HttpResponseMessage? responseMessage; | ||
| // Make sure we have a cookie container if not provided in the request | ||
| var cookieContainer = request.CookieContainer ??= new(); | ||
|
|
||
|
|
@@ -150,34 +144,159 @@ async Task<HttpResponse> ExecuteRequestAsync(RestRequest request, CancellationTo | |
| .AddCookieHeaders(url, cookieContainer) | ||
| .AddCookieHeaders(url, Options.CookieContainer); | ||
|
|
||
| var message = new HttpRequestMessage(httpMethod, url); | ||
| message.Content = initialContent.BuildContent(); | ||
| message.Headers.Host = Options.BaseHost; | ||
| message.Headers.CacheControl = request.CachePolicy ?? Options.CachePolicy; | ||
| message.Version = request.Version; | ||
| message.AddHeaders(headers); | ||
|
|
||
| #pragma warning disable CS0618 // Type or member is obsolete | ||
| if (request.OnBeforeRequest != null) await request.OnBeforeRequest(message).ConfigureAwait(false); | ||
| #pragma warning restore CS0618 // Type or member is obsolete | ||
| await OnBeforeHttpRequest(request, message, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| var redirectOptions = Options.RedirectOptions; | ||
| var redirectCount = 0; | ||
|
|
||
| HttpResponseMessage responseMessage; | ||
|
|
||
| try { | ||
| responseMessage = await HttpClient.SendAsync(message, request.CompletionOption, ct).ConfigureAwait(false); | ||
|
|
||
| // Parse all the cookies from the response and update the cookie jar with cookies | ||
| if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader)) { | ||
| // ReSharper disable once PossibleMultipleEnumeration | ||
| cookieContainer.AddCookies(url, cookiesHeader); | ||
| // ReSharper disable once PossibleMultipleEnumeration | ||
| Options.CookieContainer?.AddCookies(url, cookiesHeader); | ||
| while (true) { | ||
| responseMessage = await HttpClient.SendAsync(message, request.CompletionOption, ct).ConfigureAwait(false); | ||
|
|
||
| // Parse all the cookies from the response and update the cookie jars | ||
| if (responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader)) { | ||
| // ReSharper disable once PossibleMultipleEnumeration | ||
| cookieContainer.AddCookies(url, cookiesHeader); | ||
| // ReSharper disable once PossibleMultipleEnumeration | ||
| Options.CookieContainer?.AddCookies(url, cookiesHeader); | ||
| } | ||
|
|
||
| // Check if this is a redirect we should follow | ||
| if (!redirectOptions.FollowRedirects || | ||
| !redirectOptions.RedirectStatusCodes.Contains(responseMessage.StatusCode) || | ||
| responseMessage.Headers.Location == null) { | ||
| break; | ||
| } | ||
|
|
||
| redirectCount++; | ||
|
|
||
| if (redirectCount > redirectOptions.MaxRedirects) { | ||
| break; | ||
| } | ||
|
|
||
| // Resolve redirect URL | ||
| var location = responseMessage.Headers.Location; | ||
| var redirectUrl = location.IsAbsoluteUri ? location : new Uri(url, location); | ||
|
|
||
| // Forward original query string when the redirect Location has no query | ||
| if (redirectOptions.ForwardQuery && string.IsNullOrEmpty(redirectUrl.Query) && !string.IsNullOrEmpty(url.Query)) { | ||
| var builder = new UriBuilder(redirectUrl) { Query = url.Query.TrimStart('?') }; | ||
| redirectUrl = builder.Uri; | ||
| } | ||
|
|
||
| // Block HTTPS → HTTP unless explicitly allowed | ||
| if (url.Scheme == "https" && redirectUrl.Scheme == "http" && !redirectOptions.FollowRedirectsToInsecure) { | ||
| break; | ||
| } | ||
|
|
||
| // Determine verb change per RFC 7231 | ||
| var newMethod = GetRedirectMethod(httpMethod, responseMessage.StatusCode); | ||
| var verbChangedToGet = newMethod == HttpMethod.Get && httpMethod != HttpMethod.Get; | ||
|
|
||
| // Dispose intermediate response and message (we're following the redirect) | ||
| responseMessage.Dispose(); | ||
| message.Dispose(); | ||
|
|
||
| // Update state for next iteration | ||
| url = redirectUrl; | ||
| httpMethod = newMethod; | ||
|
|
||
| // Build new message for the redirect | ||
| message = new HttpRequestMessage(httpMethod, url); | ||
| message.Version = request.Version; | ||
|
|
||
| // Handle body: drop when verb changed to GET, otherwise forward if configured | ||
| if (!verbChangedToGet && redirectOptions.ForwardBody) { | ||
| var redirectContent = new RequestContent(this, request); | ||
| contentToDispose.Add(redirectContent); | ||
| message.Content = redirectContent.BuildContent(); | ||
| } | ||
|
|
||
| // Build headers for the redirect request | ||
| var redirectHeaders = BuildRedirectHeaders(url, redirectOptions, request, cookieContainer); | ||
| message.AddHeaders(redirectHeaders); | ||
|
alexeyzimarev marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
| catch (Exception ex) { | ||
| message.Dispose(); | ||
| DisposeContent(contentToDispose); | ||
| return new(null, url, null, ex, timeoutCts.Token); | ||
| } | ||
|
|
||
| message.Dispose(); | ||
| DisposeContent(contentToDispose); | ||
|
|
||
| #pragma warning disable CS0618 // Type or member is obsolete | ||
| if (request.OnAfterRequest != null) await request.OnAfterRequest(responseMessage).ConfigureAwait(false); | ||
| #pragma warning restore CS0618 // Type or member is obsolete | ||
| await OnAfterHttpRequest(request, responseMessage, cancellationToken).ConfigureAwait(false); | ||
| return new(responseMessage, url, cookieContainer, null, timeoutCts.Token); | ||
| } | ||
|
|
||
| RequestHeaders BuildRedirectHeaders(Uri url, RedirectOptions redirectOptions, RestRequest request, CookieContainer cookieContainer) { | ||
| var redirectHeaders = new RequestHeaders(); | ||
|
|
||
| if (redirectOptions.ForwardHeaders) { | ||
| redirectHeaders | ||
| .AddHeaders(request.Parameters) | ||
| .AddHeaders(DefaultParameters) | ||
| .AddAcceptHeader(AcceptedContentTypes); | ||
|
|
||
| if (!redirectOptions.ForwardAuthorization) { | ||
| redirectHeaders.RemoveHeader(KnownHeaders.Authorization); | ||
| } | ||
| } | ||
| else { | ||
| redirectHeaders.AddAcceptHeader(AcceptedContentTypes); | ||
| } | ||
|
|
||
| // Always remove existing Cookie headers before adding fresh ones from the container | ||
| redirectHeaders.RemoveHeader(KnownHeaders.Cookie); | ||
|
|
||
| if (redirectOptions.ForwardCookies) { | ||
| redirectHeaders | ||
| .AddCookieHeaders(url, cookieContainer) | ||
| .AddCookieHeaders(url, Options.CookieContainer); | ||
|
Comment on lines
+313
to
+316
Contributor
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. 1. Redirect sends duplicate cookie headers Redirect requests add cookies from both request.CookieContainer and Options.CookieContainer, but AddCookieHeaders appends a new Cookie header parameter each call, leading to multiple Cookie header values and potential duplicated cookies. This can cause invalid Cookie headers and redirect loops when both containers contain overlapping cookies. Agent Prompt
Member
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. This has been fixed. |
||
| } | ||
|
|
||
| return redirectHeaders; | ||
| } | ||
|
|
||
| static HttpMethod GetRedirectMethod(HttpMethod originalMethod, HttpStatusCode statusCode) { | ||
| // 307 and 308: always preserve the original method | ||
| if (statusCode is HttpStatusCode.TemporaryRedirect or (HttpStatusCode)308) { | ||
| return originalMethod; | ||
| } | ||
|
|
||
| // 303: all methods except GET and HEAD become GET | ||
| if (statusCode == HttpStatusCode.SeeOther) { | ||
| return originalMethod == HttpMethod.Get || originalMethod == HttpMethod.Head | ||
| ? originalMethod | ||
| : HttpMethod.Get; | ||
| } | ||
|
|
||
| // 301 and 302: POST becomes GET (matches browser/HttpClient behavior), others preserved | ||
| return originalMethod == HttpMethod.Post ? HttpMethod.Get : originalMethod; | ||
| } | ||
|
|
||
| static void DisposeContent(List<RequestContent> contentList) { | ||
| foreach (var content in contentList) { | ||
| content.Dispose(); | ||
| } | ||
| } | ||
|
|
||
| static async ValueTask OnBeforeRequest(RestRequest request, CancellationToken cancellationToken) { | ||
| if (request.Interceptors == null) return; | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.