glove/tests/net/egress_proxy_test.cpp
0xkite 497e703e70
feat(runtime): manage sandboxed agent sessions
Keep credentials and network access behind authenticated, audited brokers while binding launch adapters, immutable closures, recovery identities, receipts, and cleanup to the approved plan. Add Apple Container and Linux release lanes without weakening the outer sandbox.
2026-07-30 08:49:42 -04:00

321 lines
12 KiB
C++

// Stage 2: the egress proxy must tunnel an allowlisted CONNECT through to the
// real destination and refuse everything else, auditing each decision. We
// stand up a loopback "origin" server, then drive the proxy as an HTTP client
// would: send a CONNECT line and check the response. The allowed target is the
// origin itself (by the literal host "localhost"); a different host is denied.
#include "glove/net/egress_proxy.hpp"
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <unistd.h>
#include <atomic>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <string>
#include <thread>
namespace {
#define REQUIRE(cond) \
do { \
if (!(cond)) { \
std::fprintf(stderr, "REQUIRE failed: %s @ %s:%d\n", #cond, __FILE__, __LINE__); \
return 1; \
} \
} while (0)
// A loopback server that, on the first connection, sends a marker and closes.
struct origin_server {
int fd = -1;
std::uint16_t port = 0;
std::thread thread;
auto start() -> bool {
fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
return false;
}
int one = 1;
::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
::sockaddr_in a{};
a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (::bind(fd, reinterpret_cast<::sockaddr*>(&a), sizeof(a)) != 0) {
return false;
}
if (::listen(fd, 4) != 0) {
return false;
}
::sockaddr_in bound{};
::socklen_t len = sizeof(bound);
::getsockname(fd, reinterpret_cast<::sockaddr*>(&bound), &len);
port = ntohs(bound.sin_port);
thread = std::thread([this] {
int c = ::accept(fd, nullptr, nullptr);
if (c >= 0) {
const char* msg = "ORIGIN_HELLO";
(void)::write(c, msg, std::strlen(msg));
::close(c);
}
});
return true;
}
~origin_server() {
if (fd >= 0) {
::shutdown(fd, SHUT_RDWR); // unblocks a pending accept()
::close(fd);
}
if (thread.joinable()) {
thread.join();
}
}
};
auto connect_loopback(std::uint16_t port) -> int {
int s = ::socket(AF_INET, SOCK_STREAM, 0);
if (s < 0) {
return -1;
}
::sockaddr_in a{};
a.sin_family = AF_INET;
a.sin_port = htons(port);
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (::connect(s, reinterpret_cast<::sockaddr*>(&a), sizeof(a)) != 0) {
::close(s);
return -1;
}
return s;
}
auto connect_unix(const std::filesystem::path& path) -> int {
const int socket = ::socket(AF_UNIX, SOCK_STREAM, 0);
if (socket < 0 || path.string().size() >= sizeof(::sockaddr_un::sun_path)) {
return -1;
}
::sockaddr_un address{};
address.sun_family = AF_UNIX;
std::memcpy(address.sun_path, path.c_str(), path.string().size() + 1U);
if (::connect(socket, reinterpret_cast<::sockaddr*>(&address), sizeof(address)) != 0) {
::close(socket);
return -1;
}
return socket;
}
auto read_some(int fd) -> std::string {
std::string out;
char buf[256];
for (int i = 0; i < 50; ++i) {
::ssize_t n = ::read(fd, buf, sizeof(buf));
if (n <= 0) {
break;
}
out.append(buf, static_cast<std::size_t>(n));
if (out.find("ORIGIN_HELLO") != std::string::npos ||
out.find("\r\n\r\n") != std::string::npos) {
// keep reading a touch in case body trails the 200 line
if (out.find("ORIGIN_HELLO") != std::string::npos) {
break;
}
}
}
return out;
}
auto run() -> int {
origin_server origin;
REQUIRE(origin.start());
std::atomic<int> allowed_events{0};
std::atomic<int> denied_events{0};
glove::net::egress_options opts;
opts.allow = {{.host = "localhost", .port = origin.port, .allow_private = true}};
opts.on_event = [&](const glove::net::egress_event& e) -> std::expected<void, std::string> {
if (e.allowed) {
allowed_events.fetch_add(1);
} else {
denied_events.fetch_add(1);
}
return {};
};
auto proxy_or = glove::net::start_egress_proxy(opts);
REQUIRE(proxy_or.has_value());
auto proxy = std::move(*proxy_or);
REQUIRE(proxy->port() != 0);
REQUIRE(proxy->host_allowed("localhost", origin.port));
REQUIRE(!proxy->host_allowed("localhost", 443));
REQUIRE(!proxy->host_allowed("evil.example.com", origin.port));
// The loopback listener is not an ambient capability: unauthenticated
// host processes cannot borrow the agent's egress grants.
{
int c = connect_loopback(proxy->port());
REQUIRE(c >= 0);
std::string req = "CONNECT localhost:" + std::to_string(origin.port) +
" HTTP/1.1\r\nHost: localhost\r\n\r\n";
REQUIRE(::write(c, req.data(), req.size()) == static_cast<::ssize_t>(req.size()));
std::string resp = read_some(c);
::close(c);
REQUIRE(resp.find("407 Proxy Authentication Required") != std::string::npos);
}
// Allowed: CONNECT localhost:<origin> → 200, then origin's marker tunnels.
{
int c = connect_loopback(proxy->port());
REQUIRE(c >= 0);
std::string req =
"CONNECT localhost:" + std::to_string(origin.port) +
" HTTP/1.1\r\nHost: localhost\r\nProxy-Authorization: " + proxy->proxy_authorization() +
"\r\n\r\n";
REQUIRE(::write(c, req.data(), req.size()) == static_cast<::ssize_t>(req.size()));
std::string resp = read_some(c);
::close(c);
std::fprintf(stderr, "allowed resp: %s\n", resp.c_str());
REQUIRE(resp.find("200 Connection Established") != std::string::npos);
REQUIRE(resp.find("ORIGIN_HELLO") != std::string::npos);
}
// Denied: a host not on the allow-list → 403, no tunnel.
{
int c = connect_loopback(proxy->port());
REQUIRE(c >= 0);
std::string req = "CONNECT evil.example.com:443 HTTP/1.1\r\nProxy-Authorization: " +
proxy->proxy_authorization() + "\r\n\r\n";
REQUIRE(::write(c, req.data(), req.size()) == static_cast<::ssize_t>(req.size()));
std::string resp = read_some(c);
::close(c);
std::fprintf(stderr, "denied resp: %s\n", resp.c_str());
REQUIRE(resp.find("403 Forbidden") != std::string::npos);
}
REQUIRE(allowed_events.load() == 1);
REQUIRE(denied_events.load() == 2);
// Apple Container receives this broker through a forwarded Unix socket;
// authentication and allow-list behavior are identical to the loopback
// listener, while the advertised port names only the guest relay.
{
origin_server unix_origin;
REQUIRE(unix_origin.start());
std::string temporary = "/tmp/glove-egress-unix-test-XXXXXX";
REQUIRE(::mkdtemp(temporary.data()) != nullptr);
REQUIRE(::chmod(temporary.c_str(), 0700) == 0);
const auto socket_path = std::filesystem::path{temporary} / "egress.sock";
glove::net::egress_options unix_options;
unix_options.allow = {
{.host = "localhost", .port = unix_origin.port, .allow_private = true}
};
auto unix_proxy_or = glove::net::start_egress_proxy_on_unix_socket(
std::move(unix_options), socket_path, 31'820
);
REQUIRE(unix_proxy_or.has_value());
auto unix_proxy = std::move(*unix_proxy_or);
REQUIRE(unix_proxy->port() == 31'820);
int connection = connect_unix(socket_path);
REQUIRE(connection >= 0);
std::string request =
"CONNECT localhost:" + std::to_string(unix_origin.port) +
" HTTP/1.1\r\nProxy-Authorization: " + unix_proxy->proxy_authorization() +
"\r\n\r\n";
REQUIRE(
::write(connection, request.data(), request.size()) ==
static_cast<::ssize_t>(request.size())
);
const std::string response = read_some(connection);
::close(connection);
REQUIRE(response.find("200 Connection Established") != std::string::npos);
REQUIRE(response.find("ORIGIN_HELLO") != std::string::npos);
unix_proxy.reset();
REQUIRE(!std::filesystem::exists(socket_path));
std::error_code ignored;
std::filesystem::remove(temporary, ignored);
}
// A hostname grant still cannot target loopback/private space unless the
// rule says so explicitly.
{
glove::net::egress_options private_opts;
private_opts.allow = {{.host = "localhost", .port = origin.port}};
auto private_proxy_or = glove::net::start_egress_proxy(std::move(private_opts));
REQUIRE(private_proxy_or.has_value());
auto private_proxy = std::move(*private_proxy_or);
int c = connect_loopback(private_proxy->port());
REQUIRE(c >= 0);
std::string req =
"CONNECT localhost:" + std::to_string(origin.port) +
" HTTP/1.1\r\nProxy-Authorization: " + private_proxy->proxy_authorization() +
"\r\n\r\n";
REQUIRE(::write(c, req.data(), req.size()) == static_cast<::ssize_t>(req.size()));
std::string resp = read_some(c);
::close(c);
REQUIRE(resp.find("502 Bad Gateway") != std::string::npos);
}
// IPv4-mapped IPv6 must be classified using the embedded IPv4 address;
// otherwise ::ffff:127.0.0.1 bypasses the loopback/private check.
{
origin_server mapped_origin;
REQUIRE(mapped_origin.start());
glove::net::egress_options mapped_opts;
mapped_opts.allow = {{.host = "::ffff:127.0.0.1", .port = mapped_origin.port}};
auto mapped_proxy_or = glove::net::start_egress_proxy(std::move(mapped_opts));
REQUIRE(mapped_proxy_or.has_value());
auto mapped_proxy = std::move(*mapped_proxy_or);
int c = connect_loopback(mapped_proxy->port());
REQUIRE(c >= 0);
std::string req =
"CONNECT [::ffff:127.0.0.1]:" + std::to_string(mapped_origin.port) +
" HTTP/1.1\r\nProxy-Authorization: " + mapped_proxy->proxy_authorization() + "\r\n\r\n";
REQUIRE(::write(c, req.data(), req.size()) == static_cast<::ssize_t>(req.size()));
std::string resp = read_some(c);
::close(c);
REQUIRE(resp.find("502 Bad Gateway") != std::string::npos);
}
// An allowed connection is not released when its mandatory audit record
// cannot be committed.
{
origin_server audited_origin;
REQUIRE(audited_origin.start());
glove::net::egress_options failing_audit;
failing_audit.allow = {
{.host = "localhost", .port = audited_origin.port, .allow_private = true}
};
failing_audit.on_event =
[](const glove::net::egress_event& e) -> std::expected<void, std::string> {
if (e.allowed) {
return std::unexpected(std::string{"audit unavailable"});
}
return {};
};
auto audited_proxy_or = glove::net::start_egress_proxy(std::move(failing_audit));
REQUIRE(audited_proxy_or.has_value());
auto audited_proxy = std::move(*audited_proxy_or);
int c = connect_loopback(audited_proxy->port());
REQUIRE(c >= 0);
std::string req =
"CONNECT localhost:" + std::to_string(audited_origin.port) +
" HTTP/1.1\r\nProxy-Authorization: " + audited_proxy->proxy_authorization() +
"\r\n\r\n";
REQUIRE(::write(c, req.data(), req.size()) == static_cast<::ssize_t>(req.size()));
std::string resp = read_some(c);
::close(c);
REQUIRE(resp.find("503 Service Unavailable") != std::string::npos);
REQUIRE(resp.find("ORIGIN_HELLO") == std::string::npos);
}
return 0;
}
} // namespace
auto main() -> int {
return run();
}