diff --git a/src/RestSharp/Options/RedirectOptions.cs b/src/RestSharp/Options/RedirectOptions.cs
new file mode 100644
index 000000000..baaafdb86
--- /dev/null
+++ b/src/RestSharp/Options/RedirectOptions.cs
@@ -0,0 +1,83 @@
+// 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;
+
+///
+/// Options for controlling redirect behavior when RestSharp handles redirects.
+///
+public class RedirectOptions {
+ ///
+ /// Whether to follow redirects. Default is true.
+ ///
+ public bool FollowRedirects { get; set; } = true;
+
+ ///
+ /// Whether to follow redirects from HTTPS to HTTP (insecure). Default is false.
+ ///
+ public bool FollowRedirectsToInsecure { get; set; }
+
+ ///
+ /// Whether to forward request headers on redirect. Default is true.
+ ///
+ public bool ForwardHeaders { get; set; } = true;
+
+ ///
+ /// Whether to forward the Authorization header on same-host redirects. Default is false.
+ /// Even when enabled, Authorization is stripped on cross-host redirects unless
+ /// is also set to true.
+ ///
+ public bool ForwardAuthorization { get; set; }
+
+ ///
+ /// Whether to forward the Authorization header when redirecting to a different host. Default is false.
+ /// Only applies when is true. Enabling this can expose credentials
+ /// to unintended hosts if a redirect points to a third-party server.
+ ///
+ public bool ForwardAuthorizationToExternalHost { get; set; }
+
+ ///
+ /// Whether to forward cookies on redirect. Default is true.
+ /// Cookies from Set-Cookie headers are always stored in the CookieContainer regardless of this setting.
+ ///
+ public bool ForwardCookies { get; set; } = true;
+
+ ///
+ /// 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.
+ ///
+ public bool ForwardBody { get; set; } = true;
+
+ ///
+ /// Whether to forward original query string parameters on redirect. Default is true.
+ ///
+ public bool ForwardQuery { get; set; } = true;
+
+ ///
+ /// Maximum number of redirects to follow. Default is 50.
+ ///
+ public int MaxRedirects { get; set; } = 50;
+
+ ///
+ /// HTTP status codes that are considered redirects.
+ ///
+ public IReadOnlyList RedirectStatusCodes { get; set; } = [
+ HttpStatusCode.MovedPermanently, // 301
+ HttpStatusCode.Found, // 302
+ HttpStatusCode.SeeOther, // 303
+ HttpStatusCode.TemporaryRedirect, // 307
+ (HttpStatusCode)308, // 308 Permanent Redirect
+ ];
+}
diff --git a/src/RestSharp/Options/RestClientOptions.cs b/src/RestSharp/Options/RestClientOptions.cs
index 8d279d736..5cd8b09a2 100644
--- a/src/RestSharp/Options/RestClientOptions.cs
+++ b/src/RestSharp/Options/RestClientOptions.cs
@@ -108,12 +108,19 @@ public RestClientOptions(string baseUrl) : this(new Uri(Ensure.NotEmptyString(ba
#endif
///
- /// Set the maximum number of redirects to follow
+ /// Set the maximum number of redirects to follow.
+ /// This is a convenience property that delegates to .MaxRedirects.
///
#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;
+ }
+ }
///
/// X509CertificateCollection to be sent with request
@@ -141,8 +148,18 @@ public RestClientOptions(string baseUrl) : this(new Uri(Ensure.NotEmptyString(ba
///
/// Instruct the client to follow redirects. Default is true.
+ /// This is a convenience property that delegates to .FollowRedirects.
+ ///
+ [Exclude]
+ public bool FollowRedirects {
+ get => RedirectOptions.FollowRedirects;
+ set => RedirectOptions.FollowRedirects = value;
+ }
+
+ ///
+ /// Options for controlling redirect behavior.
///
- public bool FollowRedirects { get; set; } = true;
+ public RedirectOptions RedirectOptions { get; set; } = new();
///
/// Gets or sets a value that indicates if the header for an HTTP request contains Continue.
diff --git a/src/RestSharp/Request/RequestHeaders.cs b/src/RestSharp/Request/RequestHeaders.cs
index 10677d6e3..86d4be6fd 100644
--- a/src/RestSharp/Request/RequestHeaders.cs
+++ b/src/RestSharp/Request/RequestHeaders.cs
@@ -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;
@@ -48,6 +53,7 @@ public RequestHeaders AddCookieHeaders(Uri uri, CookieContainer? cookieContainer
if (existing?.Value != null) {
newCookies = newCookies.Union(SplitHeader(existing.Value!));
+ Parameters.Remove(existing);
}
Parameters.Add(new(KnownHeaders.Cookie, string.Join("; ", newCookies)));
diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs
index 8beee730b..73ed0410b 100644
--- a/src/RestSharp/RestClient.Async.cs
+++ b/src/RestSharp/RestClient.Async.cs
@@ -1,11 +1,11 @@
-// Copyright (c) .NET Foundation and Contributors
-//
+// 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.
@@ -19,7 +19,7 @@
namespace RestSharp;
public partial class RestClient {
- // Default HttpClient timeout
+ // Default HttpClient timeout
readonly TimeSpan _defaultTimeout = TimeSpan.FromSeconds(100);
///
@@ -111,37 +111,21 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo
await authenticator.Authenticate(this, request).ConfigureAwait(false);
}
- using var requestContent = new RequestContent(this, request);
+ var contentToDispose = new List();
+ 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();
-
- foreach (var cookie in request.PendingCookies) {
- try {
- cookieContainer.Add(url, cookie);
- }
- catch (CookieException) {
- // Do not fail request if we cannot parse a cookie
- }
- }
-
- request.ClearPendingCookies();
+ AddPendingCookies(cookieContainer, url, request);
var headers = new RequestHeaders()
.AddHeaders(request.Parameters)
@@ -150,32 +134,224 @@ async Task 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 (responseMessage, finalUrl, error) = await SendWithRedirectsAsync(
+ message, url, httpMethod, request, cookieContainer, contentToDispose, ct
+ ).ConfigureAwait(false);
+
+ DisposeContent(contentToDispose);
+
+ if (error != null) {
+ return new(null, finalUrl, null, error, timeoutCts.Token);
+ }
+
+#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, finalUrl, cookieContainer, null, timeoutCts.Token);
+ }
+
+ async Task<(HttpResponseMessage? Response, Uri FinalUrl, Exception? Error)> SendWithRedirectsAsync(
+ HttpRequestMessage message,
+ Uri url,
+ HttpMethod httpMethod,
+ RestRequest request,
+ CookieContainer cookieContainer,
+ List contentToDispose,
+ CancellationToken ct
+ ) {
+ var redirectOptions = Options.RedirectOptions;
+ var redirectCount = 0;
+ var originalUrl = url;
+
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) {
+ var responseMessage = await HttpClient.SendAsync(message, request.CompletionOption, ct).ConfigureAwait(false);
+
+ ParseResponseCookies(responseMessage, url, cookieContainer);
+
+ if (!ShouldFollowRedirect(redirectOptions, responseMessage, redirectCount)) {
+ return (responseMessage, url, null);
+ }
+
+ var redirectUrl = ResolveRedirectUrl(url, responseMessage, redirectOptions);
+
+ if (redirectUrl == null) {
+ return (responseMessage, url, null);
+ }
+
+ var newMethod = GetRedirectMethod(httpMethod, responseMessage.StatusCode);
+ var verbChangedToGet = newMethod == HttpMethod.Get && httpMethod != HttpMethod.Get;
+
+ responseMessage.Dispose();
+
+ var previousMessage = message;
+ url = redirectUrl;
+ httpMethod = newMethod;
+ redirectCount++;
+
+ message = CreateRedirectMessage(
+ httpMethod, url, originalUrl, request, redirectOptions, cookieContainer, contentToDispose, verbChangedToGet
+ );
+ previousMessage.Dispose();
}
}
catch (Exception ex) {
- return new(null, url, null, ex, timeoutCts.Token);
+ return (null, url, ex);
+ }
+ finally {
+ message.Dispose();
}
+ }
-#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);
+ static void AddPendingCookies(CookieContainer cookieContainer, Uri url, RestRequest request) {
+ foreach (var cookie in request.PendingCookies) {
+ try {
+ cookieContainer.Add(url, cookie);
+ }
+ catch (CookieException) {
+ // Do not fail request if we cannot parse a cookie
+ }
+ }
+
+ request.ClearPendingCookies();
+ }
+
+ void ParseResponseCookies(HttpResponseMessage responseMessage, Uri url, CookieContainer cookieContainer) {
+ if (!responseMessage.Headers.TryGetValues(KnownHeaders.SetCookie, out var cookiesHeader)) return;
+
+ // ReSharper disable once PossibleMultipleEnumeration
+ cookieContainer.AddCookies(url, cookiesHeader);
+ // ReSharper disable once PossibleMultipleEnumeration
+ Options.CookieContainer?.AddCookies(url, cookiesHeader);
+ }
+
+ static bool ShouldFollowRedirect(RedirectOptions options, HttpResponseMessage response, int redirectCount)
+ => options.FollowRedirects
+ && options.RedirectStatusCodes.Contains(response.StatusCode)
+ && response.Headers.Location != null
+ && redirectCount < options.MaxRedirects;
+
+ static Uri? ResolveRedirectUrl(Uri currentUrl, HttpResponseMessage response, RedirectOptions options) {
+ var location = response.Headers.Location!;
+ var redirectUrl = location.IsAbsoluteUri ? location : new Uri(currentUrl, location);
+
+ if (options.ForwardQuery && string.IsNullOrEmpty(redirectUrl.Query) && !string.IsNullOrEmpty(currentUrl.Query)) {
+ var builder = new UriBuilder(redirectUrl) { Query = currentUrl.Query.TrimStart('?') };
+ redirectUrl = builder.Uri;
+ }
+
+ // Block HTTPS -> HTTP unless explicitly allowed
+ if (currentUrl.Scheme == "https" && redirectUrl.Scheme == "http" && !options.FollowRedirectsToInsecure) {
+ return null;
+ }
+
+ return redirectUrl;
+ }
+
+ HttpRequestMessage CreateRedirectMessage(
+ HttpMethod httpMethod,
+ Uri url,
+ Uri originalUrl,
+ RestRequest request,
+ RedirectOptions redirectOptions,
+ CookieContainer cookieContainer,
+ List contentToDispose,
+ bool verbChangedToGet
+ ) {
+ var redirectMessage = new HttpRequestMessage(httpMethod, url);
+ redirectMessage.Version = request.Version;
+ redirectMessage.Headers.Host = Options.BaseHost;
+ redirectMessage.Headers.CacheControl = request.CachePolicy ?? Options.CachePolicy;
+
+ if (!verbChangedToGet && redirectOptions.ForwardBody) {
+ var redirectContent = new RequestContent(this, request);
+ contentToDispose.Add(redirectContent);
+ redirectMessage.Content = redirectContent.BuildContent();
+ }
+
+ var redirectHeaders = BuildRedirectHeaders(url, originalUrl, redirectOptions, request, cookieContainer);
+ redirectMessage.AddHeaders(redirectHeaders);
+
+ return redirectMessage;
+ }
+
+ RequestHeaders BuildRedirectHeaders(
+ Uri url, Uri originalUrl, RedirectOptions redirectOptions, RestRequest request, CookieContainer cookieContainer
+ ) {
+ var redirectHeaders = new RequestHeaders();
+
+ if (redirectOptions.ForwardHeaders) {
+ redirectHeaders
+ .AddHeaders(request.Parameters)
+ .AddHeaders(DefaultParameters)
+ .AddAcceptHeader(AcceptedContentTypes);
+
+ if (!ShouldForwardAuthorization(url, originalUrl, redirectOptions)) {
+ 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);
+ }
+
+ return redirectHeaders;
+ }
+
+ static bool ShouldForwardAuthorization(Uri redirectUrl, Uri originalUrl, RedirectOptions options) {
+ if (!options.ForwardAuthorization) return false;
+
+ // Never forward credentials from HTTPS to HTTP (they would be sent in plaintext)
+ if (originalUrl.Scheme == "https" && redirectUrl.Scheme == "http") return false;
+
+ // Compare full authority (host + port) to match browser same-origin policy
+ var isSameOrigin = string.Equals(redirectUrl.Authority, originalUrl.Authority, StringComparison.OrdinalIgnoreCase);
+
+ return isSameOrigin || options.ForwardAuthorizationToExternalHost;
+ }
+
+ 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 contentList) {
+ foreach (var content in contentList) {
+ content.Dispose();
+ }
}
static async ValueTask OnBeforeRequest(RestRequest request, CancellationToken cancellationToken) {
@@ -238,4 +414,4 @@ internal static HttpMethod AsHttpMethod(Method method)
Method.Search => new("SEARCH"),
_ => throw new ArgumentOutOfRangeException(nameof(method))
};
-}
\ No newline at end of file
+}
diff --git a/src/RestSharp/RestClient.cs b/src/RestSharp/RestClient.cs
index fe50cd607..d67a6fb7f 100644
--- a/src/RestSharp/RestClient.cs
+++ b/src/RestSharp/RestClient.cs
@@ -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);
@@ -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
diff --git a/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs b/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs
new file mode 100644
index 000000000..bd9b25e0c
--- /dev/null
+++ b/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs
@@ -0,0 +1,367 @@
+using System.Text.Json;
+using WireMock;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+using WireMock.Server;
+using WireMock.Types;
+using WireMock.Util;
+
+namespace RestSharp.Tests.Integrated;
+
+///
+/// Tests for cookie behavior during redirects and custom redirect handling.
+/// Verifies fixes for https://github.com/restsharp/RestSharp/issues/2077
+/// and https://github.com/restsharp/RestSharp/issues/2059
+///
+public sealed class CookieRedirectTests(WireMockTestServer server) : IClassFixture, IDisposable {
+ readonly RestClient _client = new(server.Url!);
+
+ RestClient CreateClient(Action? configure = null) {
+ var options = new RestClientOptions(server.Url!);
+ configure?.Invoke(options);
+ return new RestClient(options);
+ }
+
+ // ─── Cookie tests ────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Redirect_Should_Forward_Cookies_Set_During_Redirect() {
+ using var client = CreateClient(o => o.CookieContainer = new());
+
+ var request = new RestRequest("/set-cookie-and-redirect");
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ response.Content!.Should().Contain("redirectCookie",
+ "cookies from Set-Cookie headers on redirect responses should be forwarded to the final destination");
+ }
+
+ [Fact]
+ public async Task Redirect_Should_Capture_SetCookie_From_Redirect_In_CookieContainer() {
+ var cookieContainer = new CookieContainer();
+ using var client = CreateClient(o => o.CookieContainer = cookieContainer);
+
+ var request = new RestRequest("/set-cookie-and-redirect");
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ cookieContainer.GetCookies(new Uri(server.Url!)).Cast()
+ .Should().Contain(c => c.Name == "redirectCookie" && c.Value == "value1",
+ "cookies from Set-Cookie headers on redirect responses should be stored in the CookieContainer");
+ }
+
+ [Fact]
+ public async Task Redirect_With_Existing_Cookies_Should_Include_Both_Old_And_New_Cookies() {
+ var host = new Uri(server.Url!).Host;
+ using var client = CreateClient(o => o.CookieContainer = new());
+
+ var request = new RestRequest("/set-cookie-and-redirect") {
+ CookieContainer = new()
+ };
+ request.CookieContainer.Add(new Cookie("existingCookie", "existingValue", "/", host));
+
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ response.Content!.Should().Contain("existingCookie",
+ "pre-existing cookies should be forwarded through redirects");
+ response.Content.Should().Contain("redirectCookie",
+ "cookies set during redirect should also arrive at the final destination");
+ }
+
+ // ─── FollowRedirects = false ─────────────────────────────────────────
+
+ [Fact]
+ public async Task FollowRedirects_False_Should_Return_Redirect_Response() {
+ using var client = CreateClient(o => o.FollowRedirects = false);
+
+ var request = new RestRequest("/set-cookie-and-redirect");
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.Redirect);
+ }
+
+ // ─── Max redirects ───────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Should_Stop_After_MaxRedirects() {
+ using var client = CreateClient(o => o.RedirectOptions = new RedirectOptions { MaxRedirects = 3 });
+
+ var request = new RestRequest("/redirect-countdown?n=10");
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect);
+ }
+
+ [Fact]
+ public async Task Should_Follow_All_Redirects_When_Under_MaxRedirects() {
+ using var client = CreateClient(o => o.RedirectOptions = new RedirectOptions { MaxRedirects = 50 });
+
+ var request = new RestRequest("/redirect-countdown?n=5");
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ response.Content.Should().Contain("Done!");
+ }
+
+ // ─── Verb changes (parameterized) ────────────────────────────────────
+
+ [Theory]
+ [InlineData(302, "GET")]
+ [InlineData(303, "GET")]
+ [InlineData(307, "POST")]
+ [InlineData(308, "POST")]
+ public async Task Post_Redirect_Should_Use_Expected_Method(int statusCode, string expectedMethod) {
+ using var client = CreateClient();
+
+ var request = new RestRequest($"/redirect-with-status?status={statusCode}", Method.Post);
+ request.AddJsonBody(new { data = "test" });
+
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var doc = JsonDocument.Parse(response.Content!);
+ doc.RootElement.GetProperty("Method").GetString().Should().Be(expectedMethod);
+ }
+
+ [Fact]
+ public async Task Body_Should_Be_Dropped_When_Verb_Changes_To_Get() {
+ using var client = CreateClient();
+
+ var request = new RestRequest("/redirect-with-status?status=302", Method.Post);
+ request.AddJsonBody(new { data = "test" });
+
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var doc = JsonDocument.Parse(response.Content!);
+ doc.RootElement.GetProperty("Method").GetString().Should().Be("GET");
+ doc.RootElement.GetProperty("Body").GetString().Should().BeEmpty();
+ }
+
+ // ─── Header forwarding ──────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task ForwardHeaders_Controls_Custom_Header_Forwarding(bool forwardHeaders) {
+ using var client = CreateClient(o =>
+ o.RedirectOptions = new RedirectOptions { ForwardHeaders = forwardHeaders }
+ );
+
+ var request = new RestRequest("/redirect-with-status?status=302");
+ request.AddHeader("X-Custom-Header", "custom-value");
+
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ if (forwardHeaders)
+ response.Content.Should().Contain("X-Custom-Header").And.Contain("custom-value");
+ else
+ response.Content.Should().NotContain("X-Custom-Header");
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task ForwardAuthorization_Controls_Auth_Header_Forwarding(bool forwardAuth) {
+ using var client = CreateClient(o =>
+ o.RedirectOptions = new RedirectOptions { ForwardAuthorization = forwardAuth }
+ );
+
+ var request = new RestRequest("/redirect-with-status?status=302");
+ request.AddHeader("Authorization", "Bearer test-token");
+
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ if (forwardAuth)
+ response.Content.Should().Contain("Bearer test-token");
+ else
+ response.Content.Should().NotContain("Bearer test-token");
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(true, true)]
+ public async Task ForwardAuthorizationToExternalHost_Controls_Cross_Origin_Auth(
+ bool allowExternal, bool expectAuth
+ ) {
+ // Create a second server (different port = different origin) with echo endpoint
+ using var externalServer = WireMockServer.Start();
+ externalServer
+ .Given(Request.Create().WithPath("/echo-request"))
+ .RespondWith(Response.Create().WithCallback(WireMockTestServer.EchoRequest));
+
+ // Main server redirects to the external server
+ var redirectPath = $"/redirect-external-{allowExternal}";
+ server.Given(Request.Create().WithPath(redirectPath))
+ .RespondWith(Response.Create().WithCallback(_ => new ResponseMessage {
+ StatusCode = 302,
+ Headers = new Dictionary> {
+ ["Location"] = new(externalServer.Url + "/echo-request")
+ }
+ }));
+
+ using var client = CreateClient(o =>
+ o.RedirectOptions = new RedirectOptions {
+ ForwardAuthorization = true,
+ ForwardAuthorizationToExternalHost = allowExternal
+ }
+ );
+
+ var request = new RestRequest(redirectPath);
+ request.AddHeader("Authorization", "Bearer secret-token");
+
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ if (expectAuth)
+ response.Content.Should().Contain("Bearer secret-token");
+ else
+ response.Content.Should().NotContain("Bearer secret-token");
+ }
+
+ [Fact]
+ public async Task ForwardCookies_False_Should_Not_Send_Cookies_On_Redirect() {
+ var host = new Uri(server.Url!).Host;
+ var cookieContainer = new CookieContainer();
+ using var client = CreateClient(o => {
+ o.CookieContainer = cookieContainer;
+ o.RedirectOptions = new RedirectOptions { ForwardCookies = false };
+ });
+
+ var request = new RestRequest("/set-cookie-and-redirect?url=/echo-cookies") {
+ CookieContainer = new()
+ };
+ request.CookieContainer.Add(new Cookie("existingCookie", "existingValue", "/", host));
+
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ cookieContainer.GetCookies(new Uri(server.Url!)).Cast().Should()
+ .Contain(c => c.Name == "redirectCookie",
+ "Set-Cookie should still be stored even when ForwardCookies is false");
+ response.Content.Should().NotContain("existingCookie");
+ response.Content.Should().NotContain("redirectCookie");
+ }
+
+ // ─── ForwardBody ────────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ForwardBody_Controls_Body_Forwarding_When_Verb_Preserved(bool forwardBody) {
+ using var client = CreateClient(o =>
+ o.RedirectOptions = new RedirectOptions { ForwardBody = forwardBody }
+ );
+
+ var request = new RestRequest("/redirect-with-status?status=307", Method.Post);
+ request.AddJsonBody(new { data = "test-body" });
+
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var doc = JsonDocument.Parse(response.Content!);
+ doc.RootElement.GetProperty("Method").GetString().Should().Be("POST");
+
+ if (forwardBody)
+ doc.RootElement.GetProperty("Body").GetString().Should().Contain("test-body");
+ else
+ doc.RootElement.GetProperty("Body").GetString().Should().BeEmpty();
+ }
+
+ // ─── ForwardQuery ───────────────────────────────────────────────────
+
+ [Theory]
+ [InlineData(true, true)]
+ [InlineData(false, false)]
+ public async Task ForwardQuery_Controls_Query_String_Forwarding(bool forwardQuery, bool expectQuery) {
+ using var client = CreateClient(o =>
+ o.RedirectOptions = new RedirectOptions { ForwardQuery = forwardQuery }
+ );
+
+ var request = new RestRequest("/redirect-no-query");
+ request.AddQueryParameter("foo", "bar");
+
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ if (expectQuery)
+ response.ResponseUri!.Query.Should().Contain("foo=bar");
+ else
+ (response.ResponseUri!.Query ?? "").Should().NotContain("foo=bar");
+ }
+
+ // ─── RedirectStatusCodes customization ──────────────────────────────
+
+ [Fact]
+ public async Task Custom_RedirectStatusCodes_Should_Follow_Custom_Code() {
+ using var client = CreateClient(o =>
+ o.RedirectOptions = new RedirectOptions {
+ RedirectStatusCodes = [HttpStatusCode.Found, (HttpStatusCode)399]
+ }
+ );
+
+ var request = new RestRequest("/redirect-custom-status?status=399");
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK,
+ "399 should be treated as a redirect because it's in RedirectStatusCodes");
+ }
+
+ [Fact]
+ public async Task Custom_RedirectStatusCodes_Should_Not_Follow_Excluded_Code() {
+ using var client = CreateClient(o =>
+ o.RedirectOptions = new RedirectOptions {
+ RedirectStatusCodes = [(HttpStatusCode)399]
+ }
+ );
+
+ var request = new RestRequest("/redirect-with-status?status=302");
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(HttpStatusCode.Found,
+ "302 should NOT be followed because it's not in the custom RedirectStatusCodes");
+ }
+
+ // ─── FollowRedirectsToInsecure ──────────────────────────────────────
+
+ [Theory]
+ [InlineData(false, HttpStatusCode.Redirect)]
+ [InlineData(true, HttpStatusCode.OK)]
+ public async Task FollowRedirectsToInsecure_Controls_Https_To_Http_Redirect(
+ bool allowInsecure, HttpStatusCode expectedStatus
+ ) {
+ using var httpsServer = WireMockServer.Start(new WireMock.Settings.WireMockServerSettings {
+ Port = 0,
+ UseSSL = true
+ });
+
+ httpsServer
+ .Given(Request.Create().WithPath("/https-redirect"))
+ .RespondWith(Response.Create().WithCallback(_ => new ResponseMessage {
+ StatusCode = 302,
+ Headers = new Dictionary> {
+ ["Location"] = new(server.Url + "/echo-request")
+ }
+ }));
+
+ using var client = new RestClient(new RestClientOptions(httpsServer.Url!) {
+ // Cert validation disabled intentionally: local test HTTPS server uses self-signed cert
+ RemoteCertificateValidationCallback = (_, _, _, _) => true,
+ RedirectOptions = new RedirectOptions { FollowRedirectsToInsecure = allowInsecure }
+ });
+
+ var request = new RestRequest("/https-redirect");
+ var response = await client.ExecuteAsync(request);
+
+ response.StatusCode.Should().Be(expectedStatus);
+ }
+
+ public void Dispose() => _client.Dispose();
+}
diff --git a/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs b/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs
index 527e3d5a0..1df67db33 100644
--- a/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs
+++ b/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs
@@ -44,6 +44,32 @@ public WireMockTestServer() : base(new() { Port = 0, UseHttp2 = false, UseSSL =
Given(Request.Create().WithPath("/headers"))
.RespondWith(Response.Create().WithCallback(EchoHeaders));
+
+ Given(Request.Create().WithPath("/redirect-countdown"))
+ .RespondWith(Response.Create().WithCallback(RedirectCountdown));
+
+ Given(Request.Create().WithPath("/redirect-with-status"))
+ .RespondWith(Response.Create().WithCallback(RedirectWithStatus));
+
+ Given(Request.Create().WithPath("/echo-request"))
+ .RespondWith(Response.Create().WithCallback(EchoRequest));
+
+ Given(Request.Create().WithPath("/set-cookie-and-redirect").UsingGet())
+ .RespondWith(Response.Create().WithCallback(SetCookieAndRedirect));
+
+ Given(Request.Create().WithPath("/echo-cookies").UsingGet())
+ .RespondWith(Response.Create().WithCallback(EchoCookies));
+
+ Given(Request.Create().WithPath("/redirect-no-query"))
+ .RespondWith(Response.Create().WithCallback(_ => new ResponseMessage {
+ StatusCode = 302,
+ Headers = new Dictionary> {
+ ["Location"] = new("/echo-request")
+ }
+ }));
+
+ Given(Request.Create().WithPath("/redirect-custom-status"))
+ .RespondWith(Response.Create().WithCallback(RedirectCustomStatus));
}
static ResponseMessage WrapForm(IRequestMessage request) {
@@ -94,6 +120,100 @@ static ResponseMessage StatusCode(IRequestMessage request) {
};
}
+ static ResponseMessage RedirectCountdown(IRequestMessage request) {
+ var n = 1;
+
+ if (request.Query != null && request.Query.TryGetValue("n", out var nValues)) {
+ n = int.Parse(nValues[0]);
+ }
+
+ if (n <= 1) {
+ return CreateJson(new SuccessResponse("Done!"));
+ }
+
+ return new ResponseMessage {
+ StatusCode = (int)HttpStatusCode.TemporaryRedirect,
+ Headers = new Dictionary> {
+ ["Location"] = new($"/redirect-countdown?n={n - 1}")
+ }
+ };
+ }
+
+ static ResponseMessage RedirectWithStatus(IRequestMessage request) {
+ var status = 302;
+ var url = "/echo-request";
+
+ if (request.Query != null) {
+ if (request.Query.TryGetValue("status", out var statusValues)) {
+ status = int.Parse(statusValues[0]);
+ }
+
+ if (request.Query.TryGetValue("url", out var urlValues)) {
+ url = urlValues[0];
+ }
+ }
+
+ return new ResponseMessage {
+ StatusCode = status,
+ Headers = new Dictionary> {
+ ["Location"] = new(url)
+ }
+ };
+ }
+
+ public static ResponseMessage EchoRequest(IRequestMessage request) {
+ var headers = request.Headers?
+ .ToDictionary(x => x.Key, x => string.Join(", ", x.Value))
+ ?? new Dictionary();
+
+ return CreateJson(new {
+ Method = request.Method,
+ Headers = headers,
+ Body = request.Body ?? ""
+ });
+ }
+
+ static ResponseMessage SetCookieAndRedirect(IRequestMessage request) {
+ var url = "/echo-cookies";
+ if (request.Query != null && request.Query.TryGetValue("url", out var urlValues))
+ url = urlValues[0];
+
+ return new ResponseMessage {
+ StatusCode = 302,
+ Headers = new Dictionary> {
+ ["Location"] = new(url),
+ ["Set-Cookie"] = new("redirectCookie=value1; Path=/")
+ }
+ };
+ }
+
+ static ResponseMessage EchoCookies(IRequestMessage request) {
+ var cookieHeaders = new List();
+ if (request.Headers != null && request.Headers.TryGetValue("Cookie", out var values))
+ cookieHeaders.AddRange(values);
+
+ var parsedCookies = request.Cookies?.Select(x => $"{x.Key}={x.Value}").ToList()
+ ?? new List();
+
+ return CreateJson(new {
+ RawCookieHeaders = cookieHeaders,
+ ParsedCookies = parsedCookies
+ });
+ }
+
+ static ResponseMessage RedirectCustomStatus(IRequestMessage request) {
+ var status = 399;
+ if (request.Query != null && request.Query.TryGetValue("status", out var statusValues))
+ status = int.Parse(statusValues[0]);
+
+ return new ResponseMessage {
+ StatusCode = status,
+ Headers = new Dictionary> {
+ ["Location"] = new("/echo-request")
+ }
+ };
+ }
+
public static ResponseMessage CreateJson(object response)
=> new() {
BodyData = new BodyData {
diff --git a/test/RestSharp.Tests/OptionsTests.cs b/test/RestSharp.Tests/OptionsTests.cs
index d1d8aa372..9a310ef13 100644
--- a/test/RestSharp.Tests/OptionsTests.cs
+++ b/test/RestSharp.Tests/OptionsTests.cs
@@ -2,11 +2,11 @@ namespace RestSharp.Tests;
public class OptionsTests {
[Fact]
- public void Ensure_follow_redirect() {
- var value = false;
+ public void HttpClient_AllowAutoRedirect_Is_Always_False() {
+ var value = true;
var options = new RestClientOptions { FollowRedirects = true, ConfigureMessageHandler = Configure };
using var _ = new RestClient(options);
- value.Should().BeTrue();
+ value.Should().BeFalse("RestSharp handles redirects internally");
return;
HttpMessageHandler Configure(HttpMessageHandler handler) {