diff --git a/README.md b/README.md index bf1958f8..e1b63296 100644 --- a/README.md +++ b/README.md @@ -916,6 +916,14 @@ svr.set_keep_alive_max_count(2); // Default is 100, 0 for no limit svr.set_keep_alive_timeout(10); // Default is 5 ``` +### Client address behind a reverse proxy + +```cpp +svr.set_trusted_proxies({"10.0.0.2"}); +``` + +A request that comes straight from one of these addresses gets `req.remote_addr` from `X-Forwarded-For`: the rightmost address in it that is not a trusted proxy. A request from anywhere else keeps the address of its connection, whatever `X-Forwarded-For` says. Addresses match as IP addresses, so an IPv4 peer that a server listening on both IPv4 and IPv6 sees as `::ffff:10.0.0.2` matches `10.0.0.2`. Host names and CIDR ranges are not supported. + ### Timeout ```c++ diff --git a/httplib.h b/httplib.h index 17bcb21d..3118c568 100644 --- a/httplib.h +++ b/httplib.h @@ -10670,6 +10670,32 @@ inline bool is_ip_address(const std::string &host) { inet_pton(AF_INET6, host.c_str(), &addr6) == 1; } +// One spelling for an IPv6 address, and the IPv4 one for an IPv4-mapped IPv6 +// address, which is how a dual-stack socket reports an IPv4 peer. Anything +// else, including an address with a zone ID, is returned as it is. +inline std::string canonical_ip(const std::string &addr) { + struct in6_addr addr6; + if (addr.find('%') != std::string::npos || + inet_pton(AF_INET6, addr.c_str(), &addr6) != 1) { + return addr; + } + + char buf[INET6_ADDRSTRLEN]; + if (IN6_IS_ADDR_V4MAPPED(&addr6)) { + return inet_ntop(AF_INET, &addr6.s6_addr[12], buf, sizeof(buf)) ? buf + : addr; + } + return inet_ntop(AF_INET6, &addr6, buf, sizeof(buf)) ? buf : addr; +} + +// `trusted_proxies` holds canonical_ip() spellings. +inline bool is_trusted_proxy(const std::string &addr, + const std::vector &trusted_proxies) { + if (trusted_proxies.empty()) { return false; } + return std::find(trusted_proxies.begin(), trusted_proxies.end(), + canonical_ip(addr)) != trusted_proxies.end(); +} + // Resolve where a client should connect for `host`, honoring a user-supplied // hostname-to-address map. `host` itself is never rewritten, so it keeps // supplying the Host header and SNI; only the connection target changes. @@ -13823,7 +13849,11 @@ inline Server &Server::set_header_writer( inline Server & Server::set_trusted_proxies(const std::vector &proxies) { - trusted_proxies_ = proxies; + trusted_proxies_.clear(); + trusted_proxies_.reserve(proxies.size()); + for (const auto &proxy : proxies) { + trusted_proxies_.push_back(detail::canonical_ip(proxy)); + } return *this; } @@ -15010,12 +15040,7 @@ get_client_ip(const std::string &x_forwarded_for, // returned as the client. for (size_t i = ip_list.size(); i-- > 0;) { const auto &ip = ip_list[i]; - - auto is_trusted_proxy = - std::any_of(trusted_proxies.begin(), trusted_proxies.end(), - [&](const std::string &proxy) { return ip == proxy; }); - - if (!is_trusted_proxy) { return ip; } + if (!detail::is_trusted_proxy(ip, trusted_proxies)) { return ip; } } // Every hop was a trusted proxy; fall back to the first entry. @@ -15147,11 +15172,8 @@ Server::process_request(Stream &strm, const std::string &remote_addr, // Only honor X-Forwarded-For if the peer on the actual TCP connection is // itself a trusted proxy. Otherwise any direct client could spoof // remote_addr simply by sending an arbitrary X-Forwarded-For header. - auto is_trusted_peer = std::any_of( - trusted_proxies_.begin(), trusted_proxies_.end(), - [&](const std::string &proxy) { return proxy == remote_addr; }); - - if (is_trusted_peer && req.has_header("X-Forwarded-For")) { + if (detail::is_trusted_proxy(remote_addr, trusted_proxies_) && + req.has_header("X-Forwarded-For")) { // Some proxies append the address they observed as a separate // X-Forwarded-For field line instead of extending the one the client sent // (e.g. HAProxy's "option forwardfor"), so the whole combined value has to diff --git a/test/test.cc b/test/test.cc index f63c42c8..755e05cb 100644 --- a/test/test.cc +++ b/test/test.cc @@ -22562,6 +22562,55 @@ TEST(ForwardedHeadersTest, NoForwardedHeaders) { observed_remote_addr == "127.0.0.1"); } +// A server listening on both IPv4 and IPv6 sees an IPv4 peer as an +// IPv4-mapped IPv6 address, which still matches the proxy's IPv4 address +TEST(ForwardedHeadersTest, IPv4MappedPeerMatchesIPv4Proxy) { + Server svr; + svr.set_trusted_proxies({"127.0.0.1"}); + svr.Get("/ip", [](const Request &req, Response &res) { + res.set_content(req.remote_addr, "text/plain"); + }); + + auto port = svr.bind_to_any_port("::"); + if (port <= 0) { GTEST_SKIP() << "no IPv6 on this host"; } + thread t = thread([&] { svr.listen_after_bind(); }); + auto se = detail::scope_exit([&] { + svr.stop(); + t.join(); + ASSERT_FALSE(svr.is_running()); + }); + svr.wait_until_ready(); + + Client cli("127.0.0.1", port); + auto res = cli.Get("/ip", Headers{{"X-Forwarded-For", "198.51.100.23"}}); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ("198.51.100.23", res->body); +} + +TEST(ForwardedHeadersTest, IPv4MappedHopMatchesIPv4Proxy) { + Server svr; + svr.set_trusted_proxies({"192.0.2.45", "::1", "127.0.0.1"}); + svr.Get("/ip", [](const Request &req, Response &res) { + res.set_content(req.remote_addr, "text/plain"); + }); + + auto port = svr.bind_to_any_port(HOST); + ASSERT_GT(port, 0); + thread t = thread([&] { svr.listen_after_bind(); }); + auto se = detail::scope_exit([&] { + svr.stop(); + t.join(); + ASSERT_FALSE(svr.is_running()); + }); + svr.wait_until_ready(); + + Client cli(HOST, port); + auto res = cli.Get( + "/ip", Headers{{"X-Forwarded-For", "198.51.100.23, ::ffff:192.0.2.45"}}); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + EXPECT_EQ("198.51.100.23", res->body); +} + TEST(ForwardedHeadersTest, SingleTrustedProxy_UsesIPBeforeTrusted) { Server svr;