feat: add Linux OAuth adapters
Add a bounded libcurl transport with verified TLS, no redirects or retries, sanitized failures, binary-safe requests, and resolved-address enforcement for cleartext loopback traffic. Add shell-free xdg-open launching and a one-shot IPv4 loopback callback receiver with strict HTTP/query parsing, deadlines, fixed callback path, and RAII cleanup. Token parsing, Secret Service, and Graph remain follow-up phases.
This commit is contained in:
418
tests/curl_http_tests.cpp
Normal file
418
tests/curl_http_tests.cpp
Normal file
@@ -0,0 +1,418 @@
|
||||
#include "nocal/sync/curl_http.hpp"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <poll.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
#include <exception>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
using nocal::sync::CurlHttpError;
|
||||
using nocal::sync::CurlHttpOptions;
|
||||
using nocal::sync::CurlHttpTransport;
|
||||
using nocal::sync::HttpHeader;
|
||||
using nocal::sync::HttpMethod;
|
||||
using nocal::sync::HttpRequest;
|
||||
using nocal::sync::HttpResponse;
|
||||
|
||||
void require(bool condition, std::string_view message) {
|
||||
if (!condition) {
|
||||
throw std::runtime_error(std::string(message));
|
||||
}
|
||||
}
|
||||
|
||||
void close_socket(int descriptor) {
|
||||
if (descriptor >= 0) {
|
||||
::close(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
void send_all(int descriptor, std::string_view data) {
|
||||
std::size_t sent = 0;
|
||||
while (sent < data.size()) {
|
||||
const ssize_t count = ::send(
|
||||
descriptor, data.data() + sent, data.size() - sent, MSG_NOSIGNAL);
|
||||
if (count <= 0) {
|
||||
return;
|
||||
}
|
||||
sent += static_cast<std::size_t>(count);
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] std::size_t content_length(std::string_view headers) {
|
||||
std::string lower(headers);
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char character) {
|
||||
return character >= 'A' && character <= 'Z'
|
||||
? static_cast<char>(character + ('a' - 'A'))
|
||||
: static_cast<char>(character);
|
||||
});
|
||||
const std::string_view name = "\r\ncontent-length:";
|
||||
const std::size_t position = lower.find(name);
|
||||
if (position == std::string::npos) {
|
||||
return 0;
|
||||
}
|
||||
std::size_t offset = position + name.size();
|
||||
while (offset < lower.size() && (lower[offset] == ' ' || lower[offset] == '\t')) {
|
||||
++offset;
|
||||
}
|
||||
std::size_t length = 0;
|
||||
while (offset < lower.size() && lower[offset] >= '0' && lower[offset] <= '9') {
|
||||
length = length * 10U + static_cast<std::size_t>(lower[offset] - '0');
|
||||
++offset;
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string receive_request(int descriptor) {
|
||||
std::string request;
|
||||
std::array<char, 4096> buffer{};
|
||||
std::size_t expected = std::string::npos;
|
||||
while (true) {
|
||||
const ssize_t count = ::recv(descriptor, buffer.data(), buffer.size(), 0);
|
||||
if (count <= 0) {
|
||||
break;
|
||||
}
|
||||
request.append(buffer.data(), static_cast<std::size_t>(count));
|
||||
const std::size_t end = request.find("\r\n\r\n");
|
||||
if (end != std::string::npos && expected == std::string::npos) {
|
||||
expected = end + 4 + content_length(std::string_view(request).substr(0, end + 2));
|
||||
}
|
||||
if (expected != std::string::npos && request.size() >= expected) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
class OneShotServer {
|
||||
public:
|
||||
using Handler = std::function<void(int)>;
|
||||
|
||||
explicit OneShotServer(Handler handler) {
|
||||
listener_ = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
||||
require(listener_ >= 0, "unable to create test server socket");
|
||||
const int enabled = 1;
|
||||
require(::setsockopt(listener_, SOL_SOCKET, SO_REUSEADDR, &enabled, sizeof(enabled)) == 0,
|
||||
"unable to configure test server socket");
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
address.sin_port = 0;
|
||||
require(::bind(listener_, reinterpret_cast<const sockaddr*>(&address), sizeof(address)) == 0,
|
||||
"unable to bind test server socket");
|
||||
socklen_t size = sizeof(address);
|
||||
require(::getsockname(listener_, reinterpret_cast<sockaddr*>(&address), &size) == 0,
|
||||
"unable to inspect test server socket");
|
||||
port_ = ntohs(address.sin_port);
|
||||
require(::listen(listener_, 4) == 0, "unable to listen on test server socket");
|
||||
thread_ = std::thread([this, handler = std::move(handler)] {
|
||||
try {
|
||||
pollfd descriptor{listener_, POLLIN, 0};
|
||||
const int ready = ::poll(&descriptor, 1, 2'000);
|
||||
if (ready > 0) {
|
||||
const int connection = ::accept4(listener_, nullptr, nullptr, SOCK_CLOEXEC);
|
||||
if (connection >= 0) {
|
||||
++connections_;
|
||||
handler(connection);
|
||||
close_socket(connection);
|
||||
}
|
||||
}
|
||||
} catch (...) {
|
||||
error_ = std::current_exception();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
~OneShotServer() {
|
||||
if (thread_.joinable()) {
|
||||
thread_.join();
|
||||
}
|
||||
close_socket(listener_);
|
||||
}
|
||||
|
||||
OneShotServer(const OneShotServer&) = delete;
|
||||
OneShotServer& operator=(const OneShotServer&) = delete;
|
||||
|
||||
[[nodiscard]] std::string url(std::string_view path = "/") const {
|
||||
return "http://127.0.0.1:" + std::to_string(port_) + std::string(path);
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string localhost_url(std::string_view path = "/") const {
|
||||
return "http://localhost:" + std::to_string(port_) + std::string(path);
|
||||
}
|
||||
|
||||
void finish() {
|
||||
if (thread_.joinable()) {
|
||||
thread_.join();
|
||||
}
|
||||
if (error_) {
|
||||
std::rethrow_exception(error_);
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] int connections() const noexcept { return connections_; }
|
||||
|
||||
private:
|
||||
int listener_{-1};
|
||||
unsigned short port_{0};
|
||||
std::thread thread_;
|
||||
std::exception_ptr error_;
|
||||
int connections_{0};
|
||||
};
|
||||
|
||||
template <typename Action>
|
||||
CurlHttpError expect_curl_error(Action&& action, CurlHttpError::Kind kind) {
|
||||
try {
|
||||
action();
|
||||
} catch (const CurlHttpError& error) {
|
||||
if (error.kind() != kind) {
|
||||
throw std::runtime_error("curl error had kind "
|
||||
+ std::to_string(static_cast<int>(error.kind())) + " instead of "
|
||||
+ std::to_string(static_cast<int>(kind)));
|
||||
}
|
||||
return error;
|
||||
}
|
||||
throw std::runtime_error("expected CurlHttpError was not thrown");
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string method_text(HttpMethod method) {
|
||||
switch (method) {
|
||||
case HttpMethod::get: return "GET";
|
||||
case HttpMethod::post: return "POST";
|
||||
case HttpMethod::put: return "PUT";
|
||||
case HttpMethod::patch: return "PATCH";
|
||||
case HttpMethod::delete_: return "DELETE";
|
||||
}
|
||||
throw std::runtime_error("unknown test method");
|
||||
}
|
||||
|
||||
void test_methods_headers_binary_body_status_and_response_headers() {
|
||||
for (const HttpMethod method : {HttpMethod::get, HttpMethod::post, HttpMethod::put,
|
||||
HttpMethod::patch, HttpMethod::delete_}) {
|
||||
std::string received;
|
||||
OneShotServer server([&](int connection) {
|
||||
received = receive_request(connection);
|
||||
send_all(connection,
|
||||
"HTTP/1.1 207 Multi-Status\r\nX-Order: first\r\n"
|
||||
"X-Order: second\r\nContent-Length: 3\r\nConnection: close\r\n\r\nok!");
|
||||
});
|
||||
CurlHttpTransport transport;
|
||||
const std::string body{"A\0B", 3};
|
||||
const HttpResponse response = transport.send(
|
||||
{method, server.url("/method"), {{"X-Test", "value"}}, body});
|
||||
server.finish();
|
||||
|
||||
require(server.connections() == 1, "transport opened more than one connection");
|
||||
require(received.starts_with(method_text(method) + " /method HTTP/1.1\r\n"),
|
||||
"transport sent the wrong HTTP method");
|
||||
require(received.find("\r\nX-Test: value\r\n") != std::string::npos,
|
||||
"transport did not preserve request header");
|
||||
require(received.size() >= body.size()
|
||||
&& received.compare(received.size() - body.size(), body.size(), body) == 0,
|
||||
"transport did not preserve binary request body");
|
||||
require(response.status == 207 && response.body == "ok!",
|
||||
"transport did not preserve response status/body");
|
||||
require(std::count(response.headers.begin(), response.headers.end(),
|
||||
HttpHeader{"X-Order", "first"})
|
||||
== 1
|
||||
&& std::count(response.headers.begin(), response.headers.end(),
|
||||
HttpHeader{"X-Order", "second"})
|
||||
== 1,
|
||||
"transport did not preserve ordinary duplicate response headers");
|
||||
}
|
||||
}
|
||||
|
||||
void test_redirect_is_not_followed() {
|
||||
OneShotServer server([](int connection) {
|
||||
(void)receive_request(connection);
|
||||
send_all(connection,
|
||||
"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/never\r\n"
|
||||
"Content-Length: 0\r\nConnection: close\r\n\r\n");
|
||||
});
|
||||
CurlHttpTransport transport;
|
||||
const HttpResponse response = transport.send({HttpMethod::get, server.url(), {}, {}});
|
||||
server.finish();
|
||||
require(response.status == 302 && server.connections() == 1,
|
||||
"transport followed or retried a redirect");
|
||||
}
|
||||
|
||||
void test_localhost_resolution_remains_loopback() {
|
||||
OneShotServer server([](int connection) {
|
||||
(void)receive_request(connection);
|
||||
send_all(connection,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok");
|
||||
});
|
||||
CurlHttpTransport transport;
|
||||
const HttpResponse response =
|
||||
transport.send({HttpMethod::get, server.localhost_url(), {}, {}});
|
||||
server.finish();
|
||||
require(response.status == 200 && response.body == "ok" && server.connections() == 1,
|
||||
"localhost did not resolve through the loopback-only socket policy");
|
||||
}
|
||||
|
||||
void test_cumulative_response_limit() {
|
||||
OneShotServer server([](int connection) {
|
||||
(void)receive_request(connection);
|
||||
send_all(connection,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 256\r\nConnection: close\r\n\r\n"
|
||||
+ std::string(256, 'x'));
|
||||
});
|
||||
CurlHttpOptions options;
|
||||
options.maximum_response_bytes = 100;
|
||||
CurlHttpTransport transport(options);
|
||||
expect_curl_error(
|
||||
[&] { (void)transport.send({HttpMethod::get, server.url(), {}, {}}); },
|
||||
CurlHttpError::Kind::response_too_large);
|
||||
server.finish();
|
||||
require(server.connections() == 1, "oversize response caused a retry");
|
||||
}
|
||||
|
||||
void test_timeout_is_single_and_sanitized() {
|
||||
OneShotServer server([](int connection) {
|
||||
(void)receive_request(connection);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(150));
|
||||
send_all(connection,
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
||||
});
|
||||
CurlHttpOptions options;
|
||||
options.connect_timeout_milliseconds = 40;
|
||||
options.total_timeout_milliseconds = 40;
|
||||
CurlHttpTransport transport(options);
|
||||
const CurlHttpError error = expect_curl_error(
|
||||
[&] {
|
||||
(void)transport.send({HttpMethod::get, server.url("/?SECRET_QUERY"),
|
||||
{{"X-Secret", "SECRET_HEADER"}}, "SECRET_BODY"});
|
||||
},
|
||||
CurlHttpError::Kind::transport_failed);
|
||||
server.finish();
|
||||
const std::string message = error.what();
|
||||
require(message.find("SECRET") == std::string::npos
|
||||
&& message.find("127.0.0.1") == std::string::npos,
|
||||
"transport error leaked request details");
|
||||
require(server.connections() == 1, "timed-out request was retried");
|
||||
}
|
||||
|
||||
void test_invalid_options() {
|
||||
std::vector<CurlHttpOptions> invalid;
|
||||
CurlHttpOptions value;
|
||||
value.connect_timeout_milliseconds = 0;
|
||||
invalid.push_back(value);
|
||||
value = {};
|
||||
value.total_timeout_milliseconds = -1;
|
||||
invalid.push_back(value);
|
||||
value = {};
|
||||
value.maximum_response_bytes = 0;
|
||||
invalid.push_back(value);
|
||||
const std::vector<std::string> invalid_agents = {std::string("bad\ragent"),
|
||||
std::string("bad\nagent"), std::string("bad\0agent", 9), std::string("bad\x7f")};
|
||||
for (const std::string& agent : invalid_agents) {
|
||||
value = {};
|
||||
value.user_agent = agent;
|
||||
invalid.push_back(value);
|
||||
}
|
||||
for (const CurlHttpOptions& options : invalid) {
|
||||
expect_curl_error(
|
||||
[&] { CurlHttpTransport transport(options); }, CurlHttpError::Kind::invalid_request);
|
||||
}
|
||||
}
|
||||
|
||||
void test_invalid_headers_and_urls_are_rejected_before_network() {
|
||||
CurlHttpTransport transport;
|
||||
const std::vector<HttpHeader> invalid_headers = {{"", "value"}, {"Bad Name", "value"},
|
||||
{"Bad:Name", "value"}, {"X-Test", "line\rbreak"}, {"X-Test", "line\nbreak"},
|
||||
{"X-Test", std::string("nul\0break", 9)}};
|
||||
for (std::size_t index = 0; index < invalid_headers.size(); ++index) {
|
||||
try {
|
||||
expect_curl_error(
|
||||
[&] {
|
||||
(void)transport.send({HttpMethod::get, "http://127.0.0.1:1/",
|
||||
{invalid_headers[index]}, "SECRET_BODY"});
|
||||
},
|
||||
CurlHttpError::Kind::invalid_request);
|
||||
} catch (const std::exception& error) {
|
||||
throw std::runtime_error(
|
||||
"invalid header case " + std::to_string(index) + ": " + error.what());
|
||||
}
|
||||
}
|
||||
|
||||
const std::vector<std::string> invalid_urls = {"", "/relative", "ftp://example.test/",
|
||||
"http://example.test/", "http://localhost.evil/", "http://user@localhost/",
|
||||
"http://::1/", "http://[::1]evil/", "https:///missing", "http://localhost:0/",
|
||||
"http://localhost:65536/", "http://localhost/a path", "https://[not-ipv6]/",
|
||||
"https://bad%host/",
|
||||
std::string("http://localhost/a\nb")};
|
||||
for (std::size_t index = 0; index < invalid_urls.size(); ++index) {
|
||||
try {
|
||||
const CurlHttpError error = expect_curl_error(
|
||||
[&] {
|
||||
(void)transport.send(
|
||||
{HttpMethod::get, invalid_urls[index], {}, "SECRET_BODY"});
|
||||
},
|
||||
CurlHttpError::Kind::invalid_request);
|
||||
require(std::string(error.what()).find("SECRET") == std::string::npos,
|
||||
"invalid-request error leaked request data");
|
||||
} catch (const std::exception& error) {
|
||||
throw std::runtime_error(
|
||||
"invalid URL case " + std::to_string(index) + ": " + error.what());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void test_parallel_construction() {
|
||||
std::mutex mutex;
|
||||
std::exception_ptr error;
|
||||
std::vector<std::thread> threads;
|
||||
for (int index = 0; index < 8; ++index) {
|
||||
threads.emplace_back([&] {
|
||||
try {
|
||||
CurlHttpTransport transport;
|
||||
} catch (...) {
|
||||
std::scoped_lock lock(mutex);
|
||||
error = std::current_exception();
|
||||
}
|
||||
});
|
||||
}
|
||||
for (std::thread& thread : threads) {
|
||||
thread.join();
|
||||
}
|
||||
if (error) {
|
||||
std::rethrow_exception(error);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
try {
|
||||
test_methods_headers_binary_body_status_and_response_headers();
|
||||
test_redirect_is_not_followed();
|
||||
test_localhost_resolution_remains_loopback();
|
||||
test_cumulative_response_limit();
|
||||
test_timeout_is_single_and_sanitized();
|
||||
test_invalid_options();
|
||||
test_invalid_headers_and_urls_are_rejected_before_network();
|
||||
test_parallel_construction();
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "curl HTTP tests failed: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
std::cout << "curl HTTP tests passed\n";
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user