Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
74 changes: 74 additions & 0 deletions src/RestSharp/Options/RedirectOptions.cs
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
];
}
23 changes: 20 additions & 3 deletions src/RestSharp/Options/RestClientOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,19 @@ public RestClientOptions(string baseUrl) : this(new Uri(Ensure.NotEmptyString(ba
#endif

/// <summary>
/// Set the maximum number of redirects to follow
/// Set the maximum number of redirects to follow.
/// This is a convenience property that delegates to <see cref="RedirectOptions"/>.MaxRedirects.
/// </summary>
#if NET
[UnsupportedOSPlatform("browser")]
#endif
public int? MaxRedirects { get; set; }
[Exclude]
public int? MaxRedirects {
get => RedirectOptions.MaxRedirects;
set {
if (value.HasValue) RedirectOptions.MaxRedirects = value.Value;
}
}

/// <summary>
/// X509CertificateCollection to be sent with request
Expand Down Expand Up @@ -141,8 +148,18 @@ public RestClientOptions(string baseUrl) : this(new Uri(Ensure.NotEmptyString(ba

/// <summary>
/// Instruct the client to follow redirects. Default is true.
/// This is a convenience property that delegates to <see cref="RedirectOptions"/>.FollowRedirects.
/// </summary>
[Exclude]
public bool FollowRedirects {
get => RedirectOptions.FollowRedirects;
set => RedirectOptions.FollowRedirects = value;
}

/// <summary>
/// Options for controlling redirect behavior.
/// </summary>
public bool FollowRedirects { get; set; } = true;
public RedirectOptions RedirectOptions { get; set; } = new();

/// <summary>
/// Gets or sets a value that indicates if the <see langword="Expect" /> header for an HTTP request contains Continue.
Expand Down
5 changes: 5 additions & 0 deletions src/RestSharp/Request/RequestHeaders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ public RequestHeaders AddAcceptHeader(string[] acceptedContentTypes) {
return this;
}

public RequestHeaders RemoveHeader(string name) {
Parameters.RemoveAll(p => string.Equals(p.Name, name, StringComparison.InvariantCultureIgnoreCase));
return this;
}

// Add Cookie header from the cookie container
public RequestHeaders AddCookieHeaders(Uri uri, CookieContainer? cookieContainer) {
if (cookieContainer == null) return this;
Expand Down
155 changes: 137 additions & 18 deletions src/RestSharp/RestClient.Async.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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);
Comment thread
alexeyzimarev marked this conversation as resolved.
Outdated
Comment thread
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Action required

1. Redirect sends duplicate cookie headers 📎 Requirement gap ⛯ Reliability

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
## Issue description
Redirect requests can emit multiple `Cookie` header values (and duplicate cookie entries) when both `request.CookieContainer` and `Options.CookieContainer` are used, because `RequestHeaders.AddCookieHeaders` appends a new `Cookie` header parameter each time.

## Issue Context
This violates the requirement to avoid duplicated cookies when multiple cookie containers are configured, and can lead to invalid Cookie headers and redirect loops.

## Fix Focus Areas
- src/RestSharp/RestClient.Async.cs[265-272]
- src/RestSharp/Request/RequestHeaders.cs[43-60]
- src/RestSharp/Request/HttpRequestMessageExtensions.cs[22-33]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This has been fixed. AddCookieHeaders in RequestHeaders.cs now removes the existing Cookie parameter before adding the merged one (line 56), so consecutive calls for request.CookieContainer and Options.CookieContainer produce a single Cookie header with deduplicated values via Union.

}

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;

Expand Down
4 changes: 1 addition & 3 deletions src/RestSharp/RestClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,6 @@ internal static void ConfigureHttpMessageHandler(HttpClientHandler handler, Rest
if (options.Credentials != null) handler.Credentials = options.Credentials;
handler.AutomaticDecompression = options.AutomaticDecompression;
handler.PreAuthenticate = options.PreAuthenticate;
if (options.MaxRedirects.HasValue) handler.MaxAutomaticRedirections = options.MaxRedirects.Value;

if (options.RemoteCertificateValidationCallback != null)
handler.ServerCertificateCustomValidationCallback =
(request, cert, chain, errors) => options.RemoteCertificateValidationCallback(request, cert, chain, errors);
Expand All @@ -251,7 +249,7 @@ internal static void ConfigureHttpMessageHandler(HttpClientHandler handler, Rest
#if NET
}
#endif
handler.AllowAutoRedirect = options.FollowRedirects;
handler.AllowAutoRedirect = false;

#if NET
// ReSharper disable once InvertIf
Expand Down
Loading
Loading