Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ extension HTTPClient {
let newRequest = currentRequest.followingRedirect(
from: preparedRequest.url,
to: redirectURL,
status: response.status
status: response.status,
config: redirectState.config
)

guard newRequest.body.canBeConsumedMultipleTimes else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,17 @@ extension HTTPClientRequest {
func followingRedirect(
from originalURL: URL,
to redirectURL: URL,
status: HTTPResponseStatus
status: HTTPResponseStatus,
config: HTTPClient.Configuration.RedirectConfiguration.FollowConfiguration
) -> HTTPClientRequest {
let (method, headers, body) = transformRequestForRedirect(
from: originalURL,
method: self.method,
headers: self.headers,
body: self.body,
to: redirectURL,
status: status
status: status,
config: config
)
var newRequest = HTTPClientRequest(url: redirectURL.absoluteString)
newRequest.method = method
Expand Down
61 changes: 58 additions & 3 deletions Sources/AsyncHTTPClient/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1269,13 +1269,54 @@ extension HTTPClient.Configuration {
/// Redirects are not followed.
case disallow
/// Redirects are followed with a specified limit.
case follow(max: Int, allowCycles: Bool)
case follow(FollowConfiguration)
}

/// Configuration for following redirects.
public struct FollowConfiguration: Hashable, Sendable {
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if it is worth adding this new struct now? If I see this correctly, we don't pass it around at all. Lets just add another property on the enum for now.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

given that we're doing 3 properties now, imo we should keep the struct. We can actually take advantage of it by holding the struct inside the RedirectState

Copy link
Contributor Author

Choose a reason for hiding this comment

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

see last commit

/// The maximum number of allowed redirects.
public var max: Int
/// Whether cycles are allowed.
public var allowCycles: Bool
/// Whether to retain the HTTP method and body when following a 301 redirect on a POST request.
/// This should be false as per the fetch spec, but may be true according to RFC 9110.
/// This does not affect non-POST requests.
public var retainHTTPMethodAndBodyOn301: Bool
/// Whether to retain the HTTP method and body when following a 302 redirect on a POST request.
/// This should be false as per the fetch spec, but may be true according to RFC 9110.
/// This does not affect non-POST requests.
public var retainHTTPMethodAndBodyOn302: Bool

/// Create a new ``FollowConfiguration``
/// - Parameters:
/// - max: The maximum number of allowed redirects.
/// - allowCycles: Whether cycles are allowed.
/// - retainHTTPMethodAndBodyOn301: Whether to retain the HTTP method and body when following a 301 redirect on a POST request. This should be false as per the fetch spec, but may be true according to RFC 9110. This does not affect non-POST requests.
/// - retainHTTPMethodAndBodyOn302: Whether to retain the HTTP method and body when following a 302 redirect on a POST request. This should be false as per the fetch spec, but may be true according to RFC 9110. This does not affect non-POST requests.
public init(
max: Int,
allowCycles: Bool,
retainHTTPMethodAndBodyOn301: Bool,
retainHTTPMethodAndBodyOn302: Bool
) {
self.max = max
self.allowCycles = allowCycles
self.retainHTTPMethodAndBodyOn301 = retainHTTPMethodAndBodyOn301
self.retainHTTPMethodAndBodyOn302 = retainHTTPMethodAndBodyOn302
}
}

var mode: Mode

init() {
self.mode = .follow(max: 5, allowCycles: false)
self.mode = .follow(
.init(
max: 5,
allowCycles: false,
retainHTTPMethodAndBodyOn301: false,
retainHTTPMethodAndBodyOn302: false
)
)
}

init(configuration: Mode) {
Expand All @@ -1293,7 +1334,21 @@ extension HTTPClient.Configuration {
///
/// - warning: Cycle detection will keep all visited URLs in memory which means a malicious server could use this as a denial-of-service vector.
public static func follow(max: Int, allowCycles: Bool) -> RedirectConfiguration {
.init(configuration: .follow(max: max, allowCycles: allowCycles))
.follow(
configuration: .init(
max: max,
allowCycles: allowCycles,
retainHTTPMethodAndBodyOn301: false,
retainHTTPMethodAndBodyOn302: false
)
)
}

/// Redirects are followed.
///
/// - Parameter: configuration: Configure how redirects are followed.
public static func follow(configuration: FollowConfiguration) -> RedirectConfiguration {
.init(configuration: .follow(configuration))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ extension HTTPClient.Configuration.RedirectConfiguration {
/// - `mode` (string, optional, default: "follow"): Redirect handling mode ("follow" or "disallow").
/// - `maxRedirects` (int, optional, default: 5): Maximum allowed redirects when mode is "follow".
/// - `allowCycles` (bool, optional, default: false): Allow cyclic redirects when mode is "follow".
/// - `retainHTTPMethodAndBodyOn301` (bool, optional, default: false): Whether to retain the HTTP method and body when following a 301 redirect on a POST request. This should be false as per the fetch spec, but may be true according to RFC 9110. This does not affect non-POST requests.
/// - `retainHTTPMethodAndBodyOn302` (bool, optional, default: false): Whether to retain the HTTP method and body when following a 302 redirect on a POST request. This should be false as per the fetch spec, but may be true according to RFC 9110. This does not affect non-POST requests.
///
/// - Throws: `HTTPClientError.invalidRedirectConfiguration` if mode is specified but invalid.
public init(configReader: ConfigReader) throws {
Expand All @@ -77,7 +79,16 @@ extension HTTPClient.Configuration.RedirectConfiguration {
if mode == "follow" {
let maxRedirects = configReader.int(forKey: "maxRedirects", default: 5)
let allowCycles = configReader.bool(forKey: "allowCycles", default: false)
self = .follow(max: maxRedirects, allowCycles: allowCycles)
let retainHTTPMethodAndBodyOn301 = configReader.bool(forKey: "retainHTTPMethodAndBodyOn301", default: false)
let retainHTTPMethodAndBodyOn302 = configReader.bool(forKey: "retainHTTPMethodAndBodyOn302", default: false)
self = .follow(
configuration: .init(
max: maxRedirects,
allowCycles: allowCycles,
retainHTTPMethodAndBodyOn301: retainHTTPMethodAndBodyOn301,
retainHTTPMethodAndBodyOn302: retainHTTPMethodAndBodyOn302
)
)
} else if mode == "disallow" {
self = .disallow
} else {
Expand Down
3 changes: 2 additions & 1 deletion Sources/AsyncHTTPClient/HTTPHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1076,7 +1076,8 @@ internal struct RedirectHandler<ResponseType: Sendable> {
headers: self.request.headers,
body: self.request.body,
to: redirectURL,
status: status
status: status,
config: self.redirectState.config
)

let newRequest = try HTTPClient.Request(
Expand Down
23 changes: 11 additions & 12 deletions Sources/AsyncHTTPClient/RedirectState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,10 @@ import struct Foundation.URL
typealias RedirectMode = HTTPClient.Configuration.RedirectConfiguration.Mode

struct RedirectState {
/// number of redirects we are allowed to follow.
private var limit: Int
var config: HTTPClient.Configuration.RedirectConfiguration.FollowConfiguration

/// All visited URLs.
private var visited: [String]

/// if true, `redirect(to:)` will throw an error if a cycle is detected.
private let allowCycles: Bool
}

extension RedirectState {
Expand All @@ -40,8 +36,8 @@ extension RedirectState {
switch configuration {
case .disallow:
return nil
case .follow(let maxRedirects, let allowCycles):
self.init(limit: maxRedirects, visited: [initialURL], allowCycles: allowCycles)
case .follow(let config):
self.init(config: config, visited: [initialURL])
}
}
}
Expand All @@ -52,11 +48,11 @@ extension RedirectState {
/// - Parameter redirectURL: the new URL to redirect the request to
/// - Throws: if it reaches the redirect limit or detects a redirect cycle if and `allowCycles` is false
mutating func redirect(to redirectURL: String) throws {
guard self.visited.count <= limit else {
guard self.visited.count <= config.max else {
throw HTTPClientError.redirectLimitReached
}

guard allowCycles || !self.visited.contains(redirectURL) else {
guard config.allowCycles || !self.visited.contains(redirectURL) else {
throw HTTPClientError.redirectCycleDetected
}
self.visited.append(redirectURL)
Expand Down Expand Up @@ -111,13 +107,16 @@ func transformRequestForRedirect<Body>(
headers requestHeaders: HTTPHeaders,
body requestBody: Body?,
to redirectURL: URL,
status responseStatus: HTTPResponseStatus
status responseStatus: HTTPResponseStatus,
config: HTTPClient.Configuration.RedirectConfiguration.FollowConfiguration
) -> (HTTPMethod, HTTPHeaders, Body?) {
let convertToGet: Bool
if responseStatus == .seeOther, requestMethod != .HEAD {
convertToGet = true
} else if responseStatus == .movedPermanently || responseStatus == .found, requestMethod == .POST {
convertToGet = true
} else if responseStatus == .movedPermanently, requestMethod == .POST {
convertToGet = !config.retainHTTPMethodAndBodyOn301
} else if responseStatus == .found, requestMethod == .POST {
convertToGet = !config.retainHTTPMethodAndBodyOn302
} else {
convertToGet = false
}
Expand Down
89 changes: 89 additions & 0 deletions Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,95 @@ final class AsyncAwaitEndToEndTests: XCTestCase {
}
}
}

// MARK: - POST to GET conversion on redirects

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
private func _testPostConvertedToGetOnRedirect(
statusPath: String,
expectedStatus: HTTPResponseStatus
) {
XCTAsyncTest {
let bin = HTTPBin(.http2(compress: false))
defer { XCTAssertNoThrow(try bin.shutdown()) }
let client = makeDefaultHTTPClient()
defer { XCTAssertNoThrow(try client.syncShutdown()) }
let logger = Logger(label: "HTTPClient", factory: StreamLogHandler.standardOutput(label:))

var request = HTTPClientRequest(url: "https://localhost:\(bin.port)\(statusPath)")
request.method = .POST
request.body = .bytes(ByteBuffer(string: "test body"))

guard
let response = await XCTAssertNoThrowWithResult(
try await client.execute(request, deadline: .now() + .seconds(10), logger: logger)
)
else { return }

XCTAssertEqual(response.status, .ok)
XCTAssertEqual(response.history.count, 2, "Expected 2 entries in history for \(statusPath)")
XCTAssertEqual(
response.history[0].request.method,
.POST,
"Original request should be POST for \(statusPath)"
)
XCTAssertEqual(
response.history[1].request.method,
.GET,
"Redirected request should be converted to GET for \(statusPath)"
)
XCTAssertEqual(
response.history[0].responseHead.status,
expectedStatus,
"Expected \(expectedStatus) for \(statusPath)"
)
}
}

func testPostConvertedToGetOn301Redirect() {
self._testPostConvertedToGetOnRedirect(
statusPath: "/redirect/301",
expectedStatus: .movedPermanently
)
}

func testPostConvertedToGetOn302Redirect() {
self._testPostConvertedToGetOnRedirect(
statusPath: "/redirect/302",
expectedStatus: .found
)
}

func testPostConvertedToGetOn303Redirect() {
self._testPostConvertedToGetOnRedirect(
statusPath: "/redirect/303",
expectedStatus: .seeOther
)
}

func testGetMethodUnchangedOnRedirect() {
XCTAsyncTest {
let bin = HTTPBin(.http2(compress: false))
defer { XCTAssertNoThrow(try bin.shutdown()) }
let client = makeDefaultHTTPClient()
defer { XCTAssertNoThrow(try client.syncShutdown()) }
let logger = Logger(label: "HTTPClient", factory: StreamLogHandler.standardOutput(label:))

// Test that non-POST methods remain unchanged
let requestGet = HTTPClientRequest(url: "https://localhost:\(bin.port)/redirect/302")

guard
let responseGet = await XCTAssertNoThrowWithResult(
try await client.execute(requestGet, deadline: .now() + .seconds(10), logger: logger)
)
else { return }

XCTAssertEqual(responseGet.status, .ok)
XCTAssertEqual(responseGet.history.count, 2)
XCTAssertEqual(responseGet.history[0].request.method, .GET, "Original request should be GET")
XCTAssertEqual(responseGet.history[1].request.method, .GET, "Redirected request should remain GET")
}
}
}

struct AnySendableSequence<Element>: @unchecked Sendable {
Expand Down
10 changes: 10 additions & 0 deletions Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -984,11 +984,21 @@ internal final class HTTPBinHandler: ChannelInboundHandler {
}
self.resps.append(HTTPResponseBuilder(status: .ok, responseBodyIsRequestBodyByteCount: true))
return
case "/redirect/301":
var headers = self.responseHeaders
headers.add(name: "location", value: "/ok")
self.resps.append(HTTPResponseBuilder(status: .movedPermanently, headers: headers))
return
case "/redirect/302":
var headers = self.responseHeaders
headers.add(name: "location", value: "/ok")
self.resps.append(HTTPResponseBuilder(status: .found, headers: headers))
return
case "/redirect/303":
var headers = self.responseHeaders
headers.add(name: "location", value: "/ok")
self.resps.append(HTTPResponseBuilder(status: .seeOther, headers: headers))
return
case "/redirect/https":
let port = self.value(for: "port", from: urlComponents.query!)
var headers = self.responseHeaders
Expand Down
Loading