From 4b77d2ad1148c82e418cc0e43213af615fc8e628 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 27 Feb 2026 10:32:15 +0100 Subject: [PATCH 1/6] Implement custom redirect handling to fix lost Set-Cookie on redirects Fixes #2077 and #2059. Previously RestSharp delegated redirects to HttpClient (AllowAutoRedirect=true) but set UseCookies=false, so Set-Cookie headers from intermediate redirect responses were silently lost. This replaces HttpClient's redirect handling with a custom loop in ExecuteRequestAsync that processes Set-Cookie at each hop. Adds RedirectOptions class with fine-grained control over redirect behavior: FollowRedirectsToInsecure, ForwardHeaders, ForwardAuthorization, ForwardCookies, ForwardBody, ForwardQuery, MaxRedirects, and RedirectStatusCodes. Existing FollowRedirects/MaxRedirects properties delegate to RedirectOptions for backward compatibility. Co-Authored-By: Claude Opus 4.6 --- src/RestSharp/Options/RedirectOptions.cs | 74 +++ src/RestSharp/Options/RestClientOptions.cs | 23 +- src/RestSharp/Request/RequestHeaders.cs | 5 + src/RestSharp/RestClient.Async.cs | 155 ++++- src/RestSharp/RestClient.cs | 4 +- .../CookieRedirectTests.cs | 616 ++++++++++++++++++ .../Server/WireMockTestServer.cs | 62 ++ test/RestSharp.Tests/OptionsTests.cs | 6 +- 8 files changed, 918 insertions(+), 27 deletions(-) create mode 100644 src/RestSharp/Options/RedirectOptions.cs create mode 100644 test/RestSharp.Tests.Integrated/CookieRedirectTests.cs diff --git a/src/RestSharp/Options/RedirectOptions.cs b/src/RestSharp/Options/RedirectOptions.cs new file mode 100644 index 000000000..21db27057 --- /dev/null +++ b/src/RestSharp/Options/RedirectOptions.cs @@ -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; + +/// +/// 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 redirect. Default is false. + /// + public bool ForwardAuthorization { 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..50f2d5a68 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; diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index 8beee730b..1ab6c6b5c 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -111,24 +111,18 @@ 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(); @@ -150,27 +144,100 @@ 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 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); } } 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 @@ -178,6 +245,58 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo 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); + } + + 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 contentList) { + foreach (var content in contentList) { + content.Dispose(); + } + } + static async ValueTask OnBeforeRequest(RestRequest request, CancellationToken cancellationToken) { if (request.Interceptors == null) return; 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..6aa99b6cc --- /dev/null +++ b/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs @@ -0,0 +1,616 @@ +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 : IDisposable { + readonly WireMockServer _server = WireMockServer.Start(); + + public CookieRedirectTests() { + // Endpoint that sets a cookie via Set-Cookie header and redirects to /echo-cookies + _server + .Given(Request.Create().WithPath("/set-cookie-and-redirect").UsingGet()) + .RespondWith(Response.Create().WithCallback(request => { + var url = "/echo-cookies"; + if (request.Query != null && request.Query.TryGetValue("url", out var urlValues)) { + url = urlValues[0]; + } + + var response = new ResponseMessage { + StatusCode = 302, + Headers = new Dictionary>() + }; + response.Headers.Add("Location", new WireMockList(url)); + response.Headers.Add( + "Set-Cookie", + new WireMockList("redirectCookie=value1; Path=/") + ); + return response; + })); + + // Endpoint that echoes back received Cookie header values as JSON + _server + .Given(Request.Create().WithPath("/echo-cookies").UsingGet()) + .RespondWith(Response.Create().WithCallback(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 WireMockTestServer.CreateJson(new { + RawCookieHeaders = cookieHeaders, + ParsedCookies = parsedCookies + }); + })); + + // Endpoint that echoes request details (method, headers, body) + _server + .Given(Request.Create().WithPath("/echo-request")) + .RespondWith(Response.Create().WithCallback(request => { + var headers = request.Headers? + .ToDictionary(x => x.Key, x => string.Join(", ", x.Value)) + ?? new Dictionary(); + + return WireMockTestServer.CreateJson(new { + Method = request.Method, + Headers = headers, + Body = request.Body ?? "" + }); + })); + + // Redirect with configurable status code + _server + .Given(Request.Create().WithPath("/redirect-with-status")) + .RespondWith(Response.Create().WithCallback(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) + } + }; + })); + + // Countdown redirect: 307 redirects to self with n-1, returns 200 when n<=1 + _server + .Given(Request.Create().WithPath("/redirect-countdown")) + .RespondWith(Response.Create().WithCallback(request => { + var n = 1; + if (request.Query != null && request.Query.TryGetValue("n", out var nValues)) + n = int.Parse(nValues[0]); + + if (n <= 1) { + return WireMockTestServer.CreateJson(new { Message = "Done!" }); + } + + return new ResponseMessage { + StatusCode = (int)HttpStatusCode.TemporaryRedirect, + Headers = new Dictionary> { + ["Location"] = new($"/redirect-countdown?n={n - 1}") + } + }; + })); + + // Redirect to a path with no query (used for ForwardQuery tests) + _server + .Given(Request.Create().WithPath("/redirect-no-query")) + .RespondWith(Response.Create().WithCallback(_ => new ResponseMessage { + StatusCode = 302, + Headers = new Dictionary> { + ["Location"] = new("/echo-request") + } + })); + + // Echo query string parameters + _server + .Given(Request.Create().WithPath("/echo-query")) + .RespondWith(Response.Create().WithCallback(request => { + var query = request.Query? + .ToDictionary(x => x.Key, x => string.Join(",", x.Value)) + ?? new Dictionary(); + return WireMockTestServer.CreateJson(new { Query = query }); + })); + + // Redirect that preserves a custom status code for testing RedirectStatusCodes + _server + .Given(Request.Create().WithPath("/redirect-custom-status")) + .RespondWith(Response.Create().WithCallback(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") + } + }; + })); + } + + // ─── Cookie tests ──────────────────────────────────────────────────── + + [Fact] + public async Task Redirect_Should_Forward_Cookies_Set_During_Redirect() { + var options = new RestClientOptions(_server.Url!) { + FollowRedirects = true, + CookieContainer = new() + }; + using var client = new RestClient(options); + + var request = new RestRequest("/set-cookie-and-redirect"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = response.Content!; + + 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 options = new RestClientOptions(_server.Url!) { + FollowRedirects = true, + CookieContainer = new() + }; + using var client = new RestClient(options); + + var request = new RestRequest("/set-cookie-and-redirect"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var cookies = options.CookieContainer.GetCookies(new Uri(_server.Url!)); + cookies.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; + var options = new RestClientOptions(_server.Url!) { + FollowRedirects = true, + CookieContainer = new() + }; + using var client = new RestClient(options); + + 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); + var content = response.Content!; + + content.Should().Contain("existingCookie", + "pre-existing cookies should be forwarded through redirects"); + 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() { + var options = new RestClientOptions(_server.Url!) { + FollowRedirects = false + }; + using var client = new RestClient(options); + + 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() { + var options = new RestClientOptions(_server.Url!) { + RedirectOptions = new RedirectOptions { MaxRedirects = 3 } + }; + using var client = new RestClient(options); + + var request = new RestRequest("/redirect-countdown?n=10"); + var response = await client.ExecuteAsync(request); + + // After 3 redirects, should return the 4th redirect response as-is + response.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect); + } + + [Fact] + public async Task Should_Follow_All_Redirects_When_Under_MaxRedirects() { + var options = new RestClientOptions(_server.Url!) { + RedirectOptions = new RedirectOptions { MaxRedirects = 50 } + }; + using var client = new RestClient(options); + + 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 ──────────────────────────────────────────────────── + + [Fact] + public async Task Post_Should_Become_Get_On_302() { + var options = new RestClientOptions(_server.Url!); + using var client = new RestClient(options); + + 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"); + } + + [Fact] + public async Task Post_Should_Become_Get_On_303() { + var options = new RestClientOptions(_server.Url!); + using var client = new RestClient(options); + + var request = new RestRequest("/redirect-with-status?status=303", 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"); + } + + [Fact] + public async Task Post_Should_Stay_Post_On_307() { + var options = new RestClientOptions(_server.Url!); + using var client = new RestClient(options); + + var request = new RestRequest("/redirect-with-status?status=307", 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("POST"); + } + + [Fact] + public async Task Post_Should_Stay_Post_On_308() { + var options = new RestClientOptions(_server.Url!); + using var client = new RestClient(options); + + var request = new RestRequest("/redirect-with-status?status=308", 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("POST"); + } + + [Fact] + public async Task Body_Should_Be_Dropped_When_Verb_Changes_To_Get() { + var options = new RestClientOptions(_server.Url!); + using var client = new RestClient(options); + + 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 ────────────────────────────────────────────── + + [Fact] + public async Task ForwardHeaders_False_Should_Strip_Custom_Headers() { + var options = new RestClientOptions(_server.Url!) { + RedirectOptions = new RedirectOptions { ForwardHeaders = false } + }; + using var client = new RestClient(options); + + 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); + response.Content.Should().NotContain("X-Custom-Header"); + } + + [Fact] + public async Task ForwardAuthorization_False_Should_Strip_Authorization_Header() { + var options = new RestClientOptions(_server.Url!) { + RedirectOptions = new RedirectOptions { ForwardAuthorization = false } + }; + using var client = new RestClient(options); + + 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); + response.Content.Should().NotContain("Bearer test-token"); + } + + [Fact] + public async Task ForwardCookies_False_Should_Not_Send_Cookies_On_Redirect() { + var host = new Uri(_server.Url!).Host; + var options = new RestClientOptions(_server.Url!) { + CookieContainer = new(), + RedirectOptions = new RedirectOptions { ForwardCookies = false } + }; + using var client = new RestClient(options); + + 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); + // Cookies should still be stored in the container + options.CookieContainer.GetCookies(new Uri(_server.Url!)).Cast().Should() + .Contain(c => c.Name == "redirectCookie", + "Set-Cookie should still be stored even when ForwardCookies is false"); + // But not forwarded to the redirect target + response.Content.Should().NotContain("existingCookie", + "cookies should not be sent to the redirect target when ForwardCookies is false"); + response.Content.Should().NotContain("redirectCookie", + "cookies should not be sent to the redirect target when ForwardCookies is false"); + } + + // ─── ForwardHeaders = true (positive test) ───────────────────────── + + [Fact] + public async Task ForwardHeaders_True_Should_Forward_Custom_Headers() { + var options = new RestClientOptions(_server.Url!) { + RedirectOptions = new RedirectOptions { ForwardHeaders = true } + }; + using var client = new RestClient(options); + + 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); + response.Content.Should().Contain("X-Custom-Header"); + response.Content.Should().Contain("custom-value"); + } + + // ─── ForwardAuthorization = true (positive test) ──────────────────── + + [Fact] + public async Task ForwardAuthorization_True_Should_Forward_Authorization_Header() { + var options = new RestClientOptions(_server.Url!) { + RedirectOptions = new RedirectOptions { ForwardAuthorization = true } + }; + using var client = new RestClient(options); + + var request = new RestRequest("/redirect-with-status?status=302"); + request.AddHeader("Authorization", "Bearer keep-me"); + + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().Contain("Bearer keep-me"); + } + + // ─── ForwardBody = false ──────────────────────────────────────────── + + [Fact] + public async Task ForwardBody_False_Should_Drop_Body_Even_When_Verb_Preserved() { + var options = new RestClientOptions(_server.Url!) { + RedirectOptions = new RedirectOptions { ForwardBody = false } + }; + using var client = new RestClient(options); + + // 307 preserves verb, but ForwardBody=false should still drop the body + var request = new RestRequest("/redirect-with-status?status=307", Method.Post); + request.AddJsonBody(new { data = "should-be-dropped" }); + + 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"); + doc.RootElement.GetProperty("Body").GetString().Should().BeEmpty(); + } + + [Fact] + public async Task ForwardBody_True_Should_Forward_Body_When_Verb_Preserved() { + var options = new RestClientOptions(_server.Url!) { + RedirectOptions = new RedirectOptions { ForwardBody = true } + }; + using var client = new RestClient(options); + + // 307 preserves verb, ForwardBody=true (default) should keep the body + var request = new RestRequest("/redirect-with-status?status=307", Method.Post); + request.AddJsonBody(new { data = "keep-me" }); + + 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"); + doc.RootElement.GetProperty("Body").GetString().Should().Contain("keep-me"); + } + + // ─── ForwardQuery ─────────────────────────────────────────────────── + + [Fact] + public async Task ForwardQuery_True_Should_Carry_Query_When_Redirect_Has_No_Query() { + var options = new RestClientOptions(_server.Url!) { + RedirectOptions = new RedirectOptions { ForwardQuery = true } + }; + using var client = new RestClient(options); + + // /redirect-no-query redirects to /echo-request with no query in the Location header + var request = new RestRequest("/redirect-no-query"); + request.AddQueryParameter("foo", "bar"); + + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + // The original query should be carried to the redirect target + response.ResponseUri!.Query.Should().Contain("foo=bar"); + } + + [Fact] + public async Task ForwardQuery_False_Should_Not_Carry_Query_To_Redirect() { + var options = new RestClientOptions(_server.Url!) { + RedirectOptions = new RedirectOptions { ForwardQuery = false } + }; + using var client = new RestClient(options); + + var request = new RestRequest("/redirect-no-query"); + request.AddQueryParameter("foo", "bar"); + + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + // The original query should NOT be carried to the redirect target + (response.ResponseUri!.Query ?? "").Should().NotContain("foo=bar"); + } + + // ─── RedirectStatusCodes customization ────────────────────────────── + + [Fact] + public async Task Custom_RedirectStatusCodes_Should_Follow_Custom_Code() { + var options = new RestClientOptions(_server.Url!) { + RedirectOptions = new RedirectOptions { + RedirectStatusCodes = [HttpStatusCode.Found, (HttpStatusCode)399] + } + }; + using var client = new RestClient(options); + + // 399 is not a standard redirect, but we added it to the list + 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() { + var options = new RestClientOptions(_server.Url!) { + RedirectOptions = new RedirectOptions { + // 302 is NOT in the custom list + RedirectStatusCodes = [(HttpStatusCode)399] + } + }; + using var client = new RestClient(options); + + 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 ────────────────────────────────────── + + [Fact] + public async Task FollowRedirectsToInsecure_False_Should_Block_Https_To_Http() { + // Create an HTTPS WireMock server + 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> { + // Redirect to plain HTTP server + ["Location"] = new(_server.Url + "/echo-request") + } + })); + + var options = new RestClientOptions(httpsServer.Url!) { + RemoteCertificateValidationCallback = (_, _, _, _) => true, + RedirectOptions = new RedirectOptions { FollowRedirectsToInsecure = false } + }; + using var client = new RestClient(options); + + var request = new RestRequest("/https-redirect"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect, + "HTTPS to HTTP redirect should be blocked when FollowRedirectsToInsecure is false"); + } + + [Fact] + public async Task FollowRedirectsToInsecure_True_Should_Allow_Https_To_Http() { + // Create an HTTPS WireMock server + 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> { + // Redirect to plain HTTP server + ["Location"] = new(_server.Url + "/echo-request") + } + })); + + var options = new RestClientOptions(httpsServer.Url!) { + RemoteCertificateValidationCallback = (_, _, _, _) => true, + RedirectOptions = new RedirectOptions { FollowRedirectsToInsecure = true } + }; + using var client = new RestClient(options); + + var request = new RestRequest("/https-redirect"); + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK, + "HTTPS to HTTP redirect should be allowed when FollowRedirectsToInsecure is true"); + } + + public void Dispose() => _server.Dispose(); +} diff --git a/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs b/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs index 527e3d5a0..c9ed92dd9 100644 --- a/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs +++ b/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs @@ -44,6 +44,15 @@ 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)); } static ResponseMessage WrapForm(IRequestMessage request) { @@ -94,6 +103,59 @@ 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) + } + }; + } + + 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 ?? "" + }); + } + 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) { From 4586df884e1da1862169d67c5eff8bd8c5eb4b5f Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 27 Feb 2026 11:17:31 +0100 Subject: [PATCH 2/6] Address PR review feedback: reduce complexity, fix disposal, fix duplicate cookies - Extract redirect loop into smaller focused methods (SendWithRedirectsAsync, ShouldFollowRedirect, ResolveRedirectUrl, CreateRedirectMessage, ParseResponseCookies, AddPendingCookies) to reduce cognitive complexity - Fix double-dispose warning (S3966) by using previousMessage pattern and try/finally for message disposal in SendWithRedirectsAsync - Fix duplicate Cookie header bug in AddCookieHeaders (remove existing parameter before adding merged cookies) - Add Host/CacheControl headers to redirect request messages - Add comments for intentional cert validation bypass in HTTPS tests Co-Authored-By: Claude Opus 4.6 --- src/RestSharp/Request/RequestHeaders.cs | 1 + src/RestSharp/RestClient.Async.cs | 197 +++++++++++------- .../CookieRedirectTests.cs | 2 + 3 files changed, 122 insertions(+), 78 deletions(-) diff --git a/src/RestSharp/Request/RequestHeaders.cs b/src/RestSharp/Request/RequestHeaders.cs index 50f2d5a68..86d4be6fd 100644 --- a/src/RestSharp/Request/RequestHeaders.cs +++ b/src/RestSharp/Request/RequestHeaders.cs @@ -53,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 1ab6c6b5c..b9b72227b 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); /// @@ -125,17 +125,7 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo // 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) @@ -156,93 +146,144 @@ async Task ExecuteRequestAsync(RestRequest request, CancellationTo #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; - HttpResponseMessage responseMessage; - try { 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); - } + var responseMessage = await HttpClient.SendAsync(message, request.CompletionOption, ct).ConfigureAwait(false); - // Check if this is a redirect we should follow - if (!redirectOptions.FollowRedirects || - !redirectOptions.RedirectStatusCodes.Contains(responseMessage.StatusCode) || - responseMessage.Headers.Location == null) { - break; - } + ParseResponseCookies(responseMessage, url, cookieContainer); - redirectCount++; - - if (redirectCount > redirectOptions.MaxRedirects) { - break; + if (!ShouldFollowRedirect(redirectOptions, responseMessage, redirectCount)) { + return (responseMessage, url, null); } - // 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; - } + var redirectUrl = ResolveRedirectUrl(url, responseMessage, redirectOptions); - // Block HTTPS → HTTP unless explicitly allowed - if (url.Scheme == "https" && redirectUrl.Scheme == "http" && !redirectOptions.FollowRedirectsToInsecure) { - break; + if (redirectUrl == null) { + return (responseMessage, url, null); } - // Determine verb change per RFC 7231 - var newMethod = GetRedirectMethod(httpMethod, responseMessage.StatusCode); + 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 + var previousMessage = message; url = redirectUrl; httpMethod = newMethod; + redirectCount++; - // 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); + message = CreateRedirectMessage( + httpMethod, url, request, redirectOptions, cookieContainer, contentToDispose, verbChangedToGet + ); + previousMessage.Dispose(); } } catch (Exception ex) { + return (null, url, ex); + } + finally { message.Dispose(); - DisposeContent(contentToDispose); - return new(null, url, null, ex, timeoutCts.Token); } + } - message.Dispose(); - DisposeContent(contentToDispose); + 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 + } + } -#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); + 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, + 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, redirectOptions, request, cookieContainer); + redirectMessage.AddHeaders(redirectHeaders); + + return redirectMessage; } RequestHeaders BuildRedirectHeaders(Uri url, RedirectOptions redirectOptions, RestRequest request, CookieContainer cookieContainer) { @@ -357,4 +398,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/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs b/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs index 6aa99b6cc..1d2f9bb2f 100644 --- a/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs @@ -569,6 +569,7 @@ public async Task FollowRedirectsToInsecure_False_Should_Block_Https_To_Http() { })); var options = new RestClientOptions(httpsServer.Url!) { + // Cert validation disabled intentionally: local test HTTPS server uses self-signed cert RemoteCertificateValidationCallback = (_, _, _, _) => true, RedirectOptions = new RedirectOptions { FollowRedirectsToInsecure = false } }; @@ -600,6 +601,7 @@ public async Task FollowRedirectsToInsecure_True_Should_Allow_Https_To_Http() { })); var options = new RestClientOptions(httpsServer.Url!) { + // Cert validation disabled intentionally: local test HTTPS server uses self-signed cert RemoteCertificateValidationCallback = (_, _, _, _) => true, RedirectOptions = new RedirectOptions { FollowRedirectsToInsecure = true } }; From 8b93c49086b83783451174bdcc6760efd13257cc Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 27 Feb 2026 11:31:04 +0100 Subject: [PATCH 3/6] Reduce test code duplication flagged by SonarCloud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move shared test endpoints (set-cookie-and-redirect, echo-cookies, redirect-no-query, redirect-custom-status) into WireMockTestServer - Switch CookieRedirectTests to use IClassFixture instead of standalone WireMockServer, eliminating cross-file duplication - Parameterize verb change tests with [Theory]/[InlineData] (5 tests → 1) - Parameterize header, auth, query, and HTTPS tests with [Theory] - Extract CreateClient helper to reduce setup boilerplate - CookieRedirectTests: 616 → 336 lines (45% reduction) Co-Authored-By: Claude Opus 4.6 --- .../CookieRedirectTests.cs | 480 ++++-------------- .../Server/WireMockTestServer.cs | 58 +++ 2 files changed, 157 insertions(+), 381 deletions(-) diff --git a/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs b/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs index 1d2f9bb2f..adb104c7e 100644 --- a/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs @@ -13,189 +13,47 @@ namespace RestSharp.Tests.Integrated; /// Verifies fixes for https://github.com/restsharp/RestSharp/issues/2077 /// and https://github.com/restsharp/RestSharp/issues/2059 /// -public sealed class CookieRedirectTests : IDisposable { - readonly WireMockServer _server = WireMockServer.Start(); - - public CookieRedirectTests() { - // Endpoint that sets a cookie via Set-Cookie header and redirects to /echo-cookies - _server - .Given(Request.Create().WithPath("/set-cookie-and-redirect").UsingGet()) - .RespondWith(Response.Create().WithCallback(request => { - var url = "/echo-cookies"; - if (request.Query != null && request.Query.TryGetValue("url", out var urlValues)) { - url = urlValues[0]; - } - - var response = new ResponseMessage { - StatusCode = 302, - Headers = new Dictionary>() - }; - response.Headers.Add("Location", new WireMockList(url)); - response.Headers.Add( - "Set-Cookie", - new WireMockList("redirectCookie=value1; Path=/") - ); - return response; - })); - - // Endpoint that echoes back received Cookie header values as JSON - _server - .Given(Request.Create().WithPath("/echo-cookies").UsingGet()) - .RespondWith(Response.Create().WithCallback(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 WireMockTestServer.CreateJson(new { - RawCookieHeaders = cookieHeaders, - ParsedCookies = parsedCookies - }); - })); - - // Endpoint that echoes request details (method, headers, body) - _server - .Given(Request.Create().WithPath("/echo-request")) - .RespondWith(Response.Create().WithCallback(request => { - var headers = request.Headers? - .ToDictionary(x => x.Key, x => string.Join(", ", x.Value)) - ?? new Dictionary(); - - return WireMockTestServer.CreateJson(new { - Method = request.Method, - Headers = headers, - Body = request.Body ?? "" - }); - })); - - // Redirect with configurable status code - _server - .Given(Request.Create().WithPath("/redirect-with-status")) - .RespondWith(Response.Create().WithCallback(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) - } - }; - })); - - // Countdown redirect: 307 redirects to self with n-1, returns 200 when n<=1 - _server - .Given(Request.Create().WithPath("/redirect-countdown")) - .RespondWith(Response.Create().WithCallback(request => { - var n = 1; - if (request.Query != null && request.Query.TryGetValue("n", out var nValues)) - n = int.Parse(nValues[0]); - - if (n <= 1) { - return WireMockTestServer.CreateJson(new { Message = "Done!" }); - } - - return new ResponseMessage { - StatusCode = (int)HttpStatusCode.TemporaryRedirect, - Headers = new Dictionary> { - ["Location"] = new($"/redirect-countdown?n={n - 1}") - } - }; - })); +public sealed class CookieRedirectTests(WireMockTestServer server) : IClassFixture, IDisposable { + readonly RestClient _client = new(server.Url!); - // Redirect to a path with no query (used for ForwardQuery tests) - _server - .Given(Request.Create().WithPath("/redirect-no-query")) - .RespondWith(Response.Create().WithCallback(_ => new ResponseMessage { - StatusCode = 302, - Headers = new Dictionary> { - ["Location"] = new("/echo-request") - } - })); - - // Echo query string parameters - _server - .Given(Request.Create().WithPath("/echo-query")) - .RespondWith(Response.Create().WithCallback(request => { - var query = request.Query? - .ToDictionary(x => x.Key, x => string.Join(",", x.Value)) - ?? new Dictionary(); - return WireMockTestServer.CreateJson(new { Query = query }); - })); - - // Redirect that preserves a custom status code for testing RedirectStatusCodes - _server - .Given(Request.Create().WithPath("/redirect-custom-status")) - .RespondWith(Response.Create().WithCallback(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") - } - }; - })); + 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() { - var options = new RestClientOptions(_server.Url!) { - FollowRedirects = true, - CookieContainer = new() - }; - using var client = new RestClient(options); + 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); - var content = response.Content!; - - content.Should().Contain("redirectCookie", + 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 options = new RestClientOptions(_server.Url!) { - FollowRedirects = true, - CookieContainer = new() - }; - using var client = new RestClient(options); + 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); - - var cookies = options.CookieContainer.GetCookies(new Uri(_server.Url!)); - cookies.Cast().Should().Contain(c => c.Name == "redirectCookie" && c.Value == "value1", - "cookies from Set-Cookie headers on redirect responses should be stored in the CookieContainer"); + 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; - var options = new RestClientOptions(_server.Url!) { - FollowRedirects = true, - CookieContainer = new() - }; - using var client = new RestClient(options); + 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() @@ -205,11 +63,9 @@ public async Task Redirect_With_Existing_Cookies_Should_Include_Both_Old_And_New var response = await client.ExecuteAsync(request); response.StatusCode.Should().Be(HttpStatusCode.OK); - var content = response.Content!; - - content.Should().Contain("existingCookie", + response.Content!.Should().Contain("existingCookie", "pre-existing cookies should be forwarded through redirects"); - content.Should().Contain("redirectCookie", + response.Content.Should().Contain("redirectCookie", "cookies set during redirect should also arrive at the final destination"); } @@ -217,10 +73,7 @@ public async Task Redirect_With_Existing_Cookies_Should_Include_Both_Old_And_New [Fact] public async Task FollowRedirects_False_Should_Return_Redirect_Response() { - var options = new RestClientOptions(_server.Url!) { - FollowRedirects = false - }; - using var client = new RestClient(options); + using var client = CreateClient(o => o.FollowRedirects = false); var request = new RestRequest("/set-cookie-and-redirect"); var response = await client.ExecuteAsync(request); @@ -232,24 +85,17 @@ public async Task FollowRedirects_False_Should_Return_Redirect_Response() { [Fact] public async Task Should_Stop_After_MaxRedirects() { - var options = new RestClientOptions(_server.Url!) { - RedirectOptions = new RedirectOptions { MaxRedirects = 3 } - }; - using var client = new RestClient(options); + 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); - // After 3 redirects, should return the 4th redirect response as-is response.StatusCode.Should().Be(HttpStatusCode.TemporaryRedirect); } [Fact] public async Task Should_Follow_All_Redirects_When_Under_MaxRedirects() { - var options = new RestClientOptions(_server.Url!) { - RedirectOptions = new RedirectOptions { MaxRedirects = 50 } - }; - using var client = new RestClient(options); + 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); @@ -258,72 +104,29 @@ public async Task Should_Follow_All_Redirects_When_Under_MaxRedirects() { response.Content.Should().Contain("Done!"); } - // ─── Verb changes ──────────────────────────────────────────────────── + // ─── Verb changes (parameterized) ──────────────────────────────────── - [Fact] - public async Task Post_Should_Become_Get_On_302() { - var options = new RestClientOptions(_server.Url!); - using var client = new RestClient(options); + [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=302", Method.Post); + 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("GET"); - } - - [Fact] - public async Task Post_Should_Become_Get_On_303() { - var options = new RestClientOptions(_server.Url!); - using var client = new RestClient(options); - - var request = new RestRequest("/redirect-with-status?status=303", 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"); - } - - [Fact] - public async Task Post_Should_Stay_Post_On_307() { - var options = new RestClientOptions(_server.Url!); - using var client = new RestClient(options); - - var request = new RestRequest("/redirect-with-status?status=307", 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("POST"); - } - - [Fact] - public async Task Post_Should_Stay_Post_On_308() { - var options = new RestClientOptions(_server.Url!); - using var client = new RestClient(options); - - var request = new RestRequest("/redirect-with-status?status=308", 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("POST"); + doc.RootElement.GetProperty("Method").GetString().Should().Be(expectedMethod); } [Fact] public async Task Body_Should_Be_Dropped_When_Verb_Changes_To_Get() { - var options = new RestClientOptions(_server.Url!); - using var client = new RestClient(options); + using var client = CreateClient(); var request = new RestRequest("/redirect-with-status?status=302", Method.Post); request.AddJsonBody(new { data = "test" }); @@ -338,12 +141,13 @@ public async Task Body_Should_Be_Dropped_When_Verb_Changes_To_Get() { // ─── Header forwarding ────────────────────────────────────────────── - [Fact] - public async Task ForwardHeaders_False_Should_Strip_Custom_Headers() { - var options = new RestClientOptions(_server.Url!) { - RedirectOptions = new RedirectOptions { ForwardHeaders = false } - }; - using var client = new RestClient(options); + [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"); @@ -351,15 +155,20 @@ public async Task ForwardHeaders_False_Should_Strip_Custom_Headers() { var response = await client.ExecuteAsync(request); response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Should().NotContain("X-Custom-Header"); + + if (forwardHeaders) + response.Content.Should().Contain("X-Custom-Header").And.Contain("custom-value"); + else + response.Content.Should().NotContain("X-Custom-Header"); } - [Fact] - public async Task ForwardAuthorization_False_Should_Strip_Authorization_Header() { - var options = new RestClientOptions(_server.Url!) { - RedirectOptions = new RedirectOptions { ForwardAuthorization = false } - }; - using var client = new RestClient(options); + [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"); @@ -367,17 +176,21 @@ public async Task ForwardAuthorization_False_Should_Strip_Authorization_Header() var response = await client.ExecuteAsync(request); response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Should().NotContain("Bearer test-token"); + + if (forwardAuth) + response.Content.Should().Contain("Bearer test-token"); + else + response.Content.Should().NotContain("Bearer test-token"); } [Fact] public async Task ForwardCookies_False_Should_Not_Send_Cookies_On_Redirect() { - var host = new Uri(_server.Url!).Host; - var options = new RestClientOptions(_server.Url!) { - CookieContainer = new(), - RedirectOptions = new RedirectOptions { ForwardCookies = false } - }; - using var client = new RestClient(options); + 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() @@ -387,64 +200,21 @@ public async Task ForwardCookies_False_Should_Not_Send_Cookies_On_Redirect() { var response = await client.ExecuteAsync(request); response.StatusCode.Should().Be(HttpStatusCode.OK); - // Cookies should still be stored in the container - options.CookieContainer.GetCookies(new Uri(_server.Url!)).Cast().Should() + cookieContainer.GetCookies(new Uri(server.Url!)).Cast().Should() .Contain(c => c.Name == "redirectCookie", "Set-Cookie should still be stored even when ForwardCookies is false"); - // But not forwarded to the redirect target - response.Content.Should().NotContain("existingCookie", - "cookies should not be sent to the redirect target when ForwardCookies is false"); - response.Content.Should().NotContain("redirectCookie", - "cookies should not be sent to the redirect target when ForwardCookies is false"); + response.Content.Should().NotContain("existingCookie"); + response.Content.Should().NotContain("redirectCookie"); } - // ─── ForwardHeaders = true (positive test) ───────────────────────── - - [Fact] - public async Task ForwardHeaders_True_Should_Forward_Custom_Headers() { - var options = new RestClientOptions(_server.Url!) { - RedirectOptions = new RedirectOptions { ForwardHeaders = true } - }; - using var client = new RestClient(options); - - 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); - response.Content.Should().Contain("X-Custom-Header"); - response.Content.Should().Contain("custom-value"); - } - - // ─── ForwardAuthorization = true (positive test) ──────────────────── - - [Fact] - public async Task ForwardAuthorization_True_Should_Forward_Authorization_Header() { - var options = new RestClientOptions(_server.Url!) { - RedirectOptions = new RedirectOptions { ForwardAuthorization = true } - }; - using var client = new RestClient(options); - - var request = new RestRequest("/redirect-with-status?status=302"); - request.AddHeader("Authorization", "Bearer keep-me"); - - var response = await client.ExecuteAsync(request); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Should().Contain("Bearer keep-me"); - } - - // ─── ForwardBody = false ──────────────────────────────────────────── + // ─── ForwardBody ──────────────────────────────────────────────────── [Fact] public async Task ForwardBody_False_Should_Drop_Body_Even_When_Verb_Preserved() { - var options = new RestClientOptions(_server.Url!) { - RedirectOptions = new RedirectOptions { ForwardBody = false } - }; - using var client = new RestClient(options); + using var client = CreateClient(o => + o.RedirectOptions = new RedirectOptions { ForwardBody = false } + ); - // 307 preserves verb, but ForwardBody=false should still drop the body var request = new RestRequest("/redirect-with-status?status=307", Method.Post); request.AddJsonBody(new { data = "should-be-dropped" }); @@ -458,12 +228,10 @@ public async Task ForwardBody_False_Should_Drop_Body_Even_When_Verb_Preserved() [Fact] public async Task ForwardBody_True_Should_Forward_Body_When_Verb_Preserved() { - var options = new RestClientOptions(_server.Url!) { - RedirectOptions = new RedirectOptions { ForwardBody = true } - }; - using var client = new RestClient(options); + using var client = CreateClient(o => + o.RedirectOptions = new RedirectOptions { ForwardBody = true } + ); - // 307 preserves verb, ForwardBody=true (default) should keep the body var request = new RestRequest("/redirect-with-status?status=307", Method.Post); request.AddJsonBody(new { data = "keep-me" }); @@ -477,53 +245,37 @@ public async Task ForwardBody_True_Should_Forward_Body_When_Verb_Preserved() { // ─── ForwardQuery ─────────────────────────────────────────────────── - [Fact] - public async Task ForwardQuery_True_Should_Carry_Query_When_Redirect_Has_No_Query() { - var options = new RestClientOptions(_server.Url!) { - RedirectOptions = new RedirectOptions { ForwardQuery = true } - }; - using var client = new RestClient(options); + [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 } + ); - // /redirect-no-query redirects to /echo-request with no query in the Location header var request = new RestRequest("/redirect-no-query"); request.AddQueryParameter("foo", "bar"); var response = await client.ExecuteAsync(request); response.StatusCode.Should().Be(HttpStatusCode.OK); - // The original query should be carried to the redirect target - response.ResponseUri!.Query.Should().Contain("foo=bar"); - } - - [Fact] - public async Task ForwardQuery_False_Should_Not_Carry_Query_To_Redirect() { - var options = new RestClientOptions(_server.Url!) { - RedirectOptions = new RedirectOptions { ForwardQuery = false } - }; - using var client = new RestClient(options); - - var request = new RestRequest("/redirect-no-query"); - request.AddQueryParameter("foo", "bar"); - var response = await client.ExecuteAsync(request); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - // The original query should NOT be carried to the redirect target - (response.ResponseUri!.Query ?? "").Should().NotContain("foo=bar"); + 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() { - var options = new RestClientOptions(_server.Url!) { - RedirectOptions = new RedirectOptions { + using var client = CreateClient(o => + o.RedirectOptions = new RedirectOptions { RedirectStatusCodes = [HttpStatusCode.Found, (HttpStatusCode)399] } - }; - using var client = new RestClient(options); + ); - // 399 is not a standard redirect, but we added it to the list var request = new RestRequest("/redirect-custom-status?status=399"); var response = await client.ExecuteAsync(request); @@ -533,13 +285,11 @@ public async Task Custom_RedirectStatusCodes_Should_Follow_Custom_Code() { [Fact] public async Task Custom_RedirectStatusCodes_Should_Not_Follow_Excluded_Code() { - var options = new RestClientOptions(_server.Url!) { - RedirectOptions = new RedirectOptions { - // 302 is NOT in the custom list + using var client = CreateClient(o => + o.RedirectOptions = new RedirectOptions { RedirectStatusCodes = [(HttpStatusCode)399] } - }; - using var client = new RestClient(options); + ); var request = new RestRequest("/redirect-with-status?status=302"); var response = await client.ExecuteAsync(request); @@ -550,9 +300,12 @@ public async Task Custom_RedirectStatusCodes_Should_Not_Follow_Excluded_Code() { // ─── FollowRedirectsToInsecure ────────────────────────────────────── - [Fact] - public async Task FollowRedirectsToInsecure_False_Should_Block_Https_To_Http() { - // Create an HTTPS WireMock server + [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 @@ -563,56 +316,21 @@ public async Task FollowRedirectsToInsecure_False_Should_Block_Https_To_Http() { .RespondWith(Response.Create().WithCallback(_ => new ResponseMessage { StatusCode = 302, Headers = new Dictionary> { - // Redirect to plain HTTP server - ["Location"] = new(_server.Url + "/echo-request") + ["Location"] = new(server.Url + "/echo-request") } })); - var options = new RestClientOptions(httpsServer.Url!) { + 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 = false } - }; - using var client = new RestClient(options); - - var request = new RestRequest("/https-redirect"); - var response = await client.ExecuteAsync(request); - - response.StatusCode.Should().Be(HttpStatusCode.Redirect, - "HTTPS to HTTP redirect should be blocked when FollowRedirectsToInsecure is false"); - } - - [Fact] - public async Task FollowRedirectsToInsecure_True_Should_Allow_Https_To_Http() { - // Create an HTTPS WireMock server - using var httpsServer = WireMockServer.Start(new WireMock.Settings.WireMockServerSettings { - Port = 0, - UseSSL = true + RedirectOptions = new RedirectOptions { FollowRedirectsToInsecure = allowInsecure } }); - httpsServer - .Given(Request.Create().WithPath("/https-redirect")) - .RespondWith(Response.Create().WithCallback(_ => new ResponseMessage { - StatusCode = 302, - Headers = new Dictionary> { - // Redirect to plain HTTP server - ["Location"] = new(_server.Url + "/echo-request") - } - })); - - var options = new RestClientOptions(httpsServer.Url!) { - // Cert validation disabled intentionally: local test HTTPS server uses self-signed cert - RemoteCertificateValidationCallback = (_, _, _, _) => true, - RedirectOptions = new RedirectOptions { FollowRedirectsToInsecure = true } - }; - using var client = new RestClient(options); - var request = new RestRequest("/https-redirect"); var response = await client.ExecuteAsync(request); - response.StatusCode.Should().Be(HttpStatusCode.OK, - "HTTPS to HTTP redirect should be allowed when FollowRedirectsToInsecure is true"); + response.StatusCode.Should().Be(expectedStatus); } - public void Dispose() => _server.Dispose(); + public void Dispose() => _client.Dispose(); } diff --git a/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs b/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs index c9ed92dd9..1133434a1 100644 --- a/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs +++ b/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs @@ -53,6 +53,23 @@ public WireMockTestServer() : base(new() { Port = 0, UseHttp2 = false, UseSSL = 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) { @@ -156,6 +173,47 @@ static ResponseMessage EchoRequest(IRequestMessage request) { }); } + 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 { From 19af2d4793324aa68e488dc66dc394d49c298dc4 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 27 Feb 2026 12:21:51 +0100 Subject: [PATCH 4/6] Strip Authorization header on cross-origin and HTTPS-to-HTTP redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address security concern from PR review: ForwardAuthorization could leak credentials to unintended hosts on redirect. - Compare full authority (host+port) against original request URL, matching browser same-origin policy - Always strip Authorization on HTTPS→HTTP redirects (defense-in-depth) - Add ForwardAuthorizationToExternalHost option (default false) for explicit opt-in to cross-origin auth forwarding - Add tests for cross-host auth stripping and explicit opt-in Co-Authored-By: Claude Opus 4.6 --- src/RestSharp/Options/RedirectOptions.cs | 11 ++- src/RestSharp/RestClient.Async.cs | 24 +++++- .../CookieRedirectTests.cs | 75 +++++++++++++++++++ 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/src/RestSharp/Options/RedirectOptions.cs b/src/RestSharp/Options/RedirectOptions.cs index 21db27057..baaafdb86 100644 --- a/src/RestSharp/Options/RedirectOptions.cs +++ b/src/RestSharp/Options/RedirectOptions.cs @@ -35,10 +35,19 @@ public class RedirectOptions { public bool ForwardHeaders { get; set; } = true; /// - /// Whether to forward the Authorization header on redirect. Default is false. + /// 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. diff --git a/src/RestSharp/RestClient.Async.cs b/src/RestSharp/RestClient.Async.cs index b9b72227b..73ed0410b 100644 --- a/src/RestSharp/RestClient.Async.cs +++ b/src/RestSharp/RestClient.Async.cs @@ -174,6 +174,7 @@ CancellationToken ct ) { var redirectOptions = Options.RedirectOptions; var redirectCount = 0; + var originalUrl = url; try { while (true) { @@ -202,7 +203,7 @@ CancellationToken ct redirectCount++; message = CreateRedirectMessage( - httpMethod, url, request, redirectOptions, cookieContainer, contentToDispose, verbChangedToGet + httpMethod, url, originalUrl, request, redirectOptions, cookieContainer, contentToDispose, verbChangedToGet ); previousMessage.Dispose(); } @@ -263,6 +264,7 @@ static bool ShouldFollowRedirect(RedirectOptions options, HttpResponseMessage re HttpRequestMessage CreateRedirectMessage( HttpMethod httpMethod, Uri url, + Uri originalUrl, RestRequest request, RedirectOptions redirectOptions, CookieContainer cookieContainer, @@ -280,13 +282,15 @@ bool verbChangedToGet redirectMessage.Content = redirectContent.BuildContent(); } - var redirectHeaders = BuildRedirectHeaders(url, redirectOptions, request, cookieContainer); + var redirectHeaders = BuildRedirectHeaders(url, originalUrl, redirectOptions, request, cookieContainer); redirectMessage.AddHeaders(redirectHeaders); return redirectMessage; } - RequestHeaders BuildRedirectHeaders(Uri url, RedirectOptions redirectOptions, RestRequest request, CookieContainer cookieContainer) { + RequestHeaders BuildRedirectHeaders( + Uri url, Uri originalUrl, RedirectOptions redirectOptions, RestRequest request, CookieContainer cookieContainer + ) { var redirectHeaders = new RequestHeaders(); if (redirectOptions.ForwardHeaders) { @@ -295,7 +299,7 @@ RequestHeaders BuildRedirectHeaders(Uri url, RedirectOptions redirectOptions, Re .AddHeaders(DefaultParameters) .AddAcceptHeader(AcceptedContentTypes); - if (!redirectOptions.ForwardAuthorization) { + if (!ShouldForwardAuthorization(url, originalUrl, redirectOptions)) { redirectHeaders.RemoveHeader(KnownHeaders.Authorization); } } @@ -315,6 +319,18 @@ RequestHeaders BuildRedirectHeaders(Uri url, RedirectOptions redirectOptions, Re 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) { diff --git a/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs b/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs index adb104c7e..a23521eb5 100644 --- a/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs @@ -183,6 +183,81 @@ public async Task ForwardAuthorization_Controls_Auth_Header_Forwarding(bool forw response.Content.Should().NotContain("Bearer test-token"); } + [Fact] + public async Task ForwardAuthorization_Should_Strip_Auth_On_Cross_Host_Redirect_By_Default() { + // Create a second server (different host/port) that echoes request details + using var externalServer = WireMockServer.Start(); + externalServer + .Given(Request.Create().WithPath("/echo-request")) + .RespondWith(Response.Create().WithCallback(request => { + var headers = request.Headers? + .ToDictionary(x => x.Key, x => string.Join(", ", x.Value)) + ?? new Dictionary(); + return WireMockTestServer.CreateJson(new { Method = request.Method, Headers = headers, Body = request.Body ?? "" }); + })); + + // Configure the main server to redirect to the external server + server.Given(Request.Create().WithPath("/redirect-external")) + .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 } + ); + + var request = new RestRequest("/redirect-external"); + request.AddHeader("Authorization", "Bearer secret-token"); + + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().NotContain("Bearer secret-token", + "Authorization should be stripped on cross-host redirects by default"); + } + + [Fact] + public async Task ForwardAuthorizationToExternalHost_Allows_Auth_On_Cross_Host_Redirect() { + // Create a second server (different host/port) that echoes request details + using var externalServer = WireMockServer.Start(); + externalServer + .Given(Request.Create().WithPath("/echo-request")) + .RespondWith(Response.Create().WithCallback(request => { + var headers = request.Headers? + .ToDictionary(x => x.Key, x => string.Join(", ", x.Value)) + ?? new Dictionary(); + return WireMockTestServer.CreateJson(new { Method = request.Method, Headers = headers, Body = request.Body ?? "" }); + })); + + // Configure the main server to redirect to the external server + server.Given(Request.Create().WithPath("/redirect-external-auth")) + .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 = true + } + ); + + var request = new RestRequest("/redirect-external-auth"); + request.AddHeader("Authorization", "Bearer secret-token"); + + var response = await client.ExecuteAsync(request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Should().Contain("Bearer secret-token", + "Authorization should be forwarded when ForwardAuthorizationToExternalHost is true"); + } + [Fact] public async Task ForwardCookies_False_Should_Not_Send_Cookies_On_Redirect() { var host = new Uri(server.Url!).Host; From a248fca6df8924bd1cda283f480c90a4013f93e1 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 27 Feb 2026 13:05:30 +0100 Subject: [PATCH 5/6] Consolidate cross-host auth tests into parameterized Theory to reduce duplication Co-Authored-By: Claude Opus 4.6 --- .../CookieRedirectTests.cs | 62 +++++-------------- 1 file changed, 17 insertions(+), 45 deletions(-) diff --git a/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs b/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs index a23521eb5..76aaf994b 100644 --- a/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs @@ -183,45 +183,13 @@ public async Task ForwardAuthorization_Controls_Auth_Header_Forwarding(bool forw response.Content.Should().NotContain("Bearer test-token"); } - [Fact] - public async Task ForwardAuthorization_Should_Strip_Auth_On_Cross_Host_Redirect_By_Default() { - // Create a second server (different host/port) that echoes request details - using var externalServer = WireMockServer.Start(); - externalServer - .Given(Request.Create().WithPath("/echo-request")) - .RespondWith(Response.Create().WithCallback(request => { - var headers = request.Headers? - .ToDictionary(x => x.Key, x => string.Join(", ", x.Value)) - ?? new Dictionary(); - return WireMockTestServer.CreateJson(new { Method = request.Method, Headers = headers, Body = request.Body ?? "" }); - })); - - // Configure the main server to redirect to the external server - server.Given(Request.Create().WithPath("/redirect-external")) - .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 } - ); - - var request = new RestRequest("/redirect-external"); - request.AddHeader("Authorization", "Bearer secret-token"); - - var response = await client.ExecuteAsync(request); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Should().NotContain("Bearer secret-token", - "Authorization should be stripped on cross-host redirects by default"); - } - - [Fact] - public async Task ForwardAuthorizationToExternalHost_Allows_Auth_On_Cross_Host_Redirect() { - // Create a second server (different host/port) that echoes request details + [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")) @@ -232,8 +200,9 @@ public async Task ForwardAuthorizationToExternalHost_Allows_Auth_On_Cross_Host_R return WireMockTestServer.CreateJson(new { Method = request.Method, Headers = headers, Body = request.Body ?? "" }); })); - // Configure the main server to redirect to the external server - server.Given(Request.Create().WithPath("/redirect-external-auth")) + // 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> { @@ -244,18 +213,21 @@ public async Task ForwardAuthorizationToExternalHost_Allows_Auth_On_Cross_Host_R using var client = CreateClient(o => o.RedirectOptions = new RedirectOptions { ForwardAuthorization = true, - ForwardAuthorizationToExternalHost = true + ForwardAuthorizationToExternalHost = allowExternal } ); - var request = new RestRequest("/redirect-external-auth"); + var request = new RestRequest(redirectPath); request.AddHeader("Authorization", "Bearer secret-token"); var response = await client.ExecuteAsync(request); response.StatusCode.Should().Be(HttpStatusCode.OK); - response.Content.Should().Contain("Bearer secret-token", - "Authorization should be forwarded when ForwardAuthorizationToExternalHost is true"); + + if (expectAuth) + response.Content.Should().Contain("Bearer secret-token"); + else + response.Content.Should().NotContain("Bearer secret-token"); } [Fact] From a866fe2b5bd4af69eb48a95edc63bd49024d62db Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Fri, 27 Feb 2026 13:07:38 +0100 Subject: [PATCH 6/6] Reduce code duplication: consolidate ForwardBody tests and reuse shared EchoRequest - Merge ForwardBody_False and ForwardBody_True into a single parameterized Theory - Replace inline echo-request callback with shared WireMockTestServer.EchoRequest - Make EchoRequest public for cross-project reuse Co-Authored-By: Claude Opus 4.6 --- .../CookieRedirectTests.cs | 38 ++++++------------- .../Server/WireMockTestServer.cs | 2 +- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs b/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs index 76aaf994b..bd9b25e0c 100644 --- a/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs +++ b/test/RestSharp.Tests.Integrated/CookieRedirectTests.cs @@ -193,12 +193,7 @@ public async Task ForwardAuthorizationToExternalHost_Controls_Cross_Origin_Auth( using var externalServer = WireMockServer.Start(); externalServer .Given(Request.Create().WithPath("/echo-request")) - .RespondWith(Response.Create().WithCallback(request => { - var headers = request.Headers? - .ToDictionary(x => x.Key, x => string.Join(", ", x.Value)) - ?? new Dictionary(); - return WireMockTestServer.CreateJson(new { Method = request.Method, Headers = headers, Body = request.Body ?? "" }); - })); + .RespondWith(Response.Create().WithCallback(WireMockTestServer.EchoRequest)); // Main server redirects to the external server var redirectPath = $"/redirect-external-{allowExternal}"; @@ -256,38 +251,27 @@ public async Task ForwardCookies_False_Should_Not_Send_Cookies_On_Redirect() { // ─── ForwardBody ──────────────────────────────────────────────────── - [Fact] - public async Task ForwardBody_False_Should_Drop_Body_Even_When_Verb_Preserved() { + [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 = false } + o.RedirectOptions = new RedirectOptions { ForwardBody = forwardBody } ); var request = new RestRequest("/redirect-with-status?status=307", Method.Post); - request.AddJsonBody(new { data = "should-be-dropped" }); + 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"); - doc.RootElement.GetProperty("Body").GetString().Should().BeEmpty(); - } - [Fact] - public async Task ForwardBody_True_Should_Forward_Body_When_Verb_Preserved() { - using var client = CreateClient(o => - o.RedirectOptions = new RedirectOptions { ForwardBody = true } - ); - - var request = new RestRequest("/redirect-with-status?status=307", Method.Post); - request.AddJsonBody(new { data = "keep-me" }); - - 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"); - doc.RootElement.GetProperty("Body").GetString().Should().Contain("keep-me"); + if (forwardBody) + doc.RootElement.GetProperty("Body").GetString().Should().Contain("test-body"); + else + doc.RootElement.GetProperty("Body").GetString().Should().BeEmpty(); } // ─── ForwardQuery ─────────────────────────────────────────────────── diff --git a/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs b/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs index 1133434a1..1df67db33 100644 --- a/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs +++ b/test/RestSharp.Tests.Shared/Server/WireMockTestServer.cs @@ -161,7 +161,7 @@ static ResponseMessage RedirectWithStatus(IRequestMessage request) { }; } - static ResponseMessage EchoRequest(IRequestMessage request) { + public static ResponseMessage EchoRequest(IRequestMessage request) { var headers = request.Headers? .ToDictionary(x => x.Key, x => string.Join(", ", x.Value)) ?? new Dictionary();