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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion libs/visor_http_client/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ find_package(CURL REQUIRED)
find_package(uvw REQUIRED)
find_package(httplib REQUIRED)
find_package(Catch2 REQUIRED)
find_package(nlohmann_json REQUIRED)

add_library(VisorHttpClient STATIC HttpClient.cpp HttpCheck.cpp)
# Namespaced alias for consistency with the other libs (Visor::Lib::Dns, Visor::Lib::Tcp, ...)
# and cleaner downstream consumption.
add_library(Visor::Lib::Http ALIAS VisorHttpClient)
target_include_directories(VisorHttpClient PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(VisorHttpClient PUBLIC CURL::libcurl uvw::uvw)
target_link_libraries(VisorHttpClient PUBLIC CURL::libcurl uvw::uvw PRIVATE nlohmann_json::nlohmann_json)

add_executable(unit-tests-visor-http-client test_http_client.cpp)
target_link_libraries(unit-tests-visor-http-client PRIVATE VisorHttpClient Catch2::Catch2WithMain httplib::httplib)
Expand Down
167 changes: 166 additions & 1 deletion libs/visor_http_client/HttpCheck.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
#include "HttpCheck.h"
#include <curl/curl.h> // curl_getdate (cpp only — the header stays curl-free)
#include <algorithm>
#include <cctype>
#include <curl/curl.h> // curl_getdate, CURL_HTTP_VERSION_* (cpp only — the header stays curl-free)
#include <nlohmann/json.hpp> // JSON pointer parsing (cpp only — the header stays nlohmann-free)
#include <stdexcept>

namespace visor::http {
Expand Down Expand Up @@ -100,4 +103,166 @@ uint64_t parse_cert_expire_date(const std::string &date_str)
time_t t = curl_getdate(date_str.c_str(), nullptr);
return t > 0 ? static_cast<uint64_t>(t) : 0;
}

JsonPointerCheck JsonPointerCheck::compile(const std::string &ptr, const std::string &equals, bool equals_set)
{
JsonPointerCheck c;
try {
(void)nlohmann::json::json_pointer(ptr); // validate RFC 6901 syntax
} catch (const std::exception &) {
throw std::invalid_argument("json_path is not a valid JSON Pointer (RFC 6901): '" + ptr + "'");
}
c._pointer = ptr;
c._has_expected = equals_set;
c._expected = equals;
c._configured = true;
return c;
}

bool JsonPointerCheck::configured() const
{
return _configured;
}

bool JsonPointerCheck::matches(const std::string &body) const
{
nlohmann::json doc = nlohmann::json::parse(body, nullptr, false); // no exceptions
if (doc.is_discarded()) {
return false; // not valid JSON
}
nlohmann::json::json_pointer p(_pointer);
try {
if (!doc.contains(p)) {
return false; // pointer does not resolve
}
if (!_has_expected) {
return true; // presence-only
}
const nlohmann::json &v = doc.at(p);
std::string actual = v.is_string() ? v.get<std::string>() : v.dump(); // compact text for non-strings
return actual == _expected;
} catch (const nlohmann::json::exception &) {
// defensive: nlohmann 3.12's contains()/at() did not throw on a deeply-missing parent in
// observed testing, but guard against it anyway since it is not guaranteed by the API.
return false;
}
}

BodyNegativeCheck BodyNegativeCheck::compile(const std::string &not_substr, const std::string &not_regex_pattern)
{
BodyNegativeCheck c;
c.not_substring = not_substr;
if (!not_regex_pattern.empty()) {
try {
c.not_regex.emplace(not_regex_pattern, std::regex::ECMAScript);
} catch (const std::regex_error &) {
// never quote the pattern — it can embed secrets
throw std::invalid_argument("body_not_matches_regex is not a valid ECMAScript regular expression");
}
}
return c;
}

bool BodyNegativeCheck::matches(const std::string &body) const
{
if (!not_substring.empty() && body.find(not_substring) != std::string::npos) {
return false;
}
if (not_regex.has_value() && std::regex_search(body, *not_regex)) {
return false;
}
return true;
}

bool iequals_ascii(const std::string &a, const std::string &b)
{
if (a.size() != b.size()) {
return false;
}
for (size_t i = 0; i < a.size(); ++i) {
if (std::tolower(static_cast<unsigned char>(a[i])) != std::tolower(static_cast<unsigned char>(b[i]))) {
return false;
}
}
return true;
}

HeaderMatchers HeaderMatchers::compile(const std::vector<std::pair<std::string, std::string>> &fail_if_matches,
const std::vector<std::pair<std::string, std::string>> &fail_if_not_matches)
{
HeaderMatchers m;
auto build = [](const std::vector<std::pair<std::string, std::string>> &src, std::vector<HeaderMatcher> &dst) {
for (const auto &[name, pat] : src) {
try {
dst.push_back(HeaderMatcher{name, std::regex(pat, std::regex::ECMAScript)});
} catch (const std::regex_error &) {
// never quote the pattern — it can embed secrets; name the header instead
throw std::invalid_argument("header value_regex is not a valid ECMAScript regular expression (header '" + name + "')");
}
}
};
build(fail_if_matches, m._fail_if_matches);
build(fail_if_not_matches, m._fail_if_not_matches);
m._configured = !m._fail_if_matches.empty() || !m._fail_if_not_matches.empty();
return m;
}

bool HeaderMatchers::configured() const
{
return _configured;
}

bool HeaderMatchers::has_forbidden_rules() const
{
return !_fail_if_matches.empty();
}

bool HeaderMatchers::matches(const std::vector<std::pair<std::string, std::string>> &headers) const
{
for (const auto &hm : _fail_if_matches) {
for (const auto &[hn, hv] : headers) {
if (iequals_ascii(hn, hm.name) && std::regex_search(hv, hm.value_regex)) {
return false; // a forbidden header matched
}
}
}
for (const auto &hm : _fail_if_not_matches) {
bool any = false;
for (const auto &[hn, hv] : headers) {
if (iequals_ascii(hn, hm.name) && std::regex_search(hv, hm.value_regex)) {
any = true;
break;
}
}
if (!any) {
return false; // required header/value not present
}
}
return true;
}

uint64_t parse_http_date(const std::string &date_str)
{
if (date_str.empty()) {
return 0;
}
time_t t = curl_getdate(date_str.c_str(), nullptr);
return t > 0 ? static_cast<uint64_t>(t) : 0;
}

std::string http_version_name(long v)
{
switch (v) {
case CURL_HTTP_VERSION_1_0:
return "1.0";
case CURL_HTTP_VERSION_1_1:
return "1.1";
case CURL_HTTP_VERSION_2_0:
return "2";
case CURL_HTTP_VERSION_3:
return "3";
default:
return "";
}
}
}
65 changes: 65 additions & 0 deletions libs/visor_http_client/HttpCheck.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#include <optional>
#include <regex>
#include <string>
#include <utility>
#include <vector>

namespace visor::http {
Expand Down Expand Up @@ -35,4 +36,68 @@ struct BodyCheck {
// Parse a curl CERTINFO "Expire date:" value, e.g. "Aug 15 12:00:00 2026 GMT", to unix epoch.
// Returns 0 on parse failure. (Pure string->epoch; the CERTINFO iteration lives in HttpClient.)
uint64_t parse_cert_expire_date(const std::string &date_str);

// RFC 6901 JSON Pointer assertion over a JSON body.
class JsonPointerCheck
{
public:
JsonPointerCheck() = default; // not configured; configured()==false
// ptr: RFC 6901 pointer (validated; throws std::invalid_argument if malformed).
// equals_set=false => presence-only (pointer must resolve). equals_set=true => value's
// string form must equal `equals`.
static JsonPointerCheck compile(const std::string &ptr, const std::string &equals, bool equals_set);
bool configured() const;
bool matches(const std::string &body) const; // true = PASS
private:
std::string _pointer;
bool _has_expected{false};
std::string _expected;
bool _configured{false};
};

// Inverse body assertions: body must NOT contain `substring` and must NOT match `regex`.
struct BodyNegativeCheck {
std::string not_substring; // empty => not checked
std::optional<std::regex> not_regex; // nullopt => not checked
bool configured() const { return !not_substring.empty() || not_regex.has_value(); }
static BodyNegativeCheck compile(const std::string &not_substr, const std::string &not_regex_pattern);
bool matches(const std::string &body) const; // true = PASS (neither negative hit)
};

// Response-header assertions (fail_if_header_matches / fail_if_header_not_matches).
struct HeaderMatcher {
std::string name; // case-insensitive header name
std::regex value_regex; // compiled ECMAScript
};
class HeaderMatchers
{
public:
// Each pair is (name, value_regex_pattern). compile throws std::invalid_argument on a bad
// pattern (never quoting it). fail_if_matches: PASS unless some header `name` value matches.
// fail_if_not_matches: PASS only if some header `name` value matches.
static HeaderMatchers compile(const std::vector<std::pair<std::string, std::string>> &fail_if_matches,
const std::vector<std::pair<std::string, std::string>> &fail_if_not_matches);
bool configured() const;
// True when any fail_if_matches (forbidden-header) rule is configured. A forbidden-header PASS is
// only "no match seen in the captured headers", so it cannot be trusted when capture truncated;
// a required-header (fail_if_not_matches) PASS is a positive presence proof that truncation
// cannot invalidate. The probe uses this to decide whether to fail-safe on truncation.
bool has_forbidden_rules() const;
// headers: response headers as (name,value); name compared case-insensitively.
bool matches(const std::vector<std::pair<std::string, std::string>> &headers) const; // true = PASS
private:
std::vector<HeaderMatcher> _fail_if_matches;
std::vector<HeaderMatcher> _fail_if_not_matches;
bool _configured{false};
};

// Parse an HTTP-date (Last-Modified) to unix epoch via curl_getdate; 0 on failure/empty.
uint64_t parse_http_date(const std::string &date_str);

// Map CURLINFO_HTTP_VERSION (CURL_HTTP_VERSION_*) to "1.0"/"1.1"/"2"/"3"/"" (unknown).
std::string http_version_name(long curl_http_version);

// Portable ASCII case-insensitive equality (no strcasecmp — MSVC). Used for header-name matching
// here and by the probe's Last-Modified lookup (Task 5).
bool iequals_ascii(const std::string &a, const std::string &b);
}
Loading
Loading