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