This is an automated email from the ASF dual-hosted git repository.

wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new 059a1164 fix(rest): do not follow HTTP redirects in HttpClient (#1024)
059a1164 is described below

commit 059a116435698db0397594abf35c104331f8a234
Author: lishuxu <[email protected]>
AuthorDate: Mon Oct 19 13:05:41 2026 +0800

    fix(rest): do not follow HTTP redirects in HttpClient (#1024)
    
    Signed requests are bound to the exact request URL and cannot be
    transparently re-signed for a redirect target, and custom headers set
    on the request are forwarded verbatim when cpr follows a redirect.
    Disable redirect following for all HttpClient requests so that a 3xx
    response is surfaced to the caller as an error.
---
 src/iceberg/catalog/rest/http_client.cc |  30 +++--
 src/iceberg/test/http_client_test.cc    | 189 ++++++++++++++++++++++++++++++++
 2 files changed, 211 insertions(+), 8 deletions(-)

diff --git a/src/iceberg/catalog/rest/http_client.cc 
b/src/iceberg/catalog/rest/http_client.cc
index 871a21e6..718a5b73 100644
--- a/src/iceberg/catalog/rest/http_client.cc
+++ b/src/iceberg/catalog/rest/http_client.cc
@@ -75,6 +75,17 @@ namespace {
 /// \brief Default error type for unparseable REST responses.
 constexpr std::string_view kRestExceptionType = "RESTException";
 
+/// \brief Do not follow HTTP redirects on catalog requests.
+///
+/// cpr follows redirects by default. Signed requests are bound to the exact
+/// request URL and cannot be re-signed for a redirect target, and headers set
+/// on the request (e.g. the relocated `Original-Authorization` header used by
+/// SigV4) would be sent unchanged to the new location. A redirect response is
+/// therefore surfaced to the caller as an error instead of being followed.
+const cpr::Redirect kNoFollowRedirects{/*maximum=*/0L, /*follow=*/false,
+                                       /*cont_send_cred=*/false,
+                                       cpr::PostRedirectFlags::POST_ALL};
+
 /// \brief Merge default headers with per-request headers (per-request wins).
 HttpHeaders MergeHeaders(
     const std::unordered_map<std::string, std::string>& default_headers,
@@ -204,7 +215,8 @@ Result<HttpResponse> HttpClient::Get(
                           AuthenticateRequest(session, HttpMethod::kGet, 
std::move(url),
                                               MergeHeaders(default_headers_, 
headers)));
   cpr::Response response =
-      cpr::Get(cpr::Url{authenticated.url}, ToCprHeader(authenticated), 
*connection_pool_,
+      cpr::Get(cpr::Url{authenticated.url}, ToCprHeader(authenticated),
+               kNoFollowRedirects, *connection_pool_,
                cpr::ConnectTimeout{connection_timeout_}, 
cpr::Timeout{socket_timeout_});
 
   ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler));
@@ -223,7 +235,7 @@ Result<HttpResponse> HttpClient::Post(
                           MergeHeaders(default_headers_, headers), body));
   cpr::Response response =
       cpr::Post(cpr::Url{authenticated.url}, cpr::Body{authenticated.body},
-                ToCprHeader(authenticated), *connection_pool_,
+                ToCprHeader(authenticated), kNoFollowRedirects, 
*connection_pool_,
                 cpr::ConnectTimeout{connection_timeout_}, 
cpr::Timeout{socket_timeout_});
 
   ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler));
@@ -254,7 +266,7 @@ Result<HttpResponse> HttpClient::PostForm(
                           std::move(encoded_body)));
   cpr::Response response =
       cpr::Post(cpr::Url{authenticated.url}, cpr::Body{authenticated.body},
-                ToCprHeader(authenticated), *connection_pool_,
+                ToCprHeader(authenticated), kNoFollowRedirects, 
*connection_pool_,
                 cpr::ConnectTimeout{connection_timeout_}, 
cpr::Timeout{socket_timeout_});
 
   ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler));
@@ -269,9 +281,10 @@ Result<HttpResponse> HttpClient::Head(
   ICEBERG_ASSIGN_OR_RAISE(auto authenticated,
                           AuthenticateRequest(session, HttpMethod::kHead, path,
                                               MergeHeaders(default_headers_, 
headers)));
-  cpr::Response response = cpr::Head(
-      cpr::Url{authenticated.url}, ToCprHeader(authenticated), 
*connection_pool_,
-      cpr::ConnectTimeout{connection_timeout_}, cpr::Timeout{socket_timeout_});
+  cpr::Response response =
+      cpr::Head(cpr::Url{authenticated.url}, ToCprHeader(authenticated),
+                kNoFollowRedirects, *connection_pool_,
+                cpr::ConnectTimeout{connection_timeout_}, 
cpr::Timeout{socket_timeout_});
 
   ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler));
   HttpResponse http_response;
@@ -289,8 +302,9 @@ Result<HttpResponse> HttpClient::Delete(
       AuthenticateRequest(session, HttpMethod::kDelete, std::move(url),
                           MergeHeaders(default_headers_, headers)));
   cpr::Response response = cpr::Delete(
-      cpr::Url{authenticated.url}, ToCprHeader(authenticated), 
*connection_pool_,
-      cpr::ConnectTimeout{connection_timeout_}, cpr::Timeout{socket_timeout_});
+      cpr::Url{authenticated.url}, ToCprHeader(authenticated), 
kNoFollowRedirects,
+      *connection_pool_, cpr::ConnectTimeout{connection_timeout_},
+      cpr::Timeout{socket_timeout_});
 
   ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler));
   HttpResponse http_response;
diff --git a/src/iceberg/test/http_client_test.cc 
b/src/iceberg/test/http_client_test.cc
index 4203e742..9e503729 100644
--- a/src/iceberg/test/http_client_test.cc
+++ b/src/iceberg/test/http_client_test.cc
@@ -20,17 +20,22 @@
 #include "iceberg/catalog/rest/http_client.h"
 
 #include <array>
+#include <atomic>
+#include <cctype>
 #include <chrono>
+#include <cstddef>
 #include <cstdint>
 #include <format>
 #include <string>
 #include <string_view>
 #include <thread>
+#include <utility>
 
 #include <gmock/gmock.h>
 #include <gtest/gtest.h>
 
 #ifndef _WIN32
+#  include <poll.h>
 #  include <unistd.h>
 
 #  include <arpa/inet.h>
@@ -49,6 +54,143 @@ namespace iceberg::rest {
 
 namespace {
 
+/// \brief Minimal HTTP/1.1 server on 127.0.0.1 that answers every request with
+/// a fixed response and closes the connection.
+class LoopbackHttpServer {
+ public:
+  explicit LoopbackHttpServer(std::string response) : 
response_(std::move(response)) {}
+
+  ~LoopbackHttpServer() {
+    stop_ = true;
+    if (thread_.joinable()) {
+      thread_.join();
+    }
+    if (listen_fd_ >= 0) {
+      close(listen_fd_);
+    }
+  }
+
+  LoopbackHttpServer(const LoopbackHttpServer&) = delete;
+  LoopbackHttpServer& operator=(const LoopbackHttpServer&) = delete;
+
+  /// \brief Bind an ephemeral port and start serving. Returns false on 
failure.
+  bool Start() {
+    listen_fd_ = socket(AF_INET, SOCK_STREAM, 0);
+    if (listen_fd_ < 0) {
+      return false;
+    }
+    int reuse = 1;
+    setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
+
+    sockaddr_in addr{};
+    addr.sin_family = AF_INET;
+    addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+    addr.sin_port = 0;
+    if (bind(listen_fd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 
0 ||
+        listen(listen_fd_, 8) != 0) {
+      return false;
+    }
+    socklen_t len = sizeof(addr);
+    if (getsockname(listen_fd_, reinterpret_cast<sockaddr*>(&addr), &len) != 
0) {
+      return false;
+    }
+    port_ = ntohs(addr.sin_port);
+    thread_ = std::thread([this] { Serve(); });
+    return true;
+  }
+
+  int port() const { return port_; }
+  int request_count() const { return request_count_.load(); }
+  std::string url(std::string_view path) const {
+    return std::format("http://127.0.0.1:{}{}";, port_, path);
+  }
+
+ private:
+  void Serve() {
+    while (!stop_) {
+      pollfd pfd{.fd = listen_fd_, .events = POLLIN, .revents = 0};
+      if (poll(&pfd, 1, /*timeout_ms=*/50) <= 0) {
+        continue;
+      }
+      int conn = accept(listen_fd_, nullptr, nullptr);
+      if (conn < 0) {
+        continue;
+      }
+      ReadRequest(conn);
+      ++request_count_;
+      SendAll(conn, response_);
+      close(conn);
+    }
+  }
+
+  /// \brief Consume the request headers and any Content-Length body so the
+  /// client does not see a connection reset before it reads the response.
+  static void ReadRequest(int conn) {
+    timeval timeout{.tv_sec = 10, .tv_usec = 0};
+    setsockopt(conn, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
+    setsockopt(conn, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
+
+    std::string request;
+    std::array<char, 4096> buf{};
+    size_t header_end = std::string::npos;
+    size_t content_length = 0;
+    while (true) {
+      ssize_t n = recv(conn, buf.data(), buf.size(), 0);
+      if (n <= 0) {
+        return;
+      }
+      request.append(buf.data(), static_cast<size_t>(n));
+      if (header_end == std::string::npos) {
+        header_end = request.find("\r\n\r\n");
+        if (header_end == std::string::npos) {
+          continue;
+        }
+        content_length = ParseContentLength(request.substr(0, header_end));
+      }
+      if (request.size() >= header_end + 4 + content_length) {
+        return;
+      }
+    }
+  }
+
+  static size_t ParseContentLength(std::string headers) {
+    for (auto& c : headers) {
+      c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
+    }
+    constexpr std::string_view kHeader = "content-length:";
+    size_t pos = headers.find(kHeader);
+    if (pos == std::string::npos) {
+      return 0;
+    }
+    return std::stoul(headers.substr(pos + kHeader.size()));
+  }
+
+  static void SendAll(int conn, const std::string& data) {
+#  ifdef MSG_NOSIGNAL
+    constexpr int kFlags = MSG_NOSIGNAL;
+#  else
+    constexpr int kFlags = 0;
+    int no_sigpipe = 1;
+    setsockopt(conn, SOL_SOCKET, SO_NOSIGPIPE, &no_sigpipe, 
sizeof(no_sigpipe));
+#  endif
+    size_t sent = 0;
+    while (sent < data.size()) {
+      ssize_t n = send(conn, data.data() + sent, data.size() - sent, kFlags);
+      if (n <= 0) {
+        return;
+      }
+      sent += static_cast<size_t>(n);
+    }
+  }
+
+  std::string response_;
+  int listen_fd_ = -1;
+  int port_ = 0;
+  std::atomic<bool> stop_{false};
+  std::atomic<int> request_count_{0};
+  std::thread thread_;
+};
+
 // A loopback socket listening on an ephemeral port.
 struct Listener {
   int fd = -1;
@@ -172,4 +314,51 @@ TEST(HttpClientTest, 
PostReportsTransferFailureAfterSuccessfulStatus) {
 
 #endif
 
+// Redirect responses must be reported without replaying the request at the 
target.
+TEST(HttpClientTest, DoesNotFollowRedirects) {
+#ifdef _WIN32
+  GTEST_SKIP() << "Loopback test server is only implemented for POSIX";
+#else
+  for (const int status_code : {301, 302, 303, 307, 308}) {
+    SCOPED_TRACE(status_code);
+    LoopbackHttpServer target(
+        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n"
+        "Content-Length: 2\r\nConnection: close\r\n\r\n{}");
+    ASSERT_TRUE(target.Start());
+
+    LoopbackHttpServer redirector(
+        std::format("HTTP/1.1 {} Redirect\r\nLocation: {}\r\nContent-Length: 
0\r\n"
+                    "Connection: close\r\n\r\n",
+                    status_code, target.url("/v1/config")));
+    ASSERT_TRUE(redirector.Start());
+
+    HttpClient client({}, std::chrono::seconds(10), std::chrono::seconds(10));
+    auto session = auth::AuthSession::MakeDefault(
+        {{"Authorization", "Bearer token"},
+         {"Original-Authorization", "Bearer delegate-token"},
+         {"X-Amz-Security-Token", "session-token"}});
+    const auto& handler = *DefaultErrorHandler::Instance();
+    const std::string url = redirector.url("/v1/config");
+
+    auto expect_not_followed = [&](const Result<HttpResponse>& result,
+                                   std::string_view method) {
+      SCOPED_TRACE(method);
+      EXPECT_THAT(result, IsError(ErrorKind::kRestError));
+      EXPECT_THAT(result, HasErrorMessage(std::format("code: {}", 
status_code)));
+      EXPECT_EQ(target.request_count(), 0);
+    };
+
+    expect_not_followed(client.Get(url, {}, {}, handler, *session), "GET");
+    expect_not_followed(client.Post(url, "{}", {}, handler, *session), "POST");
+    expect_not_followed(client.PostForm(url, {{"grant_type", 
"client_credentials"}}, {},
+                                        handler, *session),
+                        "POST form");
+    expect_not_followed(client.Head(url, {}, handler, *session), "HEAD");
+    expect_not_followed(client.Delete(url, {}, {}, handler, *session), 
"DELETE");
+
+    EXPECT_EQ(redirector.request_count(), 5);
+  }
+#endif
+}
+
 }  // namespace iceberg::rest

Reply via email to