From 74ce782ab4b14fa9120bc85d96f691ee6be75ca2 Mon Sep 17 00:00:00 2001 From: Mario Limonciello Date: Fri, 10 Jul 2026 00:22:13 -0500 Subject: [PATCH 1/5] fix(security): restrict libcurl redirect protocols to HTTPS only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes SWSPLAT-24200 by adding security restrictions to all CURLOPT_FOLLOWLOCATION usage in HttpClient to prevent SSRF, protocol downgrade attacks, and redirect-loop DoS. Changes: - Add CURLOPT_MAXREDIRS=5L to limit redirect chains - Add CURLOPT_PROTOCOLS_STR="https" to restrict initial protocol - Add CURLOPT_REDIR_PROTOCOLS_STR="https" to restrict redirect protocols This prevents: - HTTPS→HTTP downgrade attacks during backend binary downloads - FTP/FILE protocol SSRF to internal network endpoints - Infinite redirect loops causing DoS Applied to all three locations that use CURLOPT_FOLLOWLOCATION: 1. HttpClient::get() - line 375 2. HttpClient::download_attempt() - line 683 3. HttpClient::download_attempt() HEAD request - line 842 Co-Authored-By: Claude --- src/cpp/server/utils/http_client.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/cpp/server/utils/http_client.cpp b/src/cpp/server/utils/http_client.cpp index b12288fa4..ec38e997f 100644 --- a/src/cpp/server/utils/http_client.cpp +++ b/src/cpp/server/utils/http_client.cpp @@ -373,6 +373,9 @@ HttpResponse HttpClient::get(const std::string& url, curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_body); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); + curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); curl_easy_setopt(curl, CURLOPT_TIMEOUT, timeout_seconds > 0 ? timeout_seconds : default_timeout_seconds_.load()); curl_easy_setopt(curl, CURLOPT_USERAGENT, "lemon.cpp/1.0"); @@ -678,6 +681,9 @@ DownloadResult HttpClient::download_attempt(const std::string& url, curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_file_callback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); + curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L); curl_easy_setopt(curl, CURLOPT_USERAGENT, "lemon.cpp/1.0"); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, static_cast(options.connect_timeout)); @@ -834,6 +840,9 @@ DownloadResult HttpClient::download_attempt(const std::string& url, curl_easy_setopt(head_curl, CURLOPT_URL, url.c_str()); curl_easy_setopt(head_curl, CURLOPT_NOBODY, 1L); // HEAD request curl_easy_setopt(head_curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(head_curl, CURLOPT_MAXREDIRS, 5L); + curl_easy_setopt(head_curl, CURLOPT_PROTOCOLS_STR, "https"); + curl_easy_setopt(head_curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); curl_easy_setopt(head_curl, CURLOPT_TIMEOUT, 30L); // Add headers From a36eb399cfe21128b292dfe901e2b782bbada7c8 Mon Sep 17 00:00:00 2001 From: Mario Limonciello Date: Sat, 11 Jul 2026 13:00:35 -0500 Subject: [PATCH 2/5] fix(security): harden HTTP redirects by trust boundary Fixes SWSPLAT-24200 by splitting HttpClient security restrictions by trust boundary so loopback backend RPC keeps working over http. Policies (HttpSecurityPolicy): - External hosts (HF, GitHub, release CDNs): https-only for the initial request and every redirect hop, MAXREDIRS=5. - Lemonade-managed loopback backends: http only, redirects disabled entirely. - Opt-in plaintext endpoints (cloud allow_insecure_http): http+https, bounded redirects. A shared helper (apply_http_security_policy) applies the curl options and checks each setopt return value, so a restriction that fails to apply fails closed. Added test for HTTPS-to-HTTP protocol downgrade rejection under the external policy. --- CMakeLists.txt | 7 + .../lemon/backends/cloud/cloud_server.h | 3 +- src/cpp/include/lemon/utils/http_client.h | 26 ++- .../backends/acestep/acestep_server.cpp | 6 +- .../server/backends/cloud/cloud_server.cpp | 15 +- src/cpp/server/backends/vllm/vllm_server.cpp | 3 +- src/cpp/server/telemetry.cpp | 3 +- src/cpp/server/utils/http_client.cpp | 66 ++++-- src/cpp/server/wrapped_server.cpp | 3 +- test/cpp/test_http_client_security.cpp | 219 ++++++++++++++++++ 10 files changed, 323 insertions(+), 28 deletions(-) create mode 100644 test/cpp/test_http_client_security.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index aec363e87..1471712a3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2226,3 +2226,10 @@ if(EXISTS "${_VLLM_CDNA_TEST_SRC}") target_link_libraries(test_vllm_cdna_gating PRIVATE lemonade-server-core) add_test(NAME VllmCdnaGatingTest COMMAND test_vllm_cdna_gating) endif() + +set(_HTTP_CLIENT_SECURITY_TEST_SRC "${CMAKE_CURRENT_SOURCE_DIR}/test/cpp/test_http_client_security.cpp") +if(EXISTS "${_HTTP_CLIENT_SECURITY_TEST_SRC}") + add_executable(test_http_client_security test/cpp/test_http_client_security.cpp) + target_link_libraries(test_http_client_security PRIVATE lemonade-server-core) + add_test(NAME HttpClientSecurityTest COMMAND test_http_client_security) +endif() diff --git a/src/cpp/include/lemon/backends/cloud/cloud_server.h b/src/cpp/include/lemon/backends/cloud/cloud_server.h index 51b61d6f4..1e9c4daf0 100644 --- a/src/cpp/include/lemon/backends/cloud/cloud_server.h +++ b/src/cpp/include/lemon/backends/cloud/cloud_server.h @@ -83,7 +83,8 @@ class CloudServer : public WrappedServer { /// can continue with other providers. static std::vector discover_models(const std::string& provider, const std::string& api_key, - const std::string& base_url); + const std::string& base_url, + bool allow_insecure_http = false); private: struct ResolvedCreds { diff --git a/src/cpp/include/lemon/utils/http_client.h b/src/cpp/include/lemon/utils/http_client.h index 8d0777b27..a07f672d9 100644 --- a/src/cpp/include/lemon/utils/http_client.h +++ b/src/cpp/include/lemon/utils/http_client.h @@ -51,6 +51,23 @@ struct DownloadResult { using ProgressCallback = std::function; using StreamCallback = std::function; +// Trust boundary for HTTP requests that may follow redirects. Selects which URL +// schemes are allowed for the initial request and for redirect targets, and +// whether redirects are followed at all. +enum class HttpSecurityPolicy { + // External hosts (Hugging Face, GitHub, release CDNs). Require https for the + // initial request and every redirect hop; bound the redirect chain. This is + // the default so untrusted destinations are HTTPS-only unless a caller opts + // out for a specific trust boundary. + ExternalHttpsOnly, + // Lemonade-managed backends reached over loopback http://127.0.0.1. Require + // http and never follow redirects. + TrustedLoopback, + // User-configured plaintext endpoints that explicitly opted in via + // allow_insecure_http. Permit http and https; bound the redirect chain. + AllowInsecureHttp, +}; + // Download configuration options struct DownloadOptions { int max_retries = 5; // Maximum retry attempts @@ -86,7 +103,8 @@ class HttpClient { // Simple GET request. timeout_seconds=0 (default) uses default_timeout_seconds_. static HttpResponse get(const std::string& url, const std::map& headers = {}, - long timeout_seconds = 0); + long timeout_seconds = 0, + HttpSecurityPolicy policy = HttpSecurityPolicy::ExternalHttpsOnly); // Simple POST request static HttpResponse post(const std::string& url, @@ -114,7 +132,8 @@ class HttpClient { const std::string& output_path, ProgressCallback callback = nullptr, const std::map& headers = {}, - const DownloadOptions& options = DownloadOptions()); + const DownloadOptions& options = DownloadOptions(), + HttpSecurityPolicy policy = HttpSecurityPolicy::ExternalHttpsOnly); // Check if URL is reachable static bool is_reachable(const std::string& url, int timeout_seconds = 5); @@ -129,7 +148,8 @@ class HttpClient { ProgressCallback callback, const std::map& headers, const DownloadOptions& options, - bool initial_range_request); + bool initial_range_request, + HttpSecurityPolicy policy); }; // Creates a throttled progress callback that prints at most once per second. diff --git a/src/cpp/server/backends/acestep/acestep_server.cpp b/src/cpp/server/backends/acestep/acestep_server.cpp index 9e76e3d63..85195e60d 100644 --- a/src/cpp/server/backends/acestep/acestep_server.cpp +++ b/src/cpp/server/backends/acestep/acestep_server.cpp @@ -205,7 +205,8 @@ bool AceStepServer::run_job(const std::string& path, const std::string& body, const std::string job_url = base + "/job?id=" + job_id; std::string status; for (int i = 0; i < 1200; ++i) { - auto poll = utils::HttpClient::get(job_url, {}, 30); + auto poll = utils::HttpClient::get(job_url, {}, 30, + utils::HttpSecurityPolicy::TrustedLoopback); if (poll.status_code == 200) { try { status = json::parse(poll.body).value("status", std::string()); } catch (...) { status.clear(); } @@ -220,7 +221,8 @@ bool AceStepServer::run_job(const std::string& path, const std::string& body, return false; } - auto fetched = utils::HttpClient::get(job_url + "&result=1", {}, 120); + auto fetched = utils::HttpClient::get(job_url + "&result=1", {}, 120, + utils::HttpSecurityPolicy::TrustedLoopback); if (fetched.status_code != 200) { LOG(ERROR, "acestep-server") << "fetching " << stage << " job result failed (HTTP " << fetched.status_code << ")" << std::endl; diff --git a/src/cpp/server/backends/cloud/cloud_server.cpp b/src/cpp/server/backends/cloud/cloud_server.cpp index 8022898ca..991a3a2b3 100644 --- a/src/cpp/server/backends/cloud/cloud_server.cpp +++ b/src/cpp/server/backends/cloud/cloud_server.cpp @@ -737,7 +737,8 @@ void CloudServer::forward_streaming_request(const std::string& endpoint, std::vector CloudServer::discover_models(const std::string& provider, const std::string& api_key, - const std::string& base_url) { + const std::string& base_url, + bool allow_insecure_http) { std::vector models; if (api_key.empty()) { return models; @@ -759,13 +760,20 @@ std::vector CloudServer::discover_models(const std::string& provider, {"Authorization", "Bearer " + api_key} }; + // A plaintext http:// provider is only reached when the operator explicitly + // opted in; https providers use the default external HTTPS-only policy. + const utils::HttpSecurityPolicy policy = + (allow_insecure_http && CloudProviderRegistry::is_http_base_url(normalized_base)) + ? utils::HttpSecurityPolicy::AllowInsecureHttp + : utils::HttpSecurityPolicy::ExternalHttpsOnly; + utils::HttpResponse response; try { // Short timeout: this runs synchronously inside cache build, once per // configured provider. The 300 s default would block model listing // for minutes if a provider's API is unreachable. 15 s is plenty for // a /v1/models response under normal conditions. - response = utils::HttpClient::get(url, headers, /*timeout_seconds=*/15); + response = utils::HttpClient::get(url, headers, /*timeout_seconds=*/15, policy); } catch (const std::exception& e) { LOG(WARNING, "Cloud") << "Model discovery failed for provider '" << provider << "': " << e.what() << std::endl; @@ -901,7 +909,8 @@ class CloudOps : public BackendOps { continue; } try { - for (auto& m : CloudServer::discover_models(rec.name, api_key, rec.base_url)) { + for (auto& m : CloudServer::discover_models(rec.name, api_key, rec.base_url, + rec.allow_insecure_http)) { if (m.recipe == "cloud" && !m.model_name.empty()) { out.push_back(std::move(m)); } diff --git a/src/cpp/server/backends/vllm/vllm_server.cpp b/src/cpp/server/backends/vllm/vllm_server.cpp index 05bd34119..4540196f1 100644 --- a/src/cpp/server/backends/vllm/vllm_server.cpp +++ b/src/cpp/server/backends/vllm/vllm_server.cpp @@ -703,7 +703,8 @@ std::map VLLMServer::get_additional_telemetry() { std::string url = "http://127.0.0.1:" + std::to_string(get_backend_port()) + "/metrics"; try { - auto response = utils::HttpClient::get(url, {}, 1); + auto response = utils::HttpClient::get(url, {}, 1, + utils::HttpSecurityPolicy::TrustedLoopback); if (response.status_code == 200) { return parse_vllm_metrics_text(response.body); } diff --git a/src/cpp/server/telemetry.cpp b/src/cpp/server/telemetry.cpp index 339a41309..a8ec9b776 100644 --- a/src/cpp/server/telemetry.cpp +++ b/src/cpp/server/telemetry.cpp @@ -410,7 +410,8 @@ class MetricsWorker { if (!task.metrics_url.empty() && task.parser) { try { - auto response = utils::HttpClient::get(task.metrics_url, {}, 1); + auto response = utils::HttpClient::get( + task.metrics_url, {}, 1, utils::HttpSecurityPolicy::TrustedLoopback); if (response.status_code == 200) { auto extra = task.parser(response.body); for (const auto& [k, v] : extra) { diff --git a/src/cpp/server/utils/http_client.cpp b/src/cpp/server/utils/http_client.cpp index ec38e997f..7e20f9652 100644 --- a/src/cpp/server/utils/http_client.cpp +++ b/src/cpp/server/utils/http_client.cpp @@ -358,9 +358,37 @@ static int progress_callback(void* clientp, curl_off_t dltotal, curl_off_t dlnow return 0; } +namespace { +// Applies the scheme and redirect restrictions for a trust boundary. Returns +// false if any security-relevant option fails to apply so callers can fail +// closed instead of issuing an unrestricted request. +bool apply_http_security_policy(CURL* curl, HttpSecurityPolicy policy) { + auto set = [curl](CURLoption opt, auto value) { + return curl_easy_setopt(curl, opt, value) == CURLE_OK; + }; + switch (policy) { + case HttpSecurityPolicy::TrustedLoopback: + return set(CURLOPT_FOLLOWLOCATION, 0L) && + set(CURLOPT_PROTOCOLS_STR, "http"); + case HttpSecurityPolicy::AllowInsecureHttp: + return set(CURLOPT_FOLLOWLOCATION, 1L) && + set(CURLOPT_MAXREDIRS, 5L) && + set(CURLOPT_PROTOCOLS_STR, "http,https") && + set(CURLOPT_REDIR_PROTOCOLS_STR, "http,https"); + case HttpSecurityPolicy::ExternalHttpsOnly: + default: + return set(CURLOPT_FOLLOWLOCATION, 1L) && + set(CURLOPT_MAXREDIRS, 5L) && + set(CURLOPT_PROTOCOLS_STR, "https") && + set(CURLOPT_REDIR_PROTOCOLS_STR, "https"); + } +} +} // namespace + HttpResponse HttpClient::get(const std::string& url, const std::map& headers, - long timeout_seconds) { + long timeout_seconds, + HttpSecurityPolicy policy) { CURL* curl = curl_easy_init(); if (!curl) { throw std::runtime_error("Failed to initialize CURL"); @@ -372,10 +400,10 @@ HttpResponse HttpClient::get(const std::string& url, curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_body); - curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); - curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); - curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); - curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); + if (!apply_http_security_policy(curl, policy)) { + curl_easy_cleanup(curl); + throw std::runtime_error("Failed to apply HTTP security policy"); + } curl_easy_setopt(curl, CURLOPT_TIMEOUT, timeout_seconds > 0 ? timeout_seconds : default_timeout_seconds_.load()); curl_easy_setopt(curl, CURLOPT_USERAGENT, "lemon.cpp/1.0"); @@ -654,7 +682,8 @@ DownloadResult HttpClient::download_attempt(const std::string& url, ProgressCallback callback, const std::map& headers, const DownloadOptions& options, - bool initial_range_request) { + bool initial_range_request, + HttpSecurityPolicy policy) { DownloadResult result; CURL* curl = curl_easy_init(); @@ -680,10 +709,12 @@ DownloadResult HttpClient::download_attempt(const std::string& url, curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_file_callback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp); - curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); - curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); - curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); - curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); + if (!apply_http_security_policy(curl, policy)) { + result.error_message = "Failed to apply HTTP security policy"; + fclose(fp); + curl_easy_cleanup(curl); + return result; + } curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L); curl_easy_setopt(curl, CURLOPT_USERAGENT, "lemon.cpp/1.0"); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, static_cast(options.connect_timeout)); @@ -839,10 +870,12 @@ DownloadResult HttpClient::download_attempt(const std::string& url, if (head_curl) { curl_easy_setopt(head_curl, CURLOPT_URL, url.c_str()); curl_easy_setopt(head_curl, CURLOPT_NOBODY, 1L); // HEAD request - curl_easy_setopt(head_curl, CURLOPT_FOLLOWLOCATION, 1L); - curl_easy_setopt(head_curl, CURLOPT_MAXREDIRS, 5L); - curl_easy_setopt(head_curl, CURLOPT_PROTOCOLS_STR, "https"); - curl_easy_setopt(head_curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); + if (!apply_http_security_policy(head_curl, policy)) { + curl_easy_cleanup(head_curl); + result.error_message = "Resume failed (HTTP 416) - could not apply security policy"; + result.can_resume = false; + return result; + } curl_easy_setopt(head_curl, CURLOPT_TIMEOUT, 30L); // Add headers @@ -908,7 +941,8 @@ DownloadResult HttpClient::download_file(const std::string& url, const std::string& output_path, ProgressCallback callback, const std::map& headers, - const DownloadOptions& options) { + const DownloadOptions& options, + HttpSecurityPolicy policy) { DownloadResult final_result; int retry_delay_ms = options.initial_retry_delay_ms; const ExpectedHash expected_hash = parse_expected_hash(options); @@ -1031,7 +1065,7 @@ DownloadResult HttpClient::download_file(const std::string& url, final_result = download_attempt(url, partial_path, resume_offset, adjusted_callback, headers, options, - initial_range_request); + initial_range_request, policy); // If cancelled by user, return immediately without retrying if (final_result.cancelled) { diff --git a/src/cpp/server/wrapped_server.cpp b/src/cpp/server/wrapped_server.cpp index cd79f07d0..e004929db 100644 --- a/src/cpp/server/wrapped_server.cpp +++ b/src/cpp/server/wrapped_server.cpp @@ -588,7 +588,8 @@ json WrappedServer::forward_get_request(const std::string& endpoint, long timeou std::map headers; try { - auto response = utils::HttpClient::get(url, headers); + auto response = utils::HttpClient::get(url, headers, 0, + utils::HttpSecurityPolicy::TrustedLoopback); note_backend_activity(); if (response.status_code == 200) { diff --git a/test/cpp/test_http_client_security.cpp b/test/cpp/test_http_client_security.cpp new file mode 100644 index 000000000..0fa84c201 --- /dev/null +++ b/test/cpp/test_http_client_security.cpp @@ -0,0 +1,219 @@ +// Unit tests for the HttpSecurityPolicy trust boundaries applied by +// lemon::utils::HttpClient. A local httplib server on loopback stands in for +// both a Lemonade-managed backend and an external host so we can exercise the +// scheme and redirect restrictions without real TLS. +// +// The HTTPS→HTTP protocol-downgrade test uses an httplib loopback server with +// an https:// URL in the client request. Because cpp-httplib's SSL support is +// disabled in this build the TLS handshake will fail, but libcurl checks +// CURLOPT_REDIR_PROTOCOLS_STR *before* attempting any network I/O, so the +// http:// redirect is blocked at the configuration level regardless of +// certificate validity. +// +// Checks use an explicit pass/fail counter (not assert()) so the test stays +// effective under the Release build the CI `default` preset uses, where +// -DNDEBUG would compile assert() to a no-op. + +#include +#include +#include + +#include +#include + +#include + +using lemon::utils::HttpClient; +using lemon::utils::HttpSecurityPolicy; + +struct TestResult { + int passed = 0; + int failed = 0; + + void check(bool cond, const std::string& name) { + if (cond) { + printf("[PASS] %s\n", name.c_str()); + ++passed; + } else { + printf("[FAIL] %s\n", name.c_str()); + ++failed; + } + } +}; + +// Make a raw curl request using an https:// URL that points to a loopback +// httplib server and returns an http:// redirect. This exercises +// CURLOPT_REDIR_PROTOCOLS_STR="https" — curl rejects the http:// redirect +// target even though the initial URL has a legitimate https:// scheme. +// +// The TLS handshake on the loopback address will fail because the loopback +// server does not handle TLS, but libcurl validates the redirect target +// protocol before any network I/O, so the rejection is guaranteed. +static bool test_https_redirect_to_http(httplib::Server& http_svr, int port) { + CURL* curl = curl_easy_init(); + if (!curl) { + printf("[FAIL] https: curl_init failed\n"); + return false; + } + + // Use an https:// URL — the External policy enforces https:// everywhere. + // The /http-redirect handler returns a 302 to http://127.0.0.1:/ok, + // which MUST be blocked by CURLOPT_REDIR_PROTOCOLS_STR="https". + std::string url = "https://127.0.0.1:" + std::to_string(port) + "/http-redirect"; + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, + [](void* ptr, size_t s, size_t n, void* userp) -> size_t { + auto* buf = static_cast(userp); + buf->append(static_cast(ptr), s * n); + return s * n; + }); + std::string body; + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body); + + // Allow initial https request but block non-https redirects — this matches + // the ExternalHttpsOnly policy enforced by apply_http_security_policy(). + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); + curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + // Disable cert verification — the loopback server lacks TLS, but the + // redirect protocol check happens before any network I/O. + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); + + CURLcode res = curl_easy_perform(curl); + bool rejected = (res != CURLE_OK); + + curl_easy_cleanup(curl); + return rejected; +} + +int main() { + TestResult r; + printf("=== HttpClient security policy Unit Tests ===\n\n"); + + httplib::Server svr; + + // Bind port first so route handlers can reference it. + const int port = svr.bind_to_any_port("127.0.0.1"); + if (port <= 0) { + printf("[FAIL] failed to bind loopback test server\n"); + return 1; + } + + svr.Get("/ok", [](const httplib::Request&, httplib::Response& res) { + res.set_content("ok", "text/plain"); + }); + svr.Get("/redirect", [](const httplib::Request&, httplib::Response& res) { + res.set_redirect("/ok"); + }); + // Redirects to itself forever; a bounded client stops at MAXREDIRS. + svr.Get("/loop", [](const httplib::Request&, httplib::Response& res) { + res.set_redirect("/loop"); + }); + svr.Get("/to-file", [](const httplib::Request&, httplib::Response& res) { + res.set_redirect("file:///etc/passwd"); + }); + // Returns an http:// redirect — exercise for the HTTPS→HTTP downgrade test. + svr.Get("/http-redirect", + [port](const httplib::Request&, httplib::Response& res) { + res.set_redirect("http://127.0.0.1:" + std::to_string(port) + "/ok"); + }); + + std::thread server_thread([&svr]() { svr.listen_after_bind(); }); + svr.wait_until_ready(); + + const std::string base = "http://127.0.0.1:" + std::to_string(port); + + // Trusted loopback: plain http GET succeeds. + try { + auto resp = HttpClient::get(base + "/ok", {}, 5, HttpSecurityPolicy::TrustedLoopback); + r.check(resp.status_code == 200 && resp.body == "ok", + "loopback: http GET returns 200"); + } catch (const std::exception& e) { + r.check(false, std::string("loopback: http GET returns 200 (threw: ") + e.what() + ")"); + } + + // Trusted loopback: redirects are not followed (302 returned verbatim). + try { + auto resp = HttpClient::get(base + "/redirect", {}, 5, HttpSecurityPolicy::TrustedLoopback); + r.check(resp.status_code == 302 && resp.body != "ok", + "loopback: redirect is not followed"); + } catch (const std::exception& e) { + r.check(false, std::string("loopback: redirect is not followed (threw: ") + e.what() + ")"); + } + + // External policy: an http:// initial URL is rejected before any request. + { + bool rejected = false; + try { + HttpClient::get(base + "/ok", {}, 5, HttpSecurityPolicy::ExternalHttpsOnly); + } catch (const std::exception&) { + rejected = true; + } + r.check(rejected, "external: initial http:// is rejected"); + } + + // Insecure opt-in: plain http GET succeeds. + try { + auto resp = HttpClient::get(base + "/ok", {}, 5, HttpSecurityPolicy::AllowInsecureHttp); + r.check(resp.status_code == 200 && resp.body == "ok", + "insecure: http GET returns 200"); + } catch (const std::exception& e) { + r.check(false, std::string("insecure: http GET returns 200 (threw: ") + e.what() + ")"); + } + + // Insecure opt-in: a single http->http redirect within the limit is followed. + try { + auto resp = HttpClient::get(base + "/redirect", {}, 5, HttpSecurityPolicy::AllowInsecureHttp); + r.check(resp.status_code == 200 && resp.body == "ok", + "insecure: redirect within limit is followed"); + } catch (const std::exception& e) { + r.check(false, std::string("insecure: redirect within limit is followed (threw: ") + e.what() + ")"); + } + + // Insecure opt-in: a redirect chain past MAXREDIRS is rejected. + { + bool rejected = false; + try { + HttpClient::get(base + "/loop", {}, 5, HttpSecurityPolicy::AllowInsecureHttp); + } catch (const std::exception&) { + rejected = true; + } + r.check(rejected, "insecure: redirect chain past the limit is rejected"); + } + + // Insecure opt-in: a redirect to a non-http(s) scheme is rejected. + { + bool rejected = false; + try { + HttpClient::get(base + "/to-file", {}, 5, HttpSecurityPolicy::AllowInsecureHttp); + } catch (const std::exception&) { + rejected = true; + } + r.check(rejected, "insecure: redirect to file:// is rejected"); + } + + // HTTPS→HTTP protocol downgrade test: the client makes an https:// request + // and the server returns a 302 redirect pointing to a http:// URL. + // + // Under the ExternalHttpsOnly policy, apply_http_security_policy() sets + // CURLOPT_REDIR_PROTOCOLS_STR="https", which makes curl return + // CURLE_UNSUPPORTED_PROTOCOL when encountering a non-https redirect. + // + // The HTTPS scheme in the URL is necessary to trigger curl's redirect + // protocol check (CURLPROTO_HTTPS allows the initial request, the http:// + // redirect is then blocked). The loopback server's TLS will not complete + // (no cipher negotiation), but curl blocks the redirect at the + // configuration level before any network I/O occurs. + { + bool rejected = test_https_redirect_to_http(svr, port); + r.check(rejected, "external: https->http redirect is rejected"); + } + + svr.stop(); + server_thread.join(); + + printf("\n=== %d passed, %d failed ===\n", r.passed, r.failed); + return r.failed == 0 ? 0 : 1; +} From 22abd06fb13ec8dc081aabc975c0b8a407cde06b Mon Sep 17 00:00:00 2001 From: "Mario Limonciello (AMD)" Date: Sun, 12 Jul 2026 08:35:12 -0500 Subject: [PATCH 3/5] fix(cloud): use correct security policy for loopback provider discovery --- .../server/backends/cloud/cloud_server.cpp | 15 ++-- test/cpp/test_http_client_security.cpp | 84 ++----------------- 2 files changed, 17 insertions(+), 82 deletions(-) diff --git a/src/cpp/server/backends/cloud/cloud_server.cpp b/src/cpp/server/backends/cloud/cloud_server.cpp index 991a3a2b3..fa508bc5c 100644 --- a/src/cpp/server/backends/cloud/cloud_server.cpp +++ b/src/cpp/server/backends/cloud/cloud_server.cpp @@ -760,12 +760,10 @@ std::vector CloudServer::discover_models(const std::string& provider, {"Authorization", "Bearer " + api_key} }; - // A plaintext http:// provider is only reached when the operator explicitly - // opted in; https providers use the default external HTTPS-only policy. - const utils::HttpSecurityPolicy policy = - (allow_insecure_http && CloudProviderRegistry::is_http_base_url(normalized_base)) - ? utils::HttpSecurityPolicy::AllowInsecureHttp - : utils::HttpSecurityPolicy::ExternalHttpsOnly; + // HTTPS providers and remote endpoints use the default external + // HTTPS-only policy. Plaintext http:// providers that explicitly set + // allow_insecure_http use the AllowInsecureHttp opt-in so http and https + // are accepted but redirect chains remain bounded. utils::HttpResponse response; try { @@ -773,7 +771,10 @@ std::vector CloudServer::discover_models(const std::string& provider, // configured provider. The 300 s default would block model listing // for minutes if a provider's API is unreachable. 15 s is plenty for // a /v1/models response under normal conditions. - response = utils::HttpClient::get(url, headers, /*timeout_seconds=*/15, policy); + response = utils::HttpClient::get(url, headers, /*timeout_seconds=*/15, + allow_insecure_http + ? utils::HttpSecurityPolicy::AllowInsecureHttp + : utils::HttpSecurityPolicy::ExternalHttpsOnly); } catch (const std::exception& e) { LOG(WARNING, "Cloud") << "Model discovery failed for provider '" << provider << "': " << e.what() << std::endl; diff --git a/test/cpp/test_http_client_security.cpp b/test/cpp/test_http_client_security.cpp index 0fa84c201..bee97dde3 100644 --- a/test/cpp/test_http_client_security.cpp +++ b/test/cpp/test_http_client_security.cpp @@ -3,13 +3,6 @@ // both a Lemonade-managed backend and an external host so we can exercise the // scheme and redirect restrictions without real TLS. // -// The HTTPS→HTTP protocol-downgrade test uses an httplib loopback server with -// an https:// URL in the client request. Because cpp-httplib's SSL support is -// disabled in this build the TLS handshake will fail, but libcurl checks -// CURLOPT_REDIR_PROTOCOLS_STR *before* attempting any network I/O, so the -// http:// redirect is blocked at the configuration level regardless of -// certificate validity. -// // Checks use an explicit pass/fail counter (not assert()) so the test stays // effective under the Release build the CI `default` preset uses, where // -DNDEBUG would compile assert() to a no-op. @@ -41,53 +34,6 @@ struct TestResult { } }; -// Make a raw curl request using an https:// URL that points to a loopback -// httplib server and returns an http:// redirect. This exercises -// CURLOPT_REDIR_PROTOCOLS_STR="https" — curl rejects the http:// redirect -// target even though the initial URL has a legitimate https:// scheme. -// -// The TLS handshake on the loopback address will fail because the loopback -// server does not handle TLS, but libcurl validates the redirect target -// protocol before any network I/O, so the rejection is guaranteed. -static bool test_https_redirect_to_http(httplib::Server& http_svr, int port) { - CURL* curl = curl_easy_init(); - if (!curl) { - printf("[FAIL] https: curl_init failed\n"); - return false; - } - - // Use an https:// URL — the External policy enforces https:// everywhere. - // The /http-redirect handler returns a 302 to http://127.0.0.1:/ok, - // which MUST be blocked by CURLOPT_REDIR_PROTOCOLS_STR="https". - std::string url = "https://127.0.0.1:" + std::to_string(port) + "/http-redirect"; - curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, - [](void* ptr, size_t s, size_t n, void* userp) -> size_t { - auto* buf = static_cast(userp); - buf->append(static_cast(ptr), s * n); - return s * n; - }); - std::string body; - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body); - - // Allow initial https request but block non-https redirects — this matches - // the ExternalHttpsOnly policy enforced by apply_http_security_policy(). - curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); - curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https"); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L); - curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); - // Disable cert verification — the loopback server lacks TLS, but the - // redirect protocol check happens before any network I/O. - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L); - curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L); - - CURLcode res = curl_easy_perform(curl); - bool rejected = (res != CURLE_OK); - - curl_easy_cleanup(curl); - return rejected; -} - int main() { TestResult r; printf("=== HttpClient security policy Unit Tests ===\n\n"); @@ -114,11 +60,6 @@ int main() { svr.Get("/to-file", [](const httplib::Request&, httplib::Response& res) { res.set_redirect("file:///etc/passwd"); }); - // Returns an http:// redirect — exercise for the HTTPS→HTTP downgrade test. - svr.Get("/http-redirect", - [port](const httplib::Request&, httplib::Response& res) { - res.set_redirect("http://127.0.0.1:" + std::to_string(port) + "/ok"); - }); std::thread server_thread([&svr]() { svr.listen_after_bind(); }); svr.wait_until_ready(); @@ -194,22 +135,15 @@ int main() { r.check(rejected, "insecure: redirect to file:// is rejected"); } - // HTTPS→HTTP protocol downgrade test: the client makes an https:// request - // and the server returns a 302 redirect pointing to a http:// URL. - // - // Under the ExternalHttpsOnly policy, apply_http_security_policy() sets - // CURLOPT_REDIR_PROTOCOLS_STR="https", which makes curl return - // CURLE_UNSUPPORTED_PROTOCOL when encountering a non-https redirect. - // - // The HTTPS scheme in the URL is necessary to trigger curl's redirect - // protocol check (CURLPROTO_HTTPS allows the initial request, the http:// - // redirect is then blocked). The loopback server's TLS will not complete - // (no cipher negotiation), but curl blocks the redirect at the - // configuration level before any network I/O occurs. - { - bool rejected = test_https_redirect_to_http(svr, port); - r.check(rejected, "external: https->http redirect is rejected"); - } + // Note: An HTTPS→HTTP protocol-downgrade test (client connects to https:// + // and is redirected to http://) is not exercised here. Running a curl + // request against a plain httplib::Server with an https:// URL fails at the + // TLS handshake before curl can reach the redirect-protocol check, so the + // test would pass on an unrelated failure rather than exercising the actual + // CURLOPT_REDIR_PROTOCOLS_STR="https" logic. That scenario is validated by + // the ExternalHttpsOnly policy configuration in apply_http_security_policy() + // and by the AllowInsecureHttp file://-block, which uses the same libcurl + // mechanism to restrict redirect targets. svr.stop(); server_thread.join(); From 5f4e16788631675ba4627b35ae0c925fce3d972b Mon Sep 17 00:00:00 2001 From: Mario Limonciello Date: Mon, 13 Jul 2026 13:36:41 -0500 Subject: [PATCH 4/5] fix(cloud): forward allow_insecure_http to discovery in refresh_cloud_models refresh_cloud_models checked allow_insecure_http before proceeding but then called discover_models() without passing it, so discovery fell back to the ExternalHttpsOnly policy and rejected loopback/opted-in http:// providers with "Unsupported protocol". Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cpp/server/model_manager.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cpp/server/model_manager.cpp b/src/cpp/server/model_manager.cpp index d37defc1a..66f0c54dd 100644 --- a/src/cpp/server/model_manager.cpp +++ b/src/cpp/server/model_manager.cpp @@ -2334,7 +2334,9 @@ size_t ModelManager::refresh_cloud_models(const std::string& provider) { // provider and we don't want to block /models on it. std::vector models; try { - models = backends::CloudServer::discover_models(provider, api_key, base_url); + models = backends::CloudServer::discover_models( + provider, api_key, base_url, + cloud_registry_->allow_insecure_http_for(provider)); } catch (const std::exception& e) { LOG(WARNING, "ModelManager") << "Cloud discovery threw for provider '" << provider << "': " << e.what() << std::endl; From b144d3eb1c300452df187697eada6bff5fd6ebf3 Mon Sep 17 00:00:00 2001 From: Mario Limonciello Date: Mon, 13 Jul 2026 14:12:42 -0500 Subject: [PATCH 5/5] fix(cloud): derive discovery policy from scheme and opt-in An HTTPS provider with a stale allow_insecure_http=true was selecting AllowInsecureHttp, which permits redirect downgrades to http on the Bearer-carrying discovery request. Derive the policy from both the URL scheme and the opt-in so HTTPS providers stay HTTPS-only, extracted into CloudServer::discovery_policy() with focused regression tests. Also treat CURLE_UNSUPPORTED_PROTOCOL and CURLE_URL_MALFORMAT as permanent download failures so the retry loop stops immediately and preserves any existing partial file. Co-Authored-By: Claude --- CMakeLists.txt | 7 +++ .../lemon/backends/cloud/cloud_server.h | 8 +++ src/cpp/include/lemon/utils/http_client.h | 1 + .../server/backends/cloud/cloud_server.cpp | 21 ++++--- src/cpp/server/utils/http_client.cpp | 17 ++++++ test/cpp/test_cloud_discovery_policy.cpp | 57 +++++++++++++++++++ 6 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 test/cpp/test_cloud_discovery_policy.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1471712a3..12a5a44e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2233,3 +2233,10 @@ if(EXISTS "${_HTTP_CLIENT_SECURITY_TEST_SRC}") target_link_libraries(test_http_client_security PRIVATE lemonade-server-core) add_test(NAME HttpClientSecurityTest COMMAND test_http_client_security) endif() + +set(_CLOUD_DISCOVERY_POLICY_TEST_SRC "${CMAKE_CURRENT_SOURCE_DIR}/test/cpp/test_cloud_discovery_policy.cpp") +if(EXISTS "${_CLOUD_DISCOVERY_POLICY_TEST_SRC}") + add_executable(test_cloud_discovery_policy test/cpp/test_cloud_discovery_policy.cpp) + target_link_libraries(test_cloud_discovery_policy PRIVATE lemonade-server-core) + add_test(NAME CloudDiscoveryPolicyTest COMMAND test_cloud_discovery_policy) +endif() diff --git a/src/cpp/include/lemon/backends/cloud/cloud_server.h b/src/cpp/include/lemon/backends/cloud/cloud_server.h index 1e9c4daf0..b06bc708f 100644 --- a/src/cpp/include/lemon/backends/cloud/cloud_server.h +++ b/src/cpp/include/lemon/backends/cloud/cloud_server.h @@ -3,6 +3,7 @@ #include "lemon/backends/backend_registry.h" #include "lemon/model_manager.h" +#include "lemon/utils/http_client.h" #include "lemon/wrapped_server.h" #include #include @@ -86,6 +87,13 @@ class CloudServer : public WrappedServer { const std::string& base_url, bool allow_insecure_http = false); + /// Trust boundary for a discovery request. The AllowInsecureHttp opt-in + /// only applies to plaintext http:// providers; an https:// provider stays + /// HTTPS-only even if allow_insecure_http is stale or accidentally set, so + /// a redirect can never downgrade the Bearer-carrying request to http. + static utils::HttpSecurityPolicy discovery_policy(const std::string& base_url, + bool allow_insecure_http); + private: struct ResolvedCreds { std::string api_key; diff --git a/src/cpp/include/lemon/utils/http_client.h b/src/cpp/include/lemon/utils/http_client.h index a07f672d9..734edc5c2 100644 --- a/src/cpp/include/lemon/utils/http_client.h +++ b/src/cpp/include/lemon/utils/http_client.h @@ -45,6 +45,7 @@ struct DownloadResult { size_t total_bytes = 0; // Total file size (if known) bool can_resume = false; // Whether partial download can be resumed bool disk_full = false; // True if download failed due to insufficient disk space + bool permanent = false; // Non-recoverable failure (e.g. unsupported protocol, malformed URL); do not retry }; // Progress callback returns bool: true = continue, false = cancel download diff --git a/src/cpp/server/backends/cloud/cloud_server.cpp b/src/cpp/server/backends/cloud/cloud_server.cpp index fa508bc5c..0ff7991e8 100644 --- a/src/cpp/server/backends/cloud/cloud_server.cpp +++ b/src/cpp/server/backends/cloud/cloud_server.cpp @@ -735,6 +735,17 @@ void CloudServer::forward_streaming_request(const std::string& endpoint, } } +utils::HttpSecurityPolicy CloudServer::discovery_policy(const std::string& base_url, + bool allow_insecure_http) { + // The AllowInsecureHttp opt-in only applies to plaintext http:// providers. + // An https:// provider stays HTTPS-only even if allow_insecure_http is stale + // or accidentally set, so a redirect can never downgrade the + // Bearer-carrying request to http. + return allow_insecure_http && CloudProviderRegistry::is_http_base_url(base_url) + ? utils::HttpSecurityPolicy::AllowInsecureHttp + : utils::HttpSecurityPolicy::ExternalHttpsOnly; +} + std::vector CloudServer::discover_models(const std::string& provider, const std::string& api_key, const std::string& base_url, @@ -760,10 +771,7 @@ std::vector CloudServer::discover_models(const std::string& provider, {"Authorization", "Bearer " + api_key} }; - // HTTPS providers and remote endpoints use the default external - // HTTPS-only policy. Plaintext http:// providers that explicitly set - // allow_insecure_http use the AllowInsecureHttp opt-in so http and https - // are accepted but redirect chains remain bounded. + const auto policy = discovery_policy(normalized_base, allow_insecure_http); utils::HttpResponse response; try { @@ -771,10 +779,7 @@ std::vector CloudServer::discover_models(const std::string& provider, // configured provider. The 300 s default would block model listing // for minutes if a provider's API is unreachable. 15 s is plenty for // a /v1/models response under normal conditions. - response = utils::HttpClient::get(url, headers, /*timeout_seconds=*/15, - allow_insecure_http - ? utils::HttpSecurityPolicy::AllowInsecureHttp - : utils::HttpSecurityPolicy::ExternalHttpsOnly); + response = utils::HttpClient::get(url, headers, /*timeout_seconds=*/15, policy); } catch (const std::exception& e) { LOG(WARNING, "Cloud") << "Model discovery failed for provider '" << provider << "': " << e.what() << std::endl; diff --git a/src/cpp/server/utils/http_client.cpp b/src/cpp/server/utils/http_client.cpp index 7e20f9652..580891671 100644 --- a/src/cpp/server/utils/http_client.cpp +++ b/src/cpp/server/utils/http_client.cpp @@ -815,6 +815,15 @@ DownloadResult HttpClient::download_attempt(const std::string& url, case CURLE_SSL_CONNECT_ERROR: retryable = true; break; + // A rejected scheme (e.g. an https-only policy hit on an http URL or + // a disallowed redirect target) and a malformed URL can never + // succeed on retry. Fail permanently so the outer loop stops + // immediately and preserves any existing partial file. + case CURLE_UNSUPPORTED_PROTOCOL: + case CURLE_URL_MALFORMAT: + result.permanent = true; + retryable = false; + break; case CURLE_WRITE_ERROR: { // CURLE_WRITE_ERROR (23) typically means disk full. // Check available disk space to confirm. @@ -1119,6 +1128,14 @@ DownloadResult HttpClient::download_file(const std::string& url, return final_result; } + // A permanent transport failure (unsupported protocol, malformed URL) + // can never succeed on retry. Stop immediately and leave any partial + // file in place rather than removing it on each doomed attempt. + if (final_result.permanent) { + LOG(ERROR, "HttpClient") << "[Download] " << final_result.error_message << std::endl; + break; + } + // Don't retry permanent HTTP failures (4xx client errors). // 408 Request Timeout and 429 Too Many Requests are transient and still retried. bool is_permanent_4xx = (final_result.http_code >= 400 && final_result.http_code < 500 diff --git a/test/cpp/test_cloud_discovery_policy.cpp b/test/cpp/test_cloud_discovery_policy.cpp new file mode 100644 index 000000000..2862f9185 --- /dev/null +++ b/test/cpp/test_cloud_discovery_policy.cpp @@ -0,0 +1,57 @@ +// Unit tests for CloudServer::discovery_policy(), which selects the HTTP trust +// boundary for a provider /v1/models discovery request. The AllowInsecureHttp +// opt-in must only apply to plaintext http:// providers; an https:// provider +// must stay HTTPS-only even when allow_insecure_http is stale or accidentally +// set, since the discovery request carries an Authorization: Bearer header. +// +// Checks use an explicit pass/fail counter (not assert()) so the test stays +// effective under the Release build the CI `default` preset uses, where +// -DNDEBUG would compile assert() to a no-op. + +#include +#include + +#include +#include + +using lemon::backends::CloudServer; +using lemon::utils::HttpSecurityPolicy; + +struct TestResult { + int passed = 0; + int failed = 0; + + void check(bool cond, const std::string& name) { + if (cond) { + printf("[PASS] %s\n", name.c_str()); + ++passed; + } else { + printf("[FAIL] %s\n", name.c_str()); + ++failed; + } + } +}; + +int main() { + TestResult r; + printf("=== CloudServer discovery policy Unit Tests ===\n\n"); + + r.check(CloudServer::discovery_policy("https://api.example.com/v1", false) == + HttpSecurityPolicy::ExternalHttpsOnly, + "https + allow_insecure_http=false -> ExternalHttpsOnly"); + + r.check(CloudServer::discovery_policy("https://api.example.com/v1", true) == + HttpSecurityPolicy::ExternalHttpsOnly, + "https + allow_insecure_http=true -> ExternalHttpsOnly (flag ignored)"); + + r.check(CloudServer::discovery_policy("http://127.0.0.1:1234/v1", true) == + HttpSecurityPolicy::AllowInsecureHttp, + "http + allow_insecure_http=true -> AllowInsecureHttp"); + + r.check(CloudServer::discovery_policy("http://127.0.0.1:1234/v1", false) == + HttpSecurityPolicy::ExternalHttpsOnly, + "http + allow_insecure_http=false -> ExternalHttpsOnly"); + + printf("\n=== %d passed, %d failed ===\n", r.passed, r.failed); + return r.failed == 0 ? 0 : 1; +}