diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..49a516a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,39 @@ +name: C++ SDK tests + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-2025] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + - name: Install libcurl on Linux + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev + - name: Configure + if: runner.os != 'Windows' + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release + - name: Configure Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + vcpkg install "curl[core,ssl]:x64-windows" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cmake -S . -B build -A x64 "-DCMAKE_TOOLCHAIN_FILE=$env:VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" + - name: Build + run: cmake --build build --config Release --parallel 4 + - name: Test + run: ctest --test-dir build -C Release --output-on-failure -LE live + - name: Install SDK + run: cmake --install build --config Release --prefix install diff --git a/CHANGELOG.md b/CHANGELOG.md index 24e4f59..03315fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.0.1 + +- Add Arabic (`Language::kAr`) for single and bulk lookups. +- Include CDN, proxy, bot and corporate gateway details in typed lookup results. +- Hide API keys in request errors and typed bulk error messages. +- Keep the HTTP status when an error response is too large or ends early. +- Reject invalid HTTP headers, malformed JSON text and extra results in typed bulk lookups. +- Fix very long timeouts on Windows and use HTTP/1.1 with libcurl versions affected by the HTTP/2 upload issue. + ## 1.0.0 - First C++ SDK release for the IPGeolocation.io IP Location API. diff --git a/CMakeLists.txt b/CMakeLists.txt index b6d20fb..587b85f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.16) -project(ipgeolocation_cpp_sdk VERSION 1.0.0 LANGUAGES CXX) +project(ipgeolocation_cpp_sdk VERSION 1.0.1 LANGUAGES CXX) option(IPGEOLOCATION_BUILD_TESTS "Build the C++ SDK unit tests" ON) option(IPGEOLOCATION_ENABLE_COVERAGE "Enable LLVM source-based coverage instrumentation" OFF) @@ -129,6 +129,9 @@ if (IPGEOLOCATION_BUILD_TESTS) target_compile_features(ipgeolocation_tests PRIVATE cxx_std_17) target_include_directories(ipgeolocation_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(ipgeolocation_tests PRIVATE ipgeolocation_test_support) + if (WIN32) + target_link_libraries(ipgeolocation_tests PRIVATE ws2_32) + endif () add_test(NAME ipgeolocation_tests COMMAND ipgeolocation_tests) add_executable(ipgeolocation_live_integration_tests @@ -155,6 +158,12 @@ if (IPGEOLOCATION_BUILD_TESTS) SKIP_RETURN_CODE 77 ) + if (MSVC) + foreach(test_target ipgeolocation_tests ipgeolocation_live_integration_tests ipgeolocation_live_field_parity_tests) + target_compile_options(${test_target} PRIVATE /utf-8) + endforeach() + endif () + if (IPGEOLOCATION_ENABLE_COVERAGE) if (APPLE) find_program(IPGEOLOCATION_XCRUN_EXECUTABLE xcrun REQUIRED) diff --git a/README.md b/README.md index a4c9c0c..6830fbb 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Look up IPv4, IPv6, and domains with `/v3/ipgeo` and `/v3/ipgeo-bulk`. Get geolo Use the SDK from CMake in one of these ways. -The SDK requires `libcurl` with HTTP(S) support. The provided CMake build locates it with `find_package(CURL REQUIRED)`. +The SDK requires `libcurl` with HTTP(S) support. Use libcurl 8.23.0 or newer, or a version patched by your operating system. The provided CMake build locates it with `find_package(CURL REQUIRED)`. ### Install with `vcpkg` @@ -142,7 +142,7 @@ Leave `request.ip` unset to resolve the caller IP. | Supported Endpoints | `/v3/ipgeo`, `/v3/ipgeo-bulk` | | Supported Inputs | IPv4, IPv6, domain | | Main Data Returned | Geolocation, company, ASN, time zone, network, hostname, abuse, user-agent, currency, security | -| Authentication | API key, request-origin auth for `/v3/ipgeo` only | +| Authentication | API key | | Response Formats | Structured JSON, raw JSON, raw XML | | Bulk Limit | Up to 50,000 IPs or domains per request | | Transport | `libcurl` | @@ -157,7 +157,7 @@ Create an IPGeolocation.io account and copy an API key from your dashboard. 4. Open your dashboard: 5. Copy an API key from the `API Keys` section -For server-side C++ code, keep the API key in an environment variable or secret manager. For browser-based single lookups on paid plans, use request-origin auth instead of exposing an API key in frontend code. +Keep your IPGeolocation.io API key in an environment variable or secret manager. ## Authentication @@ -172,22 +172,7 @@ if (const char* api_key = std::getenv("IPGEO_API_KEY"); api_key != nullptr) { ipgeolocation::IpGeolocationClient client(config); ``` -### Request-Origin Auth - -```cpp -ipgeolocation::IpGeolocationClientConfig config; -config.request_origin = "https://app.example.com"; - -ipgeolocation::IpGeolocationClient client(config); -``` - -`request_origin` must be an absolute `http` or `https` origin with no path, query string, fragment, or userinfo. - -> [!IMPORTANT] -> Request-origin auth does not work with `/v3/ipgeo-bulk`. Bulk lookup always requires `api_key`. - -> [!NOTE] -> If you set both `api_key` and `request_origin`, single lookup still uses the API key. The API key is sent as the `apiKey` query parameter, so avoid logging full request URLs. +The API key is sent as the `apiKey` query parameter, so avoid logging full request URLs. ## Plan Behavior @@ -199,7 +184,6 @@ Feature availability depends on your plan and request parameters. | Domain lookup | Not supported | Supported | | Bulk lookup | Not supported | Supported | | Non-English `lang` | Not supported | Supported | -| Request-origin auth | Not supported | Supported for `/v3/ipgeo` only | | Optional modules via `include` | Not supported | Supported | | `include = {"*"}` | Base response only | All plan-available modules | @@ -209,12 +193,11 @@ Paid plans still need `include` for optional modules. `fields` and `excludes` on | Field | Type | Default | Notes | |-------|------|---------|-------| -| `api_key` | `std::optional` | unset | Required for bulk lookup. Optional for single lookup if `request_origin` is set. | -| `request_origin` | `std::optional` | unset | Must be an absolute `http` or `https` origin with no path, query string, fragment, or userinfo. | +| `api_key` | `std::optional` | unset | Your IPGeolocation.io API key. | | `base_url` | `std::string` | `https://api.ipgeolocation.io` | Override the API base URL. | | `connect_timeout` | `std::chrono::milliseconds` | `10000ms` | Time to open the connection. Must be greater than zero and must be less than or equal to `read_timeout`. | -| `read_timeout` | `std::chrono::milliseconds` | `30000ms` | Time to wait while reading the response body. Must be greater than zero and must be greater than or equal to `connect_timeout`. | -| `max_response_body_chars` | `std::size_t` | `33554432` | Maximum response body size the SDK will buffer before failing the request. Must be greater than zero. | +| `read_timeout` | `std::chrono::milliseconds` | `30000ms` | Maximum time for the whole request, including the connection and the response. Must be greater than zero and at least as long as `connect_timeout`. | +| `max_response_body_chars` | `std::size_t` | `33554432` | Largest response to read, in bytes. Must be greater than zero. | Config values are validated when the client is created. Request values are validated before each request is sent. @@ -222,7 +205,7 @@ Request methods are safe to call concurrently on a shared client instance. `Clos Typed JSON parsing rejects payloads nested deeper than `256` levels. -The transport buffers up to `max_response_body_chars` bytes of response body data before failing with `response body exceeded maxResponseBodyChars`. +If a successful response is too large, the SDK throws `TransportError`. If an API error is too large, you still get its HTTP status. ## Available Methods @@ -244,7 +227,7 @@ The transport buffers up to `max_response_body_chars` bytes of response body dat |-------|------------|-------| | `ip` | Single lookup | IPv4, IPv6, or domain. Leave it unset for caller IP lookup. | | `ips` | Bulk lookup | Collection of 1 to 50,000 IPs or domains. | -| `lang` | Single and bulk | One of `Language::kEn`, `kDe`, `kRu`, `kJa`, `kFr`, `kCn`, `kEs`, `kCs`, `kIt`, `kKo`, `kFa`, or `kPt`. | +| `lang` | Single and bulk | One of `Language::kEn`, `kDe`, `kRu`, `kJa`, `kFr`, `kCn`, `kEs`, `kCs`, `kIt`, `kKo`, `kFa`, `kPt`, or `kAr` (Arabic). | | `include` | Single and bulk | Collection of module names such as `security`, `abuse`, `user_agent`, `hostname`, `liveHostname`, `hostnameFallbackLive`, `geo_accuracy`, `dma_code`, or `*`. | | `fields` | Single and bulk | Collection of field paths to keep, for example `location.country_name` or `security.threat_score`. | | `excludes` | Single and bulk | Collection of field paths to remove from the response. | @@ -416,14 +399,14 @@ Header names are preserved in the case the server returned them. Response header The SDK throws exceptions derived from `IpGeolocationError`: -- `ValidationError`: invalid config, invalid request, blank headers, bad origin, or missing auth +- `ValidationError`: invalid settings, invalid request, invalid headers, or missing credentials - `ClientClosedError`: method called after `Close()` - `TransportError`: `libcurl` transport failure - `RequestTimeoutError`: connection or read timeout - `SerializationError`: invalid JSON in typed methods - `ApiError`: non-2xx API response, including HTTP status code and response body -`ApiError` exposes `status_code()` and `response_body()`. Its `what()` message is taken from the API's JSON error body when present. If the body is not parseable JSON, the message falls back to the raw body or a status-based string. +`ApiError` gives you the HTTP status, the error message and the response body. If an error includes your API key, the SDK hides it. This also applies to errors in typed bulk results. Raw bulk lookups return the API response as received. If the error body is too large or the connection ends before it arrives in full, you still get the HTTP status. Example: @@ -447,13 +430,9 @@ try { ## Troubleshooting -### `bulk lookup requires apiKey in client config` - -Bulk lookup does not support request-origin auth. Set `api_key` in the client config. - -### `requestOrigin must not include a path` +### Missing API key -Use an origin only, such as `https://app.example.com`. Do not include `/path`, query strings, fragments, or userinfo. +Set `api_key` before making a lookup. ### `headers must not contain CR or LF` @@ -477,11 +456,6 @@ Response fields are `std::optional` so omitted fields stay omitted. Check `has_v ## Frequently Asked Questions -
-Can I use this SDK without an API key? -Only for single lookup with paid-plan request-origin auth. Bulk lookup always requires an API key. -
-
Can I request XML and still get typed models? No. Typed methods only support JSON. Use `LookupIpGeolocationRaw` or `BulkLookupIpGeolocationRaw` for XML. diff --git a/conanfile.py b/conanfile.py index c0b3d68..a864101 100644 --- a/conanfile.py +++ b/conanfile.py @@ -14,7 +14,7 @@ class IpGeolocationCppSdkConan(ConanFile): name = "ipgeolocation-cpp-sdk" - version = "1.0.0" + version = "1.0.1" license = "MIT" url = "https://github.com/IPGeolocation/ip-geolocation-api-cpp-sdk" homepage = "https://github.com/IPGeolocation/ip-geolocation-api-cpp-sdk" diff --git a/include/ipgeolocation/language.hpp b/include/ipgeolocation/language.hpp index e99896d..75f359b 100644 --- a/include/ipgeolocation/language.hpp +++ b/include/ipgeolocation/language.hpp @@ -15,6 +15,7 @@ enum class Language { kKo, kFa, kPt, + kAr, }; } // namespace ipgeolocation diff --git a/include/ipgeolocation/models.hpp b/include/ipgeolocation/models.hpp index f90d036..1e9a6a5 100644 --- a/include/ipgeolocation/models.hpp +++ b/include/ipgeolocation/models.hpp @@ -1,7 +1,11 @@ #pragma once +#include #include #include +#include +#include +#include #include #include "ipgeolocation/visibility.hpp" @@ -49,6 +53,8 @@ struct IPGEOLOCATION_API Network { std::optional connection_type; std::optional route; std::optional is_anycast; + std::optional is_cdn{}; + std::optional cdn_provider_name{}; }; struct IPGEOLOCATION_API Asn { @@ -115,6 +121,17 @@ struct IPGEOLOCATION_API Security { std::optional is_spam; std::optional is_cloud_provider; std::optional cloud_provider_name; + std::optional is_known_good_bot{}; + std::optional is_corporate_gateway{}; + std::optional proxy_days_seen_30d{}; + std::optional proxy_days_seen_7d{}; + std::optional proxy_provider_count_30d{}; + std::optional bot_confidence_score{}; + std::optional bot_operator_name{}; + std::optional bot_type{}; + std::optional bot_last_seen{}; + std::optional corporate_gateway_type{}; + std::optional corporate_gateway_provider_name{}; }; struct IPGEOLOCATION_API Abuse { @@ -190,4 +207,57 @@ struct IPGEOLOCATION_API BulkLookupResult { } }; +namespace internal { + +template +struct BindingMembers; + +template <> +struct BindingMembers { + inline static constexpr auto fields = + std::make_tuple(&Network::connection_type, &Network::route, &Network::is_anycast); +}; + +template <> +struct BindingMembers { + inline static constexpr auto fields = std::make_tuple( + &Security::threat_score, &Security::is_tor, &Security::is_proxy, &Security::proxy_provider_names, + &Security::proxy_confidence_score, &Security::proxy_last_seen, &Security::is_residential_proxy, + &Security::is_vpn, &Security::vpn_provider_names, &Security::vpn_confidence_score, &Security::vpn_last_seen, + &Security::is_relay, &Security::relay_provider_name, &Security::is_anonymous, &Security::is_known_attacker, + &Security::is_bot, &Security::is_spam, &Security::is_cloud_provider, &Security::cloud_provider_name); +}; + +} + +template , Network> || + std::is_same_v, Security>, int> = 0> +constexpr decltype(auto) get(Model&& value) noexcept { + constexpr auto field = std::get(internal::BindingMembers>::fields); + return (std::forward(value).*field); +} + } // namespace ipgeolocation + +namespace std { + +template <> +struct tuple_size + : tuple_size::fields)> {}; + +template <> +struct tuple_size + : tuple_size::fields)> {}; + +template +struct tuple_element { + using type = remove_reference_t(declval()))>; +}; + +template +struct tuple_element { + using type = remove_reference_t(declval()))>; +}; + +} diff --git a/include/ipgeolocation/version.hpp b/include/ipgeolocation/version.hpp index a8ba96b..b1b80ec 100644 --- a/include/ipgeolocation/version.hpp +++ b/include/ipgeolocation/version.hpp @@ -2,6 +2,6 @@ namespace ipgeolocation { -inline constexpr const char* VERSION = "1.0.0"; +inline constexpr const char* VERSION = "1.0.1"; } // namespace ipgeolocation diff --git a/src/client.cpp b/src/client.cpp index 7124963..34bb73b 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -41,7 +41,7 @@ ApiResponse IpGeolocationClient::LookupIpGeolocation( const auto duration_ms = std::chrono::duration_cast(finished_at - started_at).count(); if (response.status_code / 100 != 2) { - throw internal::ToApiError(response.status_code, response.body); + throw internal::ToApiError(response.status_code, response.body, impl_->config.api_key); } ApiResponse result; @@ -72,7 +72,7 @@ ApiResponse IpGeolocationClient::LookupIpGeolocationRaw( const auto duration_ms = std::chrono::duration_cast(finished_at - started_at).count(); if (response.status_code / 100 != 2) { - throw internal::ToApiError(response.status_code, response.body); + throw internal::ToApiError(response.status_code, response.body, impl_->config.api_key); } ApiResponse result; @@ -107,11 +107,11 @@ ApiResponse> IpGeolocationClient::BulkLookupIpGeol const auto duration_ms = std::chrono::duration_cast(finished_at - started_at).count(); if (response.status_code / 100 != 2) { - throw internal::ToApiError(response.status_code, response.body); + throw internal::ToApiError(response.status_code, response.body, impl_->config.api_key); } ApiResponse> result; - result.data = internal::ParseBulkLookupResults(response.body); + result.data = internal::ParseBulkLookupResults(response.body, normalized.ips.size(), impl_->config.api_key); result.metadata = internal::ToMetadata(response.status_code, duration_ms, response.headers); return result; } @@ -138,7 +138,7 @@ ApiResponse IpGeolocationClient::BulkLookupIpGeolocationRaw( const auto duration_ms = std::chrono::duration_cast(finished_at - started_at).count(); if (response.status_code / 100 != 2) { - throw internal::ToApiError(response.status_code, response.body); + throw internal::ToApiError(response.status_code, response.body, impl_->config.api_key); } ApiResponse result; diff --git a/src/curl_http_transport.cpp b/src/curl_http_transport.cpp index 3c94e21..5298c19 100644 --- a/src/curl_http_transport.cpp +++ b/src/curl_http_transport.cpp @@ -1,5 +1,8 @@ #include "internal.hpp" +#include +#include +#include #include #include #include @@ -31,16 +34,20 @@ struct WriteContext { std::size_t WriteBody(char* pointer, std::size_t size, std::size_t nmemb, void* userdata) { auto* context = static_cast(userdata); - const std::size_t bytes = size * nmemb; - if (context->body.size() + bytes > context->max_size) { + if (size != 0 && nmemb > (context->max_size - context->body.size()) / size) { context->size_exceeded = true; return 0; } - context->body.append(pointer, bytes); - return bytes; + const std::size_t bytes = size * nmemb; + try { + context->body.append(pointer, bytes); + return bytes; + } catch (...) { + return 0; + } } -std::size_t ReadHeaders(char* buffer, std::size_t size, std::size_t nitems, void* userdata) { +std::size_t ReadHeaders(char* buffer, std::size_t size, std::size_t nitems, void* userdata) try { auto* headers = static_cast>*>(userdata); const std::size_t bytes = size * nitems; std::string line(buffer, bytes); @@ -60,9 +67,7 @@ std::size_t ReadHeaders(char* buffer, std::size_t size, std::size_t nitems, void while (!name.empty() && (name.back() == '\r' || name.back() == '\n' || name.back() == ' ' || name.back() == '\t')) { name.pop_back(); } - while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) { - value.erase(value.begin()); - } + value.erase(0, value.find_first_not_of(" \t")); while (!value.empty() && (value.back() == '\r' || value.back() == '\n')) { value.pop_back(); } @@ -72,6 +77,8 @@ std::size_t ReadHeaders(char* buffer, std::size_t size, std::size_t nitems, void } return bytes; +} catch (...) { + return 0; } class CurlHeaderList { @@ -88,7 +95,11 @@ class CurlHeaderList { CurlHeaderList& operator=(const CurlHeaderList&) = delete; void Append(const std::string& header_line) { - list_ = curl_slist_append(list_, header_line.c_str()); + curl_slist* appended = curl_slist_append(list_, header_line.c_str()); + if (appended == nullptr) { + throw TransportError("failed to prepare request headers"); + } + list_ = appended; } curl_slist* get() const noexcept { @@ -99,10 +110,28 @@ class CurlHeaderList { curl_slist* list_ = nullptr; }; +void CheckCurl(CURLcode result) { + if (result != CURLE_OK) { + throw TransportError(std::string("libcurl transport error: ") + curl_easy_strerror(result)); + } +} + } // namespace +long CurlTimeoutMilliseconds(std::chrono::milliseconds timeout) { + return static_cast(std::min(timeout.count(), LONG_MAX)); +} + +bool NeedsHttp11ForCurl(unsigned int version) { + return version >= 0x080c00 && version < 0x081700 && + !(version >= 0x080e03 && version < 0x080f00) && + !(version >= 0x081002 && version < 0x081100) && + !(version >= 0x081402 && version < 0x081500); +} + CurlHttpTransport::CurlHttpTransport() { EnsureCurlGlobalState(); + idle_handles_.reserve(kMaxIdleHandles); } CurlHttpTransport::~CurlHttpTransport() { @@ -161,32 +190,42 @@ HttpResponseData CurlHttpTransport::Send(const HttpRequestData& request, std::si header_list.Append(name + ": " + value); } - curl_easy_setopt(handle, CURLOPT_URL, request.url.c_str()); - curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT_MS, static_cast(request.connect_timeout.count())); - curl_easy_setopt(handle, CURLOPT_TIMEOUT_MS, static_cast(request.read_timeout.count())); - curl_easy_setopt(handle, CURLOPT_FOLLOWLOCATION, 0L); - curl_easy_setopt(handle, CURLOPT_NOSIGNAL, 1L); - curl_easy_setopt(handle, CURLOPT_SSL_VERIFYPEER, 1L); - curl_easy_setopt(handle, CURLOPT_SSL_VERIFYHOST, 2L); - curl_easy_setopt(handle, CURLOPT_HTTPHEADER, header_list.get()); - curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, &WriteBody); - curl_easy_setopt(handle, CURLOPT_WRITEDATA, &write_context); - curl_easy_setopt(handle, CURLOPT_HEADERFUNCTION, &ReadHeaders); - curl_easy_setopt(handle, CURLOPT_HEADERDATA, &headers); + static const bool use_http11 = NeedsHttp11ForCurl(curl_version_info(CURLVERSION_NOW)->version_num); + if (use_http11) { + CheckCurl(curl_easy_setopt(handle, CURLOPT_HTTP_VERSION, static_cast(CURL_HTTP_VERSION_1_1))); + } + CheckCurl(curl_easy_setopt(handle, CURLOPT_URL, request.url.c_str())); + CheckCurl(curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT_MS, CurlTimeoutMilliseconds(request.connect_timeout))); + const long request_timeout = CurlTimeoutMilliseconds(request.read_timeout); + CheckCurl(curl_easy_setopt(handle, CURLOPT_TIMEOUT_MS, request_timeout)); + CheckCurl(curl_easy_setopt(handle, CURLOPT_FOLLOWLOCATION, 0L)); + CheckCurl(curl_easy_setopt(handle, CURLOPT_NOSIGNAL, 1L)); + CheckCurl(curl_easy_setopt(handle, CURLOPT_SSL_VERIFYPEER, 1L)); + CheckCurl(curl_easy_setopt(handle, CURLOPT_SSL_VERIFYHOST, 2L)); + CheckCurl(curl_easy_setopt(handle, CURLOPT_HTTPHEADER, header_list.get())); + CheckCurl(curl_easy_setopt(handle, CURLOPT_WRITEFUNCTION, &WriteBody)); + CheckCurl(curl_easy_setopt(handle, CURLOPT_WRITEDATA, &write_context)); + CheckCurl(curl_easy_setopt(handle, CURLOPT_HEADERFUNCTION, &ReadHeaders)); + CheckCurl(curl_easy_setopt(handle, CURLOPT_HEADERDATA, &headers)); if (request.method == "POST") { - curl_easy_setopt(handle, CURLOPT_POST, 1L); - curl_easy_setopt(handle, CURLOPT_POSTFIELDS, request.body.c_str()); - curl_easy_setopt(handle, CURLOPT_POSTFIELDSIZE, static_cast(request.body.size())); + CheckCurl(curl_easy_setopt(handle, CURLOPT_POST, 1L)); + CheckCurl(curl_easy_setopt(handle, CURLOPT_POSTFIELDS, request.body.c_str())); + CheckCurl(curl_easy_setopt(handle, CURLOPT_POSTFIELDSIZE_LARGE, static_cast(request.body.size()))); } else { - curl_easy_setopt(handle, CURLOPT_HTTPGET, 1L); + CheckCurl(curl_easy_setopt(handle, CURLOPT_HTTPGET, 1L)); } const CURLcode result = curl_easy_perform(handle); + long status_code = 0; + CheckCurl(curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &status_code)); if (result != CURLE_OK) { + if (status_code >= 300 && status_code < 600) { + return {static_cast(status_code), {}, std::move(headers)}; + } if (result == CURLE_OPERATION_TIMEDOUT) { throw RequestTimeoutError( - "HTTP request timed out after " + std::to_string(request.read_timeout.count()) + + "HTTP request timed out after " + std::to_string(request_timeout) + "ms while waiting for response data"); } if (result == CURLE_WRITE_ERROR && write_context.size_exceeded) { @@ -196,9 +235,6 @@ HttpResponseData CurlHttpTransport::Send(const HttpRequestData& request, std::si throw TransportError(std::string("libcurl transport error: ") + curl_easy_strerror(result)); } - long status_code = 0; - curl_easy_getinfo(handle, CURLINFO_RESPONSE_CODE, &status_code); - HttpResponseData response; response.status_code = static_cast(status_code); response.body = std::move(write_context.body); diff --git a/src/internal.cpp b/src/internal.cpp index f95ea07..ff861df 100644 --- a/src/internal.cpp +++ b/src/internal.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include "json.hpp" #include "ipgeolocation/version.hpp" @@ -60,6 +61,17 @@ bool ContainsCrOrLf(const std::string& value) { return value.find('\r') != std::string::npos || value.find('\n') != std::string::npos; } +void ValidateHeaderValue(const std::string& value, const char* field_name) { + if (ContainsCrOrLf(value)) { + throw ValidationError(std::string(field_name) + " must not contain CR or LF"); + } + if (std::any_of(value.begin(), value.end(), [](unsigned char c) { + return (c < 0x20 && c != '\t') || c == 0x7f; + })) { + throw ValidationError(std::string(field_name) + " must not contain control characters"); + } +} + bool ContainsAsciiWhitespace(const std::string& value) { for (char character : value) { if (IsAsciiWhitespace(character)) { @@ -249,15 +261,14 @@ std::string NormalizeOptionalNonBlankString( return ""; } + if (reject_crlf) { + ValidateHeaderValue(*value, field_name); + } const std::string normalized = Trim(*value); if (normalized.empty()) { throw ValidationError(std::string(field_name) + " must not be blank"); } - if (reject_crlf && ContainsCrOrLf(normalized)) { - throw ValidationError(std::string(field_name) + " must not contain CR or LF"); - } - return normalized; } @@ -423,6 +434,8 @@ std::optional NormalizeLanguage(const std::optional& raw_ std::map NormalizeHeaders(const std::map& headers) { std::map normalized; for (const auto& [raw_name, raw_value] : headers) { + ValidateHeaderValue(raw_name, "headers"); + ValidateHeaderValue(raw_value, "headers"); const std::string name = Trim(raw_name); const std::string value = Trim(raw_value); if (name.empty()) { @@ -431,8 +444,10 @@ std::map NormalizeHeaders(const std::map(value.size(), 4096); + std::string safe; + std::size_t from = 0; + do { + safe.append(value, from, pos - from); + safe += kRedacted; + if (safe.size() > limit) { + return kRedacted; + } + from = pos + secret.size(); + pos = value.find(secret, from); + } while (pos != std::string::npos); + if (value.size() - from > limit - safe.size()) { + return kRedacted; + } + safe.append(value, from); + return safe; +} + +int HexValue(unsigned char byte) { + if (IsAsciiDigit(byte)) { + return byte - '0'; + } + const auto lower = ToLowerAscii(byte); + return lower >= 'a' && lower <= 'f' ? lower - 'a' + 10 : -1; +} + +std::string RedactErrorText(std::string value, const std::optional& key) { + if (!key || key->empty()) { + return value; + } + if (value.find('%') != std::string::npos) { + const auto encoded = UrlEncode(*key); + if (encoded != *key) { + value = MaskMatches(std::move(value), encoded); + } + std::string decoded; + for (std::size_t i = 0; i < value.size(); ++i) { + if (value[i] == '%' && value.size() - i >= 3) { + const int hi = HexValue(value[i + 1]), lo = HexValue(value[i + 2]); + if (hi >= 0 && lo >= 0) { + decoded.push_back(static_cast((hi << 4) | lo)); + i += 2; + continue; + } + } + decoded.push_back(value[i]); + } + if (decoded != value && decoded.find(*key) != std::string::npos) { + return kRedacted; + } + } + return MaskMatches(std::move(value), *key); +} + +std::string SafeErrorBody(const std::string& body, const std::optional& key) { + if (key && !key->empty() && body.find('\\') != std::string::npos) { + for (std::size_t start = body.find('"'); start != std::string::npos;) { + auto end = start + 1; + bool escaped = false; + for (; end < body.size() && body[end] != '"'; ++end) { + if (body[end] == '\\') { + escaped = true; + if (++end == body.size()) { + return kRedacted; + } + } + } + if (end == body.size()) { + return kRedacted; + } + if (escaped) { + try { + const auto token = ParseJson(body.substr(start, end - start + 1)); + if (RedactErrorText(token.string_value(), key) != token.string_value()) { + return kRedacted; + } + } catch (const SerializationError&) { + return kRedacted; + } + } + start = body.find('"', end + 1); + } + } + return RedactErrorText(body, key); +} + std::string JoinCsv(const std::vector& values) { std::ostringstream stream; for (std::size_t index = 0; index < values.size(); ++index) { @@ -671,6 +780,8 @@ Network ParseNetwork(const JsonValue::Object& object) { value.connection_type = ReadString(GetMember(object, {"connection_type"})); value.route = ReadString(GetMember(object, {"route"})); value.is_anycast = ReadBool(GetMember(object, {"is_anycast"})); + value.is_cdn = ReadBool(GetMember(object, {"is_cdn"})); + value.cdn_provider_name = ReadString(GetMember(object, {"cdn_provider_name"})); return value; } @@ -747,6 +858,17 @@ Security ParseSecurity(const JsonValue::Object& object) { value.is_spam = ReadBool(GetMember(object, {"is_spam"})); value.is_cloud_provider = ReadBool(GetMember(object, {"is_cloud_provider"})); value.cloud_provider_name = ReadString(GetMember(object, {"cloud_provider_name"})); + value.is_known_good_bot = ReadBool(GetMember(object, {"is_known_good_bot"})); + value.is_corporate_gateway = ReadBool(GetMember(object, {"is_corporate_gateway"})); + value.proxy_days_seen_30d = ReadNumber(GetMember(object, {"proxy_days_seen_30d"})); + value.proxy_days_seen_7d = ReadNumber(GetMember(object, {"proxy_days_seen_7d"})); + value.proxy_provider_count_30d = ReadNumber(GetMember(object, {"proxy_provider_count_30d"})); + value.bot_confidence_score = ReadNumber(GetMember(object, {"bot_confidence_score"})); + value.bot_operator_name = ReadString(GetMember(object, {"bot_operator_name"})); + value.bot_type = ReadString(GetMember(object, {"bot_type"})); + value.bot_last_seen = ReadString(GetMember(object, {"bot_last_seen"})); + value.corporate_gateway_type = ReadString(GetMember(object, {"corporate_gateway_type"})); + value.corporate_gateway_provider_name = ReadString(GetMember(object, {"corporate_gateway_provider_name"})); return value; } @@ -945,6 +1067,8 @@ std::string LanguageCode(Language language) { return "fa"; case Language::kPt: return "pt"; + case Language::kAr: + return "ar"; } return "en"; @@ -1045,8 +1169,9 @@ IpGeolocationResponse ParseIpGeolocationResponse(const std::string& body) { return ParseResponseObject(root.object_value()); } -std::vector ParseBulkLookupResults(const std::string& body) { - const JsonValue root = ParseJson(body); +std::vector ParseBulkLookupResults(const std::string& body, std::size_t max_items, + const std::optional& api_key) { + const JsonValue root = ParseJson(body, max_items); if (!root.is_array()) { throw SerializationError("Failed to deserialize bulk response: expected an array payload"); } @@ -1066,7 +1191,7 @@ std::vector ParseBulkLookupResults(const std::string& body) { has_error_key || (error_message.has_value() && !looks_like_success)) { BulkLookupResult result; result.error = BulkLookupError{ - error_message.value_or("bulk item returned an error without a parsable message")}; + RedactErrorText(error_message.value_or("bulk item returned an error without a parsable message"), api_key)}; results.push_back(std::move(result)); continue; } @@ -1084,19 +1209,20 @@ std::vector ParseBulkLookupResults(const std::string& body) { return results; } -ApiError ToApiError(int status_code, const std::string& body) { - std::string message = ExtractApiMessage(body); +ApiError ToApiError(int status_code, const std::string& body, const std::optional& api_key) { + std::string safe_body = SafeErrorBody(body, api_key); + std::string message = ExtractApiMessage(safe_body); if (message.empty()) { message = GenericApiErrorMessage(status_code); } - return ApiError(status_code, message, body); + return ApiError(status_code, RedactErrorText(std::move(message), api_key), std::move(safe_body)); } std::string ExtractApiMessage(const std::string& body) { const std::string trimmed = Trim(body); - if (trimmed.empty()) { - return ""; + if (trimmed.empty() || (trimmed.front() != '{' && trimmed.front() != '"')) { + return trimmed; } try { diff --git a/src/internal.hpp b/src/internal.hpp index c3ca7ce..561f13b 100644 --- a/src/internal.hpp +++ b/src/internal.hpp @@ -56,6 +56,9 @@ struct NormalizedBulkLookupRequest { ResponseFormat output = ResponseFormat::kJson; }; +long CurlTimeoutMilliseconds(std::chrono::milliseconds timeout); +bool NeedsHttp11ForCurl(unsigned int version); + struct HttpRequestData { std::string url; std::string method; @@ -146,8 +149,10 @@ ApiResponseMetadata ToMetadata( const std::map>& headers); IpGeolocationResponse ParseIpGeolocationResponse(const std::string& body); -std::vector ParseBulkLookupResults(const std::string& body); -ApiError ToApiError(int status_code, const std::string& body); +std::vector ParseBulkLookupResults(const std::string& body, std::size_t max_items, + const std::optional& api_key = std::nullopt); +ApiError ToApiError(int status_code, const std::string& body, + const std::optional& api_key = std::nullopt); std::string ExtractApiMessage(const std::string& body); std::string BuildBulkRequestBody(const std::vector& ips); std::string EscapeJsonString(const std::string& value); diff --git a/src/json.cpp b/src/json.cpp index 4948bab..ec3e14a 100644 --- a/src/json.cpp +++ b/src/json.cpp @@ -120,8 +120,6 @@ bool IsAsciiWhitespace(char value) { case '\n': case '\r': case '\t': - case '\f': - case '\v': return true; default: return false; @@ -172,7 +170,8 @@ double ParseLocaleIndependentDouble(std::string_view input) { class JsonParser { public: - explicit JsonParser(const std::string& input) : input_(input) {} + JsonParser(const std::string& input, std::size_t max_root_items) + : input_(input), max_root_items_(max_root_items) {} JsonValue Parse() { SkipWhitespace(); @@ -274,6 +273,9 @@ class JsonParser { while (true) { SkipWhitespace(); + if (depth == 0 && array.size() >= max_root_items_) { + Fail("bulk response contains more results than requested"); + } array.push_back(ParseValue(depth + 1)); SkipWhitespace(); if (Consume(']')) { @@ -294,6 +296,30 @@ class JsonParser { if (current == '"') { return result; } + const auto byte = static_cast(current); + if (byte < 0x20) { + Fail("unescaped control character in string"); + } + if (byte >= 0x80) { + const std::size_t start = index_ - 1; + if (byte < 0xc2 || byte > 0xf4) { + Fail("invalid UTF-8 in string"); + } + const unsigned length = byte < 0xe0 ? 2 : (byte < 0xf0 ? 3 : 4); + for (unsigned i = 1; i < length; ++i) { + if (AtEnd()) { + Fail("invalid UTF-8 in string"); + } + const auto next = static_cast(input_[index_++]); + if ((next & 0xc0) != 0x80 || + (i == 1 && ((byte == 0xe0 && next < 0xa0) || (byte == 0xed && next >= 0xa0) || + (byte == 0xf0 && next < 0x90) || (byte == 0xf4 && next >= 0x90)))) { + Fail("invalid UTF-8 in string"); + } + } + result.append(input_, start, length); + continue; + } if (current != '\\') { result.push_back(current); continue; @@ -511,13 +537,14 @@ class JsonParser { } const std::string& input_; + const std::size_t max_root_items_; std::size_t index_ = 0; }; } // namespace -JsonValue ParseJson(const std::string& input) { - JsonParser parser(input); +JsonValue ParseJson(const std::string& input, std::size_t max_root_items) { + JsonParser parser(input, max_root_items); return parser.Parse(); } diff --git a/src/json.hpp b/src/json.hpp index 022c6e0..35b8a29 100644 --- a/src/json.hpp +++ b/src/json.hpp @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include #include @@ -52,6 +54,7 @@ class JsonValue { Array array_value_; }; -JsonValue ParseJson(const std::string& input); +JsonValue ParseJson(const std::string& input, + std::size_t max_root_items = (std::numeric_limits::max)()); } // namespace ipgeolocation::internal diff --git a/tests/cpp_sdk_tests.cpp b/tests/cpp_sdk_tests.cpp index 849d197..8f445f0 100644 --- a/tests/cpp_sdk_tests.cpp +++ b/tests/cpp_sdk_tests.cpp @@ -1,4 +1,17 @@ +#if defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#else #include +#include +#include +#include +#include +#endif +#include #include #include #include @@ -9,12 +22,9 @@ #include #include #include -#include #include #include -#include #include -#include #include #include "ipgeolocation/ipgeolocation.hpp" @@ -154,10 +164,24 @@ class ScopedConcurrentTransportFactory { std::shared_ptr state_; }; -void WriteAll(int fd, const std::string& data) { +#if defined(_WIN32) +using TestSocket = SOCKET; +using SocketLength = int; +constexpr TestSocket kInvalidSocket = INVALID_SOCKET; +constexpr int kShutdownBoth = SD_BOTH; +void CloseSocket(TestSocket fd) { closesocket(fd); } +#else +using TestSocket = int; +using SocketLength = socklen_t; +constexpr TestSocket kInvalidSocket = -1; +constexpr int kShutdownBoth = SHUT_RDWR; +void CloseSocket(TestSocket fd) { close(fd); } +#endif + +void WriteAll(TestSocket fd, const std::string& data) { std::size_t total = 0; while (total < data.size()) { - const auto sent = send(fd, data.data() + total, data.size() - total, 0); + const auto sent = send(fd, data.data() + total, static_cast(std::min(data.size() - total, INT_MAX)), 0); if (sent <= 0) { throw std::runtime_error("failed to write loopback response"); } @@ -165,7 +189,7 @@ void WriteAll(int fd, const std::string& data) { } } -std::string ReadHttpRequest(int fd) { +std::string ReadHttpRequest(TestSocket fd) { std::string request; std::size_t expected_body_size = 0; bool saw_headers = false; @@ -201,14 +225,14 @@ std::string ReadHttpRequest(int fd) { class LoopbackServer { public: - explicit LoopbackServer(std::function handler) : handler_(std::move(handler)) { + explicit LoopbackServer(std::function handler) : handler_(std::move(handler)) { listen_fd_ = socket(AF_INET, SOCK_STREAM, 0); - if (listen_fd_ < 0) { + if (listen_fd_ == kInvalidSocket) { throw std::runtime_error("failed to create loopback socket"); } int reuse = 1; - static_cast(setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse))); + static_cast(setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast(&reuse), sizeof(reuse))); sockaddr_in address{}; #ifdef __APPLE__ @@ -219,28 +243,28 @@ class LoopbackServer { address.sin_port = 0; if (bind(listen_fd_, reinterpret_cast(&address), sizeof(address)) != 0) { - close(listen_fd_); + CloseSocket(listen_fd_); throw std::runtime_error(std::string("failed to bind loopback socket: ") + std::strerror(errno)); } - socklen_t address_length = sizeof(address); + SocketLength address_length = sizeof(address); if (getsockname(listen_fd_, reinterpret_cast(&address), &address_length) != 0) { - close(listen_fd_); + CloseSocket(listen_fd_); throw std::runtime_error("failed to inspect loopback socket"); } port_ = ntohs(address.sin_port); if (listen(listen_fd_, 1) != 0) { - close(listen_fd_); + CloseSocket(listen_fd_); throw std::runtime_error("failed to listen on loopback socket"); } thread_ = std::thread([this]() { sockaddr_in client_address{}; - socklen_t client_length = sizeof(client_address); - const int client_fd = accept(listen_fd_, reinterpret_cast(&client_address), &client_length); - if (client_fd < 0) { + SocketLength client_length = sizeof(client_address); + const TestSocket client_fd = accept(listen_fd_, reinterpret_cast(&client_address), &client_length); + if (client_fd == kInvalidSocket) { return; } @@ -249,18 +273,17 @@ class LoopbackServer { } catch (...) { } - shutdown(client_fd, SHUT_RDWR); - close(client_fd); + shutdown(client_fd, kShutdownBoth); + CloseSocket(client_fd); }); } ~LoopbackServer() { + shutdown(listen_fd_, kShutdownBoth); + CloseSocket(listen_fd_); if (thread_.joinable()) { thread_.join(); } - if (listen_fd_ >= 0) { - close(listen_fd_); - } } std::string url(const std::string& path = "/") const { @@ -268,10 +291,10 @@ class LoopbackServer { } private: - int listen_fd_ = -1; + TestSocket listen_fd_ = kInvalidSocket; int port_ = 0; std::thread thread_; - std::function handler_; + std::function handler_; }; void Expect(bool condition, const std::string& message) { @@ -1050,13 +1073,13 @@ void TestParseIpGeolocationResponseRejectsNonObjectPayload() { void TestParseBulkLookupRejectsInvalidShapes() { ExpectThrows( []() { - static_cast(ipgeolocation::internal::ParseBulkLookupResults("{}")); + static_cast(ipgeolocation::internal::ParseBulkLookupResults("{}", 50000)); }, "Failed to deserialize bulk response: expected an array payload"); ExpectThrows( []() { - static_cast(ipgeolocation::internal::ParseBulkLookupResults(R"([1])")); + static_cast(ipgeolocation::internal::ParseBulkLookupResults(R"([1])", 50000)); }, "Failed to deserialize bulk response: expected object items"); } @@ -1160,7 +1183,7 @@ void TestParseBulkLookupParsesDirectMessageErrors() { R"([ {"message":"Plain bulk failure"}, {"ip":"8.8.8.8"} - ])"); + ])", 50000); Expect(response.size() == 2, "bulk parser should return both items"); Expect(response[0].error.has_value() && response[0].error->message.has_value() && @@ -1177,7 +1200,7 @@ void TestParseBulkLookupUsesFallbackMessageForMalformedErrorItems() { {"error":{}}, {"error":null}, {"error":{"code":123}} - ])"); + ])", 50000); Expect(response.size() == 3, "bulk parser should keep malformed error items"); for (const auto& result : response) { @@ -1197,7 +1220,7 @@ void TestParseBulkLookupPrefersSuccessMarkersOverTopLevelMessage() { "message":"not-an-error", "location":{"country_name":"United States"} } - ])"); + ])", 50000); Expect(response.size() == 1, "bulk parser should keep success items with informational message fields"); Expect(response[0].data.has_value(), "success markers should win over a top-level message field"); @@ -1210,7 +1233,7 @@ void TestParseBulkLookupRejectsWrappedSuccessItems() { ExpectThrows( []() { static_cast(ipgeolocation::internal::ParseBulkLookupResults( - R"([{"data":{"ip":"8.8.8.8"}}])")); + R"([{"data":{"ip":"8.8.8.8"}}])", 50000)); }, "Failed to deserialize bulk response: unexpected wrapped success item"); } @@ -2097,13 +2120,13 @@ void TestJsonParserRejectsMoreInvalidForms() { "Failed to parse JSON: maximum nesting depth exceeded"); } -void TestCurlTransportCapturesHeadersAndTrimsHeaderNameWhitespace() { - LoopbackServer server([](int client_fd) { +void TestCurlTransportCapturesHeaders() { + LoopbackServer server([](TestSocket client_fd) { + ReadHttpRequest(client_fd); WriteAll( client_fd, "HTTP/1.1 200 OK\r\n" - ":\tignored\r\n" - "X-Test \t: value\r\n" + "X-Test: value\r\n" "X-Test: second\r\n" "X-Tab:\ttrimmed\r\n" "\r\n" @@ -2121,7 +2144,7 @@ void TestCurlTransportCapturesHeadersAndTrimsHeaderNameWhitespace() { Expect(response.status_code == 200, "loopback transport GET should succeed"); Expect(response.body == "ok", "loopback transport should read the response body"); Expect(response.headers.at("X-Test").size() == 2, "loopback transport should keep duplicate headers"); - Expect(response.headers.at("X-Test").front() == "value", "loopback transport should trim header name whitespace"); + Expect(response.headers.at("X-Test").front() == "value", "loopback transport should preserve the first header value"); Expect(response.headers.at("X-Tab").front() == "trimmed", "loopback transport should trim leading tab whitespace in header values"); Expect(response.headers.count("") == 0, @@ -2130,7 +2153,8 @@ void TestCurlTransportCapturesHeadersAndTrimsHeaderNameWhitespace() { void TestCurlTransportTimeoutAndSizeLimitErrors() { { - LoopbackServer timeout_server([](int client_fd) { + LoopbackServer timeout_server([](TestSocket client_fd) { + ReadHttpRequest(client_fd); std::this_thread::sleep_for(std::chrono::milliseconds(120)); WriteAll(client_fd, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"); }); @@ -2150,7 +2174,8 @@ void TestCurlTransportTimeoutAndSizeLimitErrors() { } { - LoopbackServer oversized_server([](int client_fd) { + LoopbackServer oversized_server([](TestSocket client_fd) { + ReadHttpRequest(client_fd); WriteAll(client_fd, "HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nhello"); std::this_thread::sleep_for(std::chrono::milliseconds(50)); WriteAll(client_fd, "world"); @@ -2171,7 +2196,8 @@ void TestCurlTransportTimeoutAndSizeLimitErrors() { } { - LoopbackServer first_chunk_oversized_server([](int client_fd) { + LoopbackServer first_chunk_oversized_server([](TestSocket client_fd) { + ReadHttpRequest(client_fd); const std::string body(65536, 'x'); WriteAll( client_fd, @@ -2195,7 +2221,7 @@ void TestCurlTransportTimeoutAndSizeLimitErrors() { void TestCurlTransportReusesConnectionAcrossSequentialRequests() { auto captured_requests = std::make_shared>(); - LoopbackServer server([captured_requests](int client_fd) { + LoopbackServer server([captured_requests](TestSocket client_fd) { captured_requests->push_back(ReadHttpRequest(client_fd)); WriteAll( client_fd, @@ -2309,7 +2335,7 @@ void TestCurlTransportSurfacesGenericLibcurlErrors() { void TestCurlTransportPostPathAndOutgoingHeaders() { auto captured_request = std::make_shared(); - LoopbackServer server([captured_request](int client_fd) { + LoopbackServer server([captured_request](TestSocket client_fd) { *captured_request = ReadHttpRequest(client_fd); WriteAll( client_fd, @@ -2379,8 +2405,273 @@ void TestBuildRequestsCoverHeaderReplacementAndBulkOrigin() { "bulk request builder should include the configured origin header"); } +void TestArabicRequestsAndBulkResults() { + using namespace ipgeolocation; + const std::string row = R"({"ip":"8.8.8.8","location":{"country_name":"الولايات المتحدة"},"country_metadata":{"languages":["en","ar"]}})"; + const std::string mixed = "[" + row + R"(,{"error":{"message":"invalid fixture/+"}}])"; + ScopedMockTransportFactory factory({{200, row, {}}, {200, mixed, {}}, {200, mixed, {}}}); + IpGeolocationClientConfig config; + config.api_key = "fixture/+"; + auto client = factory.CreateClient(config); + LookupIpGeolocationRequest single; + single.ip = "8.8.8.8"; + single.lang = Language::kAr; + auto one = client.LookupIpGeolocation(single); + Expect(one.data.location->country_name == "الولايات المتحدة", "Arabic single response"); + BulkLookupIpGeolocationRequest bulk; + bulk.ips = {"8.8.8.8", "bad.example", "8.8.8.8"}; + bulk.lang = Language::kAr; + const auto rows = client.BulkLookupIpGeolocation(bulk).data; + Expect(rows.size() == 2 && rows[0].data->location->country_name == one.data.location->country_name, + "allow fewer results and preserve Arabic data"); + Expect(rows[1].error->message == "invalid [REDACTED]", "mask the key in a typed bulk error"); + Expect(client.BulkLookupIpGeolocationRaw(bulk).data == mixed, "raw bulk returns the original response"); + for (const auto& request : factory.state()->requests) { + Expect(request.url.find("lang=ar") != std::string::npos, "single and bulk must send Arabic"); + Expect(request.url.find("apiKey=fixture%2F%2B") != std::string::npos, "send the configured key"); + } +} + +void TestRedactedApiErrors() { + using namespace ipgeolocation; + IpGeolocationClientConfig config; + config.api_key = "fixture/+"; + const std::string echoed = R"({"error":{"message":"use fixture/+ or fixture%2F%2B"}})"; + ScopedMockTransportFactory factory(std::vector(4, {401, echoed, {}})); + auto client = factory.CreateClient(config); + BulkLookupIpGeolocationRequest bulk; + bulk.ips = {"8.8.8.8"}; + const std::vector> calls = { + [&] { client.LookupIpGeolocation(); }, [&] { client.LookupIpGeolocationRaw(); }, + [&] { client.BulkLookupIpGeolocation(bulk); }, [&] { client.BulkLookupIpGeolocationRaw(bulk); } + }; + for (const auto& call : calls) { + try { + call(); + throw std::runtime_error("expected HTTP error"); + } catch (const ApiError& error) { + Expect(error.status_code() == 401, "keep HTTP error status"); + Expect(std::string(error.what()) == "use [REDACTED] or [REDACTED]", "redact both key forms"); + Expect(error.response_body() == R"({"error":{"message":"use [REDACTED] or [REDACTED]"}})", + "redact stored HTTP error body"); + } + } + for (const auto& body : {R"({"message":"failed","other":"fixt\u0075re/+"})", + R"({"detail":{"message":"fixture%2f%2B"}})", + R"({"message":"fixture\u00)", "unquoted %66ixture%2f%2b"}) { + const auto error = internal::ToApiError(403, body, config.api_key); + Expect(error.response_body() == "[REDACTED]", "discard unsafe encoded or incomplete diagnostics"); + } + for (const std::string broken : {"\"unfinished\\", R"("broken\q")"}) { + Expect(internal::ToApiError(502, broken, config.api_key).response_body() == "[REDACTED]", + "discard a quoted error that cannot be decoded safely"); + } + Expect(internal::ToApiError(502, "x" + std::string(4095, 'a'), std::string("x")).response_body() == "[REDACTED]", + "redaction stays bounded even when a short key is followed by a large suffix"); + const std::string benign = R"({"message":"try\nagain","path":"a%20b"})"; + Expect(internal::ToApiError(503, benign, config.api_key).response_body() == benign, + "preserve harmless escaped and percent-encoded text"); + Expect(internal::ToApiError(401, std::string(10000, 'x'), std::string("x")).response_body() == "[REDACTED]", + "many copies of a short key must not expand the diagnostic"); + const std::string success = R"({"ip":"fixture/+"})"; + ScopedMockTransportFactory success_factory({{200, success, {}}, {200, success, {}}}); + auto success_client = success_factory.CreateClient(config); + Expect(success_client.LookupIpGeolocation().data.ip == config.api_key, "keep successful typed data intact"); + Expect(success_client.LookupIpGeolocationRaw().data == success, "keep successful raw data intact"); +} + +void TestBulkResponseLimit() { + using namespace ipgeolocation; + BulkLookupIpGeolocationRequest bulk; + bulk.ips = {"8.8.8.8"}; + const std::string extra = R"([{},{"ignored":)" + std::string(200000, '['); + const std::string nested = R"([{"country_metadata":{"languages":["en","ar"]}}])"; + ScopedMockTransportFactory factory({{200, extra, {}}, {200, extra, {}}, {200, nested, {}}, {200, "[]", {}}}); + IpGeolocationClientConfig config; + config.api_key = "fixture"; + auto client = factory.CreateClient(config); + ExpectThrows([&] { client.BulkLookupIpGeolocation(bulk); }, + "Failed to parse JSON: bulk response contains more results than requested"); + Expect(client.BulkLookupIpGeolocationRaw(bulk).data == extra, "raw response remains byte-for-byte"); + Expect(client.BulkLookupIpGeolocation(bulk).data[0].data->country_metadata->languages->size() == 2, + "nested lists have their own lengths"); + Expect(client.BulkLookupIpGeolocation(bulk).data.empty(), "an empty response stays empty"); + + bulk.ips.assign(50000, "8.8.8.8"); + bulk.ips.back() = "1.1.1.1"; + std::string response = "["; + for (std::size_t i = 0; i < bulk.ips.size(); ++i) { + if (i > 0) response += ','; + response += "{\"ip\":\"" + bulk.ips[i] + "\"}"; + } + response += ']'; + ScopedMockTransportFactory full({{200, std::move(response), {}}}); + const auto rows = full.CreateClient(config).BulkLookupIpGeolocation(bulk).data; + Expect(rows.size() == bulk.ips.size(), "accept a full 50,000-result batch"); + for (std::size_t i = 0; i < rows.size(); ++i) { + Expect(rows[i].data->ip == bulk.ips[i], "keep all rows in request order, including duplicates"); + } +} + +void TestExistingModelBindings() { + ipgeolocation::Network network{"hosting", "8.8.8.0/24", true}; + auto& [connection_type, route, anycast] = network; + route = "8.8.0.0/16"; + Expect(connection_type == "hosting" && network.route == route && anycast == true, "existing Network bindings"); + const auto& read_only = network; + const auto& [read_type, read_route, read_anycast] = read_only; + Expect(&read_route == &network.route && read_type == connection_type && read_anycast == anycast, + "const reference bindings alias the original fields"); + + ipgeolocation::Security security; + security.threat_score = 51; + security.cloud_provider_name = "Example"; + const auto [score, tor, proxy, providers, proxy_score, proxy_seen, residential, vpn, vpn_names, + vpn_score, vpn_seen, relay, relay_name, anonymous, attacker, bot, spam, cloud, cloud_name] = security; + Expect(score == 51 && !proxy && cloud_name == "Example", "existing Security bindings keep their order"); +} + +void TestOptionalNetworkAndSecurityFields() { + using namespace ipgeolocation::internal; + const auto data = ParseIpGeolocationResponse(R"({ + "network":{"is_cdn":false,"cdn_provider_name":"Example CDN"}, + "security":{"proxy_days_seen_30d":20,"proxy_days_seen_7d":0,"proxy_provider_count_30d":2, + "bot_confidence_score":91.5,"bot_operator_name":"Example Bot","bot_type":"crawler", + "is_known_good_bot":true,"bot_last_seen":"2026-10-01","is_corporate_gateway":false, + "corporate_gateway_type":"secure_web_gateway","corporate_gateway_provider_name":"Gateway"} + })"); + Expect(data.network->is_cdn == false && data.network->cdn_provider_name == "Example CDN", "CDN details"); + const auto& security = data.security.value(); + Expect(security.proxy_days_seen_30d == 20 && security.proxy_days_seen_7d == 0 && + security.proxy_provider_count_30d == 2, "proxy details"); + Expect(security.bot_confidence_score == 91.5 && security.is_known_good_bot == true && + security.bot_operator_name == "Example Bot" && security.bot_type == "crawler" && + security.bot_last_seen == "2026-10-01", "bot details"); + Expect(security.is_corporate_gateway == false && security.corporate_gateway_type == "secure_web_gateway" && + security.corporate_gateway_provider_name == "Gateway", "corporate gateway details"); + const auto missing = ParseIpGeolocationResponse(R"({"network":{},"security":{"bot_last_seen":null}})"); + Expect(!missing.network->is_cdn && !missing.security->proxy_days_seen_7d && !missing.security->bot_last_seen, + "fields omitted by the API stay empty"); +} + +void TestTypedJsonText() { + using namespace ipgeolocation; + const std::string unicode = "عربي 🌍" "\xF4\x8F\xBF\xBF"; + Expect(internal::ParseJson("\"" + unicode + "\"").string_value() == unicode, "valid UTF-8 is preserved"); + for (const std::string invalid : {"\x80", "\xc0\xaf", "\xc2", "\xe0\x9f\x80", "\xed\xa0\x80", + "\xe2\x28\xa1", "\xf0\x8f\xbf\xbf", "\xf4\x90\x80\x80", "\xf5"}) { + for (const auto& json : {"{\"ip\":\"" + invalid + "\"}", "{\"" + invalid + "\":0}"}) { + ExpectThrows([&] { internal::ParseIpGeolocationResponse(json); }, + "Failed to parse JSON: invalid UTF-8 in string"); + } + } + for (int code = 0; code < 0x20; ++code) { + const std::string body = "{\"ip\":\"a" + std::string(1, static_cast(code)) + "b\"}"; + ExpectThrows([&] { internal::ParseIpGeolocationResponse(body); }, + "Failed to parse JSON: unescaped control character in string"); + } + ExpectThrows([] { internal::ParseJson("\"\xe2\x82"); }, + "Failed to parse JSON: invalid UTF-8 in string"); + for (const auto* malformed : {R"("\ud800\)", R"("\ud800\x")", R"("\ud800\u0000")"}) { + ExpectThrows([&] { internal::ParseJson(malformed); }, + "Failed to parse JSON: invalid unicode surrogate pair"); + } + const std::string raw = "{\"ip\":\"\xff\n\"}"; + ScopedMockTransportFactory factory({{200, raw, {}}}); + IpGeolocationClientConfig config; + config.api_key = "fixture"; + Expect(factory.CreateClient(config).LookupIpGeolocationRaw().data == raw, "raw response preserves invalid bytes"); +} + +void TestHeaderValidationBeforeSending() { + using namespace ipgeolocation; + LookupIpGeolocationRequest single; + BulkLookupIpGeolocationRequest bulk; + bulk.ips = {"8.8.8.8"}; + const auto rejected = [&](const std::string& message) { + ExpectThrows([&] { internal::NormalizeLookupRequest(single); }, message); + ExpectThrows([&] { internal::NormalizeBulkLookupRequest(bulk); }, message); + }; + for (const std::string name : {"X:Injected", "X Test", "X\tTest", "X/Name"}) { + single.headers = bulk.headers = {{name, "value"}}; + rejected("invalid HTTP header name"); + } + for (const auto ch : {0, 11, 12, 31, 127}) { + const auto control = std::string(1, static_cast(ch)); + single.headers = bulk.headers = {{"X-Test" + control, "value"}}; + rejected("headers must not contain control characters"); + single.headers = bulk.headers = {{"X-Test", control + "value"}}; + rejected("headers must not contain control characters"); + single.headers = bulk.headers = {}; + single.user_agent = bulk.user_agent = "Agent" + control; + rejected("userAgent must not contain control characters"); + single.user_agent.reset(); + bulk.user_agent.reset(); + } + single.user_agent = bulk.user_agent = "\r\nAgent"; + rejected("userAgent must not contain CR or LF"); + single.user_agent.reset(); + single.headers = {{" X-Test_123 ", " \tfirst\tsecond "}}; + Expect(internal::NormalizeLookupRequest(single).headers.at("X-Test_123") == "first\tsecond", + "valid names, surrounding whitespace and a tab in a value still work"); +} + +void TestIncompleteHttpResponses() { + using namespace ipgeolocation; + const auto check_http_error = [](int status, const std::string& body, std::size_t limit) { + LoopbackServer server([&](TestSocket fd) { + ReadHttpRequest(fd); + WriteAll(fd, "HTTP/1.1 " + std::to_string(status) + " Error\r\nContent-Length: 30\r\n\r\n" + body); + }); + IpGeolocationClientConfig config; + config.base_url = server.url(); + config.api_key = "fixture"; + config.max_response_body_chars = limit; + try { + IpGeolocationClient(config).LookupIpGeolocationRaw(); + throw std::runtime_error("expected an HTTP error"); + } catch (const ApiError& error) { + Expect(error.status_code() == status && error.response_body().empty(), + "keep the status and discard the incomplete HTTP error body"); + } + }; + check_http_error(429, std::string(30, 'a'), 4); + check_http_error(503, "fixture", 32); + check_http_error(302, "moved", 32); + for (const std::string status : {"200 OK", "100 Continue"}) { + LoopbackServer server([&](TestSocket fd) { + ReadHttpRequest(fd); + WriteAll(fd, "HTTP/1.1 " + status + "\r\nContent-Length: 12\r\n\r\nx"); + }); + IpGeolocationClientConfig config; + config.api_key = "fixture"; + config.base_url = server.url(); + try { + IpGeolocationClient(config).LookupIpGeolocationRaw(); + throw std::runtime_error("expected a transport error"); + } catch (const TransportError&) { + } + } +} + +void TestCurlTimeoutAndVersionBoundaries() { + using namespace ipgeolocation::internal; + using std::chrono::milliseconds; + Expect(CurlTimeoutMilliseconds(milliseconds(1)) == 1, "small timeout remains positive"); + Expect(CurlTimeoutMilliseconds(milliseconds(1500)) == 1500, "ordinary timeout is exact"); + Expect(CurlTimeoutMilliseconds(milliseconds::max()) == LONG_MAX, "largest timeout saturates"); + Expect(CurlTimeoutMilliseconds(milliseconds(4294967296LL)) == std::min(4294967296LL, static_cast(LONG_MAX)), + "large Windows timeout cannot become zero"); + for (const auto affected : {0x080c00, 0x080e02, 0x080f00, 0x081001, 0x081100, 0x081401, 0x081500, 0x081600}) { + Expect(NeedsHttp11ForCurl(affected), "use HTTP/1.1 with affected libcurl"); + } + for (const auto patched : {0x080b01, 0x080e03, 0x081002, 0x081402, 0x081700, 0x081701}) { + Expect(!NeedsHttp11ForCurl(patched), "keep protocol negotiation on unaffected releases"); + } +} + void TestDefaultUserAgentIsStable() { - Expect(ipgeolocation::IpGeolocationClient::DefaultUserAgent() == "ipgeolocation-cpp-sdk/1.0.0", + Expect(ipgeolocation::IpGeolocationClient::DefaultUserAgent() == "ipgeolocation-cpp-sdk/1.0.1", "default user agent should match version"); } @@ -2401,6 +2692,15 @@ void TestClosedClientRejectsRequestsBeforeTransport() { } // namespace int main() { +#if defined(_WIN32) + WSADATA data{}; + if (WSAStartup(MAKEWORD(2, 2), &data) != 0) { + return 1; + } + std::atexit([] { WSACleanup(); }); +#else + std::signal(SIGPIPE, SIG_IGN); +#endif const std::vector>> tests = { {"TestConfigNormalizesRequestOriginAndBaseUrl", TestConfigNormalizesRequestOriginAndBaseUrl}, {"TestConfigRejectsRequestOriginPath", TestConfigRejectsRequestOriginPath}, @@ -2464,8 +2764,8 @@ int main() { {"TestJsonParserCoversNullEscapesExponentAndFailures", TestJsonParserCoversNullEscapesExponentAndFailures}, {"TestJsonParserUsesLocaleIndependentNumberParsing", TestJsonParserUsesLocaleIndependentNumberParsing}, {"TestJsonParserRejectsMoreInvalidForms", TestJsonParserRejectsMoreInvalidForms}, - {"TestCurlTransportCapturesHeadersAndTrimsHeaderNameWhitespace", - TestCurlTransportCapturesHeadersAndTrimsHeaderNameWhitespace}, + {"TestCurlTransportCapturesHeaders", + TestCurlTransportCapturesHeaders}, {"TestCurlTransportTimeoutAndSizeLimitErrors", TestCurlTransportTimeoutAndSizeLimitErrors}, {"TestCurlTransportReusesConnectionAcrossSequentialRequests", TestCurlTransportReusesConnectionAcrossSequentialRequests}, @@ -2473,6 +2773,15 @@ int main() { {"TestCurlTransportCapsIdleHandlePool", TestCurlTransportCapsIdleHandlePool}, {"TestCurlTransportSurfacesGenericLibcurlErrors", TestCurlTransportSurfacesGenericLibcurlErrors}, {"TestCurlTransportPostPathAndOutgoingHeaders", TestCurlTransportPostPathAndOutgoingHeaders}, + {"TestArabicRequestsAndBulkResults", TestArabicRequestsAndBulkResults}, + {"TestRedactedApiErrors", TestRedactedApiErrors}, + {"TestBulkResponseLimit", TestBulkResponseLimit}, + {"TestExistingModelBindings", TestExistingModelBindings}, + {"TestOptionalNetworkAndSecurityFields", TestOptionalNetworkAndSecurityFields}, + {"TestTypedJsonText", TestTypedJsonText}, + {"TestHeaderValidationBeforeSending", TestHeaderValidationBeforeSending}, + {"TestIncompleteHttpResponses", TestIncompleteHttpResponses}, + {"TestCurlTimeoutAndVersionBoundaries", TestCurlTimeoutAndVersionBoundaries}, {"TestDefaultUserAgentIsStable", TestDefaultUserAgentIsStable}, {"TestClosedClientRejectsRequestsBeforeTransport", TestClosedClientRejectsRequestsBeforeTransport}, }; diff --git a/tests/live_field_parity_tests.cpp b/tests/live_field_parity_tests.cpp index 549ee58..348f8b6 100644 --- a/tests/live_field_parity_tests.cpp +++ b/tests/live_field_parity_tests.cpp @@ -95,6 +95,8 @@ JsonValue ToJsonValue(const ipgeolocation::Network& value) { AddOptionalField(object, "connection_type", value.connection_type); AddOptionalField(object, "route", value.route); AddOptionalField(object, "is_anycast", value.is_anycast); + AddOptionalField(object, "is_cdn", value.is_cdn); + AddOptionalField(object, "cdn_provider_name", value.cdn_provider_name); return JsonValue::ObjectValue(std::move(object)); } @@ -171,6 +173,17 @@ JsonValue ToJsonValue(const ipgeolocation::Security& value) { AddOptionalField(object, "is_spam", value.is_spam); AddOptionalField(object, "is_cloud_provider", value.is_cloud_provider); AddOptionalField(object, "cloud_provider_name", value.cloud_provider_name); + AddOptionalField(object, "is_known_good_bot", value.is_known_good_bot); + AddOptionalField(object, "is_corporate_gateway", value.is_corporate_gateway); + AddOptionalField(object, "proxy_days_seen_30d", value.proxy_days_seen_30d); + AddOptionalField(object, "proxy_days_seen_7d", value.proxy_days_seen_7d); + AddOptionalField(object, "proxy_provider_count_30d", value.proxy_provider_count_30d); + AddOptionalField(object, "bot_confidence_score", value.bot_confidence_score); + AddOptionalField(object, "bot_operator_name", value.bot_operator_name); + AddOptionalField(object, "bot_type", value.bot_type); + AddOptionalField(object, "bot_last_seen", value.bot_last_seen); + AddOptionalField(object, "corporate_gateway_type", value.corporate_gateway_type); + AddOptionalField(object, "corporate_gateway_provider_name", value.corporate_gateway_provider_name); return JsonValue::ObjectValue(std::move(object)); } diff --git a/tests/live_integration_tests.cpp b/tests/live_integration_tests.cpp index 1a48eab..3abfa8b 100644 --- a/tests/live_integration_tests.cpp +++ b/tests/live_integration_tests.cpp @@ -77,22 +77,6 @@ void TestFreePlanIncludeStarReturnsBaseResponseOnly(const std::string& free_key) Expect(!response.data.user_agent.has_value(), "free-plan include=* should not unlock user-agent parsing"); } -void TestFreePlanBulkIsRejected(const std::string& free_key) { - ipgeolocation::IpGeolocationClient client(ApiKeyConfig(free_key)); - - ipgeolocation::BulkLookupIpGeolocationRequest request; - request.ips = {"8.8.8.8"}; - - try { - static_cast(client.BulkLookupIpGeolocation(request)); - } catch (const ipgeolocation::ApiError& error) { - Expect(error.status_code() == 401, "free-plan bulk should return status 401"); - return; - } - - throw std::runtime_error("expected free-plan bulk rejection"); -} - void TestPaidPlanSecurityAndAbuseWorks(const std::string& paid_key) { ipgeolocation::IpGeolocationClient client(ApiKeyConfig(paid_key)); @@ -187,6 +171,25 @@ void TestPaidPlanRawMethodsReturnXmlWhenRequested(const std::string& paid_key) { Expect(bulk.data.find('<') != std::string::npos, "raw bulk XML lookup should return XML"); } +void TestPaidArabicLookups(const std::string& paid_key) { + ipgeolocation::IpGeolocationClient client(ApiKeyConfig(paid_key)); + ipgeolocation::LookupIpGeolocationRequest single; + single.ip = "8.8.8.8"; + single.lang = ipgeolocation::Language::kAr; + const auto result = client.LookupIpGeolocation(single); + Expect(result.data.location && result.data.location->country_name && + result.data.location->country_name->find("الولايات") != std::string::npos, + "single lookup should return the country name in Arabic"); + + ipgeolocation::BulkLookupIpGeolocationRequest bulk; + bulk.ips = {*single.ip}; + bulk.lang = single.lang; + const auto rows = client.BulkLookupIpGeolocation(bulk).data; + Expect(rows.size() == 1 && rows[0].data && rows[0].data->location && + rows[0].data->location->country_name == result.data.location->country_name, + "bulk lookup should return the same Arabic country name"); +} + } // namespace int main() { @@ -207,7 +210,7 @@ int main() { {"TestFreePlanSecurityModuleIsRejected", [free_key]() { TestFreePlanSecurityModuleIsRejected(*free_key); }}, {"TestFreePlanIncludeStarReturnsBaseResponseOnly", [free_key]() { TestFreePlanIncludeStarReturnsBaseResponseOnly(*free_key); }}, - {"TestFreePlanBulkIsRejected", [free_key]() { TestFreePlanBulkIsRejected(*free_key); }}, + {"TestPaidArabicLookups", [paid_key]() { TestPaidArabicLookups(*paid_key); }}, {"TestPaidPlanSecurityAndAbuseWorks", [paid_key]() { TestPaidPlanSecurityAndAbuseWorks(*paid_key); }}, {"TestPaidPlanBulkMixedReturnsSuccessAndErrorItems", [paid_key]() { TestPaidPlanBulkMixedReturnsSuccessAndErrorItems(*paid_key); }},