diff --git a/docs/api/public-api.md b/docs/api/public-api.md index aa1d928..f69eec7 100644 --- a/docs/api/public-api.md +++ b/docs/api/public-api.md @@ -108,6 +108,24 @@ A single `Client` instance is **not** guaranteed to be safe for concurrent use f Independent `Client` instances may be used from different threads, subject to normal platform constraints. +### Redirect behavior + +Automatic redirect following is enabled by default with a finite default limit of **10 followed redirects**. Callers may disable following with `set_follow_redirects(false)` or replace the limit with `set_max_redirects()`. + +The v1 method/body policy is: + +- `301` and `302`: `POST` becomes `GET` and its body is dropped; other methods are preserved, +- `303`: every method except `HEAD` becomes `GET`; the body is dropped, +- `307` and `308`: method and body are preserved. + +When a body is dropped, body-specific request headers such as `Content-Length`, `Transfer-Encoding`, and `Content-Type` are not forwarded to the redirected request. + +Relative `Location` values are resolved against the current request URL, including absolute-path, relative-path, query-only, and scheme-relative forms. URL fragments are never sent in the HTTP request target. + +Cross-origin redirects do not forward caller-supplied `Host`, `Authorization`, `Proxy-Authorization`, or `Cookie` fields. Once these fields are removed during a redirect chain they are not automatically restored if a later hop returns to the original origin. + +Redirects requiring HTTPS/TLS or another unsupported scheme fail with a structured redirect error rather than being followed. + --- ## `Request` diff --git a/include/cpp_request/client.hpp b/include/cpp_request/client.hpp index 483d165..2a8411d 100644 --- a/include/cpp_request/client.hpp +++ b/include/cpp_request/client.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -48,7 +49,16 @@ class Client final { write_timeout_ = timeout; } + void set_follow_redirects(bool enabled) noexcept { + follow_redirects_ = enabled; + } + + void set_max_redirects(std::size_t count) noexcept { + max_redirects_ = count; + } + private: + [[nodiscard]] Result execute_once(const Request& request); void close_reusable_connection() noexcept; std::uintptr_t reusable_socket_token_{0}; @@ -56,6 +66,9 @@ class Client final { std::string reusable_host_; std::uint16_t reusable_port_{0}; + bool follow_redirects_{true}; + std::size_t max_redirects_{10}; + std::chrono::milliseconds connect_timeout_{5000}; std::chrono::milliseconds read_timeout_{30000}; std::chrono::milliseconds write_timeout_{30000}; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 00c78c0..ee98691 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -3,6 +3,7 @@ add_library(internal ${CMAKE_CURRENT_SOURCE_DIR}/core/headers.cpp ${CMAKE_CURRENT_SOURCE_DIR}/core/url.cpp ${CMAKE_CURRENT_SOURCE_DIR}/http/request_serializer.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/http/redirect.cpp ${CMAKE_CURRENT_SOURCE_DIR}/http/chunked_decoder.cpp ${CMAKE_CURRENT_SOURCE_DIR}/http/response_parser.cpp ${CMAKE_CURRENT_SOURCE_DIR}/platform/native_socket.cpp diff --git a/src/core/client.cpp b/src/core/client.cpp index 794ede9..94e3289 100644 --- a/src/core/client.cpp +++ b/src/core/client.cpp @@ -1,5 +1,6 @@ #include +#include "http/redirect.hpp" #include "http/request_serializer.hpp" #include "http/response_parser.hpp" #include "net/resolver.hpp" @@ -10,6 +11,7 @@ #include #include #include +#include #include #include @@ -85,6 +87,41 @@ namespace { return stored_port == port && ascii_iequals(stored_host, host); } +[[nodiscard]] bool same_origin(const Url& lhs, const Url& rhs) noexcept { + return lhs.port() == rhs.port() + && ascii_iequals(lhs.host(), rhs.host()); +} + +[[nodiscard]] bool is_cross_origin_sensitive_header(std::string_view name) noexcept { + return ascii_iequals(name, "Host") + || ascii_iequals(name, "Authorization") + || ascii_iequals(name, "Proxy-Authorization") + || ascii_iequals(name, "Cookie"); +} + +[[nodiscard]] bool is_entity_header_removed_with_body(std::string_view name) noexcept { + return ascii_iequals(name, "Content-Length") + || ascii_iequals(name, "Transfer-Encoding") + || ascii_iequals(name, "Content-Type"); +} + +[[nodiscard]] Headers redirected_headers( + const Headers& source, + bool cross_origin, + bool drop_body) { + Headers result; + for (const auto& field : source) { + if (cross_origin && is_cross_origin_sensitive_header(field.name)) { + continue; + } + if (drop_body && is_entity_header_removed_with_body(field.name)) { + continue; + } + result.add(field.name, field.value); + } + return result; +} + [[nodiscard]] std::uintptr_t encode_socket_handle( detail::platform::NativeSocketHandle handle) noexcept { return static_cast(handle); @@ -130,6 +167,8 @@ Client::Client(Client&& other) noexcept has_reusable_connection_(std::exchange(other.has_reusable_connection_, false)), reusable_host_(std::move(other.reusable_host_)), reusable_port_(std::exchange(other.reusable_port_, 0)), + follow_redirects_(other.follow_redirects_), + max_redirects_(other.max_redirects_), connect_timeout_(other.connect_timeout_), read_timeout_(other.read_timeout_), write_timeout_(other.write_timeout_) {} @@ -145,6 +184,8 @@ Client& Client::operator=(Client&& other) noexcept { has_reusable_connection_ = std::exchange(other.has_reusable_connection_, false); reusable_host_ = std::move(other.reusable_host_); reusable_port_ = std::exchange(other.reusable_port_, 0); + follow_redirects_ = other.follow_redirects_; + max_redirects_ = other.max_redirects_; connect_timeout_ = other.connect_timeout_; read_timeout_ = other.read_timeout_; write_timeout_ = other.write_timeout_; @@ -165,6 +206,92 @@ void Client::close_reusable_connection() noexcept { } Result Client::request(const Request& request_value) { + auto response_result = execute_once(request_value); + if (!response_result) { + return response_result.error(); + } + + Response response = std::move(response_result).value(); + if (!follow_redirects_ + || !detail::http::is_redirect_status(response.status_code())) { + return response; + } + + std::string current_url{request_value.url()}; + Method current_method = request_value.method(); + Headers current_headers = request_value.headers(); + std::string_view current_body = request_value.body(); + std::size_t redirects_followed = 0; + + while (detail::http::is_redirect_status(response.status_code())) { + if (redirects_followed >= max_redirects_) { + return Error{ErrorCode::RedirectLimitExceeded}; + } + + if (!response.headers().contains("Location")) { + return Error{ErrorCode::MissingRedirectLocation}; + } + const std::string_view location = response.headers().get("Location"); + if (location.empty()) { + return Error{ErrorCode::MissingRedirectLocation}; + } + + auto base_result = Url::parse(current_url); + if (!base_result) { + return base_result.error(); + } + Url base = std::move(base_result).value(); + + auto next_url_result = detail::http::resolve_redirect_location(base, location); + if (!next_url_result) { + return next_url_result.error(); + } + std::string next_url = std::move(next_url_result).value(); + + auto next_parsed_result = Url::parse(next_url); + if (!next_parsed_result) { + if (next_parsed_result.error().code == ErrorCode::UnsupportedScheme) { + return Error{ErrorCode::UnsupportedRedirectScheme}; + } + return next_parsed_result.error(); + } + Url next_parsed = std::move(next_parsed_result).value(); + + const auto behavior = detail::http::redirect_behavior( + response.status_code(), + current_method); + const bool cross_origin = !same_origin(base, next_parsed); + const bool drop_body = !behavior.preserve_body; + + Headers next_headers = redirected_headers( + current_headers, + cross_origin, + drop_body); + const std::string_view next_body = behavior.preserve_body + ? current_body + : std::string_view{}; + + current_url = std::move(next_url); + current_method = behavior.method; + current_headers = std::move(next_headers); + current_body = next_body; + ++redirects_followed; + + Request redirected{current_method, current_url}; + redirected.headers() = current_headers; + redirected.set_body(current_body); + + auto redirected_result = execute_once(redirected); + if (!redirected_result) { + return redirected_result.error(); + } + response = std::move(redirected_result).value(); + } + + return response; +} + +Result Client::execute_once(const Request& request_value) { auto serialized_result = detail::http::serialize_request(request_value); if (!serialized_result) { return serialized_result.error(); diff --git a/src/http/redirect.cpp b/src/http/redirect.cpp new file mode 100644 index 0000000..a81e1e4 --- /dev/null +++ b/src/http/redirect.cpp @@ -0,0 +1,228 @@ +#include "http/redirect.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +namespace cpp_request::detail::http { +namespace { + +[[nodiscard]] constexpr char ascii_lower(char ch) noexcept { + return ch >= 'A' && ch <= 'Z' + ? static_cast(ch + ('a' - 'A')) + : ch; +} + +[[nodiscard]] bool ascii_iequals( + std::string_view lhs, + std::string_view rhs) noexcept { + if (lhs.size() != rhs.size()) { + return false; + } + + for (std::size_t index = 0; index < lhs.size(); ++index) { + if (ascii_lower(lhs[index]) != ascii_lower(rhs[index])) { + return false; + } + } + return true; +} + +[[nodiscard]] bool is_scheme_char(char ch) noexcept { + const unsigned char value = static_cast(ch); + return std::isalnum(value) != 0 + || ch == '+' + || ch == '-' + || ch == '.'; +} + +[[nodiscard]] std::size_t scheme_end(std::string_view value) noexcept { + if (value.empty() + || !((value.front() >= 'A' && value.front() <= 'Z') + || (value.front() >= 'a' && value.front() <= 'z'))) { + return std::string_view::npos; + } + + for (std::size_t index = 1; index < value.size(); ++index) { + const char ch = value[index]; + if (ch == ':') { + return index; + } + if (ch == '/' || ch == '?' || ch == '#') { + return std::string_view::npos; + } + if (!is_scheme_char(ch)) { + return std::string_view::npos; + } + } + return std::string_view::npos; +} + +[[nodiscard]] std::string build_origin(const Url& base) { + std::string origin{"http://"}; + if (base.host_is_ipv6_literal()) { + origin.push_back('['); + origin.append(base.host()); + origin.push_back(']'); + } else { + origin.append(base.host()); + } + + if (base.has_explicit_port()) { + origin.push_back(':'); + origin.append(std::to_string(base.port())); + } + return origin; +} + +[[nodiscard]] std::string remove_dot_segments(std::string_view path) { + std::vector segments; + std::size_t begin = path.empty() || path.front() != '/' ? 0 : 1; + + while (begin <= path.size()) { + const std::size_t slash = path.find('/', begin); + const std::size_t end = slash == std::string_view::npos + ? path.size() + : slash; + const std::string_view segment = path.substr(begin, end - begin); + + if (segment == "..") { + if (!segments.empty()) { + segments.pop_back(); + } + } else if (segment != ".") { + segments.push_back(segment); + } + + if (slash == std::string_view::npos) { + break; + } + begin = slash + 1; + } + + std::string normalized{"/"}; + for (std::size_t index = 0; index < segments.size(); ++index) { + if (index != 0) { + normalized.push_back('/'); + } + normalized.append(segments[index]); + } + + const bool ends_with_dot = path.size() >= 2 + && path.substr(path.size() - 2) == "/."; + const bool ends_with_dot_dot = path.size() >= 3 + && path.substr(path.size() - 3) == "/.."; + const bool keep_trailing_slash = !path.empty() + && (path.back() == '/' || ends_with_dot || ends_with_dot_dot); + if (keep_trailing_slash && normalized.back() != '/') { + normalized.push_back('/'); + } + return normalized; +} + +[[nodiscard]] std::string strip_fragment(std::string value) { + const std::size_t fragment = value.find('#'); + if (fragment != std::string::npos) { + value.resize(fragment); + } + return value; +} + +[[nodiscard]] Result validate_redirect_url(std::string candidate) { + candidate = strip_fragment(std::move(candidate)); + auto parsed = Url::parse(candidate); + if (!parsed) { + if (parsed.error().code == ErrorCode::UnsupportedScheme) { + return Error{ErrorCode::UnsupportedRedirectScheme}; + } + return parsed.error(); + } + return candidate; +} + +} // namespace + +bool is_redirect_status(int status_code) noexcept { + return status_code == 301 + || status_code == 302 + || status_code == 303 + || status_code == 307 + || status_code == 308; +} + +RedirectBehavior redirect_behavior( + int status_code, + Method current_method) noexcept { + if (status_code == 303) { + return { + current_method == Method::Head ? Method::Head : Method::Get, + false}; + } + + if ((status_code == 301 || status_code == 302) + && current_method == Method::Post) { + return {Method::Get, false}; + } + + return {current_method, true}; +} + +Result resolve_redirect_location( + const Url& base, + std::string_view location) { + if (location.empty()) { + return Error{ErrorCode::MissingRedirectLocation}; + } + + const std::size_t explicit_scheme = scheme_end(location); + if (explicit_scheme != std::string_view::npos) { + if (!ascii_iequals(location.substr(0, explicit_scheme), "http")) { + return Error{ErrorCode::UnsupportedRedirectScheme}; + } + return validate_redirect_url(std::string{location}); + } + + if (location.rfind("//", 0) == 0) { + return validate_redirect_url(std::string{"http:"} + std::string{location}); + } + + const std::string origin = build_origin(base); + + if (location.front() == '#') { + return validate_redirect_url(origin + std::string{base.target()} + std::string{location}); + } + + if (location.front() == '?') { + return validate_redirect_url(origin + std::string{base.path()} + std::string{location}); + } + + const std::size_t suffix_begin = location.find_first_of("?#"); + const std::string_view relative_path = location.substr(0, suffix_begin); + const std::string_view suffix = suffix_begin == std::string_view::npos + ? std::string_view{} + : location.substr(suffix_begin); + + std::string merged_path; + if (!relative_path.empty() && relative_path.front() == '/') { + merged_path.assign(relative_path); + } else { + const std::string_view base_path = base.path(); + const std::size_t slash = base_path.rfind('/'); + if (slash == std::string_view::npos) { + merged_path = "/"; + } else { + merged_path.assign(base_path.substr(0, slash + 1)); + } + merged_path.append(relative_path); + } + + return validate_redirect_url( + origin + remove_dot_segments(merged_path) + std::string{suffix}); +} + +} // namespace cpp_request::detail::http diff --git a/src/http/redirect.hpp b/src/http/redirect.hpp new file mode 100644 index 0000000..893de3e --- /dev/null +++ b/src/http/redirect.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace cpp_request::detail::http { + +struct RedirectBehavior { + Method method; + bool preserve_body; +}; + +[[nodiscard]] bool is_redirect_status(int status_code) noexcept; + +[[nodiscard]] RedirectBehavior redirect_behavior( + int status_code, + Method current_method) noexcept; + +[[nodiscard]] Result resolve_redirect_location( + const Url& base, + std::string_view location); + +} // namespace cpp_request::detail::http diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b7289a2..adc1827 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,11 +6,13 @@ add_executable(cpp_request_tests ${CMAKE_CURRENT_SOURCE_DIR}/headers_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/request_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/request_serializer_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/redirect_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/chunked_decoder_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/response_parser_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/response_upgrade_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/client_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/client_reuse_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/client_redirect_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/native_socket_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/resolver_test.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tcp_connection_test.cpp diff --git a/tests/client_redirect_test.cpp b/tests/client_redirect_test.cpp new file mode 100644 index 0000000..c4085e0 --- /dev/null +++ b/tests/client_redirect_test.cpp @@ -0,0 +1,541 @@ +#include + +#include "platform/native_socket.hpp" +#include "platform/network_runtime.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#include +#include +#endif + +namespace { + +using cpp_request::Client; +using cpp_request::ErrorCode; +using cpp_request::Method; +using cpp_request::Request; +using cpp_request::detail::platform::NativeSocket; +using cpp_request::detail::platform::NativeSocketHandle; + +struct LoopbackServer { + NativeSocket listener; + std::uint16_t port{0}; +}; + +LoopbackServer make_loopback_server() { + const auto runtime = cpp_request::detail::platform::ensure_network_runtime(); + if (!runtime.ok) { + return {}; + } + + NativeSocket listener{::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)}; + if (!listener) { + return {}; + } + + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_port = 0; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + + if (::bind( + listener.get(), + reinterpret_cast(&address), + static_cast(sizeof(address))) != 0) { + return {}; + } + + if (::listen(listener.get(), 4) != 0) { + return {}; + } + +#ifdef _WIN32 + int address_size = sizeof(address); +#else + socklen_t address_size = sizeof(address); +#endif + if (::getsockname( + listener.get(), + reinterpret_cast(&address), + &address_size) != 0) { + return {}; + } + + return {std::move(listener), ntohs(address.sin_port)}; +} + +bool wait_readable( + NativeSocketHandle socket, + std::chrono::milliseconds timeout) { + fd_set read_set; + FD_ZERO(&read_set); + FD_SET(socket, &read_set); + + timeval value{}; + value.tv_sec = static_cast(timeout.count() / 1000); + value.tv_usec = static_cast((timeout.count() % 1000) * 1000); + +#ifdef _WIN32 + return ::select(0, &read_set, nullptr, nullptr, &value) > 0; +#else + return ::select(socket + 1, &read_set, nullptr, nullptr, &value) > 0; +#endif +} + +NativeSocket accept_one( + NativeSocketHandle listener, + std::chrono::milliseconds timeout = std::chrono::seconds{4}) { + if (!wait_readable(listener, timeout)) { + return {}; + } + return NativeSocket{::accept(listener, nullptr, nullptr)}; +} + +int receive_native( + NativeSocketHandle socket, + char* buffer, + std::size_t capacity) { +#ifdef _WIN32 + return ::recv(socket, buffer, static_cast(capacity), 0); +#else + return static_cast(::recv(socket, buffer, capacity, 0)); +#endif +} + +int send_native( + NativeSocketHandle socket, + const char* data, + std::size_t size) { +#ifdef _WIN32 + return ::send(socket, data, static_cast(size), 0); +#else + return static_cast(::send(socket, data, size, 0)); +#endif +} + +bool send_all_native(NativeSocketHandle socket, std::string_view data) { + std::size_t written = 0; + while (written < data.size()) { + const int count = send_native( + socket, + data.data() + written, + data.size() - written); + if (count <= 0) { + return false; + } + written += static_cast(count); + } + return true; +} + +std::size_t parse_content_length(std::string_view headers) { + constexpr std::string_view kName = "Content-Length:"; + const std::size_t position = headers.find(kName); + if (position == std::string_view::npos) { + return 0; + } + + std::size_t begin = position + kName.size(); + while (begin < headers.size() && headers[begin] == ' ') { + ++begin; + } + + const std::size_t end = headers.find("\r\n", begin); + const std::string_view value = headers.substr(begin, end - begin); + std::size_t length = 0; + const auto parsed = std::from_chars( + value.data(), + value.data() + value.size(), + length, + 10); + return parsed.ec == std::errc{} && parsed.ptr == value.data() + value.size() + ? length + : 0; +} + +std::string receive_request( + NativeSocketHandle socket, + std::chrono::milliseconds timeout = std::chrono::seconds{3}) { + std::string request; + std::array buffer{}; + + for (;;) { + if (!wait_readable(socket, timeout)) { + return request; + } + + const int count = receive_native(socket, buffer.data(), buffer.size()); + if (count <= 0) { + return request; + } + + request.append(buffer.data(), static_cast(count)); + const std::size_t head_end = request.find("\r\n\r\n"); + if (head_end == std::string::npos) { + continue; + } + + const std::size_t expected_size = head_end + 4 + + parse_content_length(std::string_view{request}.substr(0, head_end + 2)); + if (request.size() >= expected_size) { + return request; + } + } +} + +std::string make_url(std::uint16_t port, std::string_view target = "/") { + return "http://127.0.0.1:" + std::to_string(port) + std::string{target}; +} + +void configure_test_timeouts(Client& client) { + client.set_connect_timeout(std::chrono::seconds{2}); + client.set_write_timeout(std::chrono::seconds{2}); + client.set_read_timeout(std::chrono::seconds{2}); +} + +TEST(ClientRedirectTest, FollowsRelativeRedirectOnReusableConnection) { + auto server = make_loopback_server(); + ASSERT_TRUE(server.listener); + + std::atomic accepted{0}; + std::string first_request; + std::string second_request; + + std::thread worker([&] { + NativeSocket peer = accept_one(server.listener.get()); + if (!peer) { + return; + } + ++accepted; + + first_request = receive_request(peer.get()); + (void)send_all_native( + peer.get(), + "HTTP/1.1 302 Found\r\n" + "Location: ../final?x=1\r\n" + "Content-Length: 0\r\n" + "\r\n"); + + second_request = receive_request(peer.get()); + (void)send_all_native( + peer.get(), + "HTTP/1.1 200 OK\r\n" + "Content-Length: 2\r\n" + "\r\n" + "ok"); + }); + + Client client; + configure_test_timeouts(client); + auto result = client.get(make_url(server.port, "/a/start")); + + worker.join(); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value().status_code(), 200); + EXPECT_EQ(result.value().body(), "ok"); + EXPECT_EQ(accepted.load(), 1); + EXPECT_NE(first_request.find("GET /a/start HTTP/1.1\r\n"), std::string::npos); + EXPECT_NE(second_request.find("GET /final?x=1 HTTP/1.1\r\n"), std::string::npos); +} + +TEST(ClientRedirectTest, CanDisableAutomaticRedirectFollowing) { + auto server = make_loopback_server(); + ASSERT_TRUE(server.listener); + + std::thread worker([&] { + NativeSocket peer = accept_one(server.listener.get()); + if (!peer) { + return; + } + (void)receive_request(peer.get()); + (void)send_all_native( + peer.get(), + "HTTP/1.1 302 Found\r\n" + "Location: /ignored\r\n" + "Content-Length: 0\r\n" + "\r\n"); + }); + + Client client; + configure_test_timeouts(client); + client.set_follow_redirects(false); + auto result = client.get(make_url(server.port, "/start")); + + worker.join(); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value().status_code(), 302); +} + +TEST(ClientRedirectTest, EnforcesConfiguredRedirectLimit) { + auto server = make_loopback_server(); + ASSERT_TRUE(server.listener); + + std::thread worker([&] { + NativeSocket peer = accept_one(server.listener.get()); + if (!peer) { + return; + } + + (void)receive_request(peer.get()); + (void)send_all_native( + peer.get(), + "HTTP/1.1 302 Found\r\n" + "Location: /two\r\n" + "Content-Length: 0\r\n" + "\r\n"); + + (void)receive_request(peer.get()); + (void)send_all_native( + peer.get(), + "HTTP/1.1 302 Found\r\n" + "Location: /three\r\n" + "Content-Length: 0\r\n" + "\r\n"); + }); + + Client client; + configure_test_timeouts(client); + client.set_max_redirects(1); + auto result = client.get(make_url(server.port, "/one")); + + worker.join(); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::RedirectLimitExceeded); +} + +TEST(ClientRedirectTest, Post302BecomesGetAndDropsBodyHeaders) { + auto server = make_loopback_server(); + ASSERT_TRUE(server.listener); + + std::string first_request; + std::string second_request; + + std::thread worker([&] { + NativeSocket peer = accept_one(server.listener.get()); + if (!peer) { + return; + } + + first_request = receive_request(peer.get()); + (void)send_all_native( + peer.get(), + "HTTP/1.1 302 Found\r\n" + "Location: /after\r\n" + "Content-Length: 0\r\n" + "\r\n"); + + second_request = receive_request(peer.get()); + (void)send_all_native( + peer.get(), + "HTTP/1.1 200 OK\r\n" + "Content-Length: 2\r\n" + "\r\n" + "ok"); + }); + + const std::string url = make_url(server.port, "/submit"); + Request request{Method::Post, url}; + request.headers().add("Content-Type", "text/plain"); + request.set_body("payload"); + + Client client; + configure_test_timeouts(client); + auto result = client.request(request); + + worker.join(); + + ASSERT_TRUE(result); + EXPECT_NE(first_request.find("POST /submit HTTP/1.1\r\n"), std::string::npos); + EXPECT_NE(first_request.find("Content-Length: 7\r\n"), std::string::npos); + EXPECT_NE(second_request.find("GET /after HTTP/1.1\r\n"), std::string::npos); + EXPECT_EQ(second_request.find("Content-Length:"), std::string::npos); + EXPECT_EQ(second_request.find("Content-Type:"), std::string::npos); + EXPECT_EQ(second_request.find("payload"), std::string::npos); +} + +TEST(ClientRedirectTest, Redirect307PreservesMethodBodyAndEntityHeaders) { + auto server = make_loopback_server(); + ASSERT_TRUE(server.listener); + + std::string second_request; + + std::thread worker([&] { + NativeSocket peer = accept_one(server.listener.get()); + if (!peer) { + return; + } + + (void)receive_request(peer.get()); + (void)send_all_native( + peer.get(), + "HTTP/1.1 307 Temporary Redirect\r\n" + "Location: /again\r\n" + "Content-Length: 0\r\n" + "\r\n"); + + second_request = receive_request(peer.get()); + (void)send_all_native( + peer.get(), + "HTTP/1.1 200 OK\r\n" + "Content-Length: 2\r\n" + "\r\n" + "ok"); + }); + + const std::string url = make_url(server.port, "/submit"); + Request request{Method::Post, url}; + request.headers().add("Content-Type", "text/plain"); + request.set_body("payload"); + + Client client; + configure_test_timeouts(client); + auto result = client.request(request); + + worker.join(); + + ASSERT_TRUE(result); + EXPECT_NE(second_request.find("POST /again HTTP/1.1\r\n"), std::string::npos); + EXPECT_NE(second_request.find("Content-Length: 7\r\n"), std::string::npos); + EXPECT_NE(second_request.find("Content-Type: text/plain\r\n"), std::string::npos); + ASSERT_GE(second_request.size(), 7U); + EXPECT_EQ(second_request.substr(second_request.size() - 7), "payload"); +} + +TEST(ClientRedirectTest, CrossOriginRedirectDropsCredentialsAndRegeneratesHost) { + auto source = make_loopback_server(); + auto destination = make_loopback_server(); + ASSERT_TRUE(source.listener); + ASSERT_TRUE(destination.listener); + + std::string source_request; + std::string destination_request; + + std::thread worker([&] { + NativeSocket first_peer = accept_one(source.listener.get()); + if (!first_peer) { + return; + } + source_request = receive_request(first_peer.get()); + + const std::string redirect = + "HTTP/1.1 302 Found\r\n" + "Location: " + make_url(destination.port, "/final") + "\r\n" + "Content-Length: 0\r\n" + "\r\n"; + (void)send_all_native(first_peer.get(), redirect); + + NativeSocket second_peer = accept_one(destination.listener.get()); + if (!second_peer) { + return; + } + destination_request = receive_request(second_peer.get()); + (void)send_all_native( + second_peer.get(), + "HTTP/1.1 200 OK\r\n" + "Content-Length: 2\r\n" + "\r\n" + "ok"); + }); + + const std::string url = make_url(source.port, "/start"); + Request request{Method::Get, url}; + request.headers().add("Host", "override.invalid"); + request.headers().add("Authorization", "Bearer secret"); + request.headers().add("Cookie", "session=secret"); + + Client client; + configure_test_timeouts(client); + auto result = client.request(request); + + worker.join(); + + ASSERT_TRUE(result); + EXPECT_NE(source_request.find("Authorization: Bearer secret\r\n"), std::string::npos); + EXPECT_NE(source_request.find("Cookie: session=secret\r\n"), std::string::npos); + EXPECT_EQ(destination_request.find("Authorization:"), std::string::npos); + EXPECT_EQ(destination_request.find("Cookie:"), std::string::npos); + EXPECT_EQ(destination_request.find("override.invalid"), std::string::npos); + EXPECT_NE( + destination_request.find( + "Host: 127.0.0.1:" + std::to_string(destination.port) + "\r\n"), + std::string::npos); +} + +TEST(ClientRedirectTest, RejectsRedirectToHttps) { + auto server = make_loopback_server(); + ASSERT_TRUE(server.listener); + + std::thread worker([&] { + NativeSocket peer = accept_one(server.listener.get()); + if (!peer) { + return; + } + (void)receive_request(peer.get()); + (void)send_all_native( + peer.get(), + "HTTP/1.1 302 Found\r\n" + "Location: https://example.com/final\r\n" + "Content-Length: 0\r\n" + "\r\n"); + }); + + Client client; + configure_test_timeouts(client); + auto result = client.get(make_url(server.port, "/start")); + + worker.join(); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::UnsupportedRedirectScheme); +} + +TEST(ClientRedirectTest, RedirectWithoutLocationIsStructuredError) { + auto server = make_loopback_server(); + ASSERT_TRUE(server.listener); + + std::thread worker([&] { + NativeSocket peer = accept_one(server.listener.get()); + if (!peer) { + return; + } + (void)receive_request(peer.get()); + (void)send_all_native( + peer.get(), + "HTTP/1.1 301 Moved Permanently\r\n" + "Content-Length: 0\r\n" + "\r\n"); + }); + + Client client; + configure_test_timeouts(client); + auto result = client.get(make_url(server.port, "/start")); + + worker.join(); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::MissingRedirectLocation); +} + +} // namespace diff --git a/tests/redirect_test.cpp b/tests/redirect_test.cpp new file mode 100644 index 0000000..59d802c --- /dev/null +++ b/tests/redirect_test.cpp @@ -0,0 +1,151 @@ +#include + +#include "http/redirect.hpp" + +#include +#include +#include + +#include + +namespace { + +using cpp_request::ErrorCode; +using cpp_request::Method; +using cpp_request::Url; +using cpp_request::detail::http::redirect_behavior; +using cpp_request::detail::http::resolve_redirect_location; + +TEST(RedirectTest, ResolvesAbsoluteHttpLocation) { + auto base = Url::parse("http://example.com/a/b?x=1"); + ASSERT_TRUE(base); + + auto result = resolve_redirect_location( + base.value(), + "http://other.test:8080/next?q=2#fragment"); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), "http://other.test:8080/next?q=2"); +} + +TEST(RedirectTest, ResolvesSchemeRelativeLocation) { + auto base = Url::parse("http://example.com/a/b"); + ASSERT_TRUE(base); + + auto result = resolve_redirect_location(base.value(), "//other.test/next"); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), "http://other.test/next"); +} + +TEST(RedirectTest, ResolvesAbsolutePathAndDotSegments) { + auto base = Url::parse("http://example.com/a/b/c"); + ASSERT_TRUE(base); + + auto result = resolve_redirect_location(base.value(), "/x/./y/../z"); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), "http://example.com/x/z"); +} + +TEST(RedirectTest, ResolvesRelativePathAgainstCurrentDirectory) { + auto base = Url::parse("http://example.com/a/b/c?old=1"); + ASSERT_TRUE(base); + + auto result = resolve_redirect_location(base.value(), "../next?q=2"); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), "http://example.com/a/next?q=2"); +} + +TEST(RedirectTest, ResolvesQueryOnlyLocation) { + auto base = Url::parse("http://example.com/a/b?old=1"); + ASSERT_TRUE(base); + + auto result = resolve_redirect_location(base.value(), "?new=2"); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), "http://example.com/a/b?new=2"); +} + +TEST(RedirectTest, FragmentIsNeverSentToServer) { + auto base = Url::parse("http://example.com/a/b?x=1"); + ASSERT_TRUE(base); + + auto result = resolve_redirect_location(base.value(), "#section"); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), "http://example.com/a/b?x=1"); +} + +TEST(RedirectTest, PreservesExplicitPortAndIpv6Authority) { + auto base = Url::parse("http://[::1]:8080/a/b"); + ASSERT_TRUE(base); + + auto result = resolve_redirect_location(base.value(), "../next"); + + ASSERT_TRUE(result); + EXPECT_EQ(result.value(), "http://[::1]:8080/next"); +} + +TEST(RedirectTest, RejectsHttpsLocationExplicitly) { + auto base = Url::parse("http://example.com/a"); + ASSERT_TRUE(base); + + auto result = resolve_redirect_location(base.value(), "https://example.com/b"); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::UnsupportedRedirectScheme); +} + +TEST(RedirectTest, EmptyLocationIsMissing) { + auto base = Url::parse("http://example.com/a"); + ASSERT_TRUE(base); + + auto result = resolve_redirect_location(base.value(), ""); + + ASSERT_FALSE(result); + EXPECT_EQ(result.error().code, ErrorCode::MissingRedirectLocation); +} + +TEST(RedirectTest, PostBecomesGetFor301And302) { + const auto moved = redirect_behavior(301, Method::Post); + EXPECT_EQ(moved.method, Method::Get); + EXPECT_FALSE(moved.preserve_body); + + const auto found = redirect_behavior(302, Method::Post); + EXPECT_EQ(found.method, Method::Get); + EXPECT_FALSE(found.preserve_body); +} + +TEST(RedirectTest, OtherMethodsRemainFor301And302) { + const auto put = redirect_behavior(301, Method::Put); + EXPECT_EQ(put.method, Method::Put); + EXPECT_TRUE(put.preserve_body); + + const auto patch = redirect_behavior(302, Method::Patch); + EXPECT_EQ(patch.method, Method::Patch); + EXPECT_TRUE(patch.preserve_body); +} + +TEST(RedirectTest, SeeOtherUsesGetExceptForHead) { + const auto post = redirect_behavior(303, Method::Post); + EXPECT_EQ(post.method, Method::Get); + EXPECT_FALSE(post.preserve_body); + + const auto head = redirect_behavior(303, Method::Head); + EXPECT_EQ(head.method, Method::Head); + EXPECT_FALSE(head.preserve_body); +} + +TEST(RedirectTest, TemporaryAndPermanentRedirectPreserveMethodAndBody) { + const auto temporary = redirect_behavior(307, Method::Post); + EXPECT_EQ(temporary.method, Method::Post); + EXPECT_TRUE(temporary.preserve_body); + + const auto permanent = redirect_behavior(308, Method::Patch); + EXPECT_EQ(permanent.method, Method::Patch); + EXPECT_TRUE(permanent.preserve_body); +} + +} // namespace