875 lines
32 KiB
C++
875 lines
32 KiB
C++
/*
|
|
* BastionGuard™
|
|
* Copyright (C) 2025–2026 Calogero Scarnà
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, version 3.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*
|
|
* BastionGuard™ is a trademark of Calogero Scarnà.
|
|
* The BastionGuard™ name and branding are not licensed under the GPL.
|
|
*/
|
|
|
|
#pragma once
|
|
|
|
#include <string>
|
|
#include <memory>
|
|
#include <iostream>
|
|
#include <sstream>
|
|
#include <fstream>
|
|
#include <filesystem>
|
|
#include <chrono>
|
|
#include <mutex>
|
|
#include <shared_mutex>
|
|
#include <unordered_map>
|
|
#include <functional>
|
|
#include <cstdlib>
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cctype>
|
|
#include <cerrno>
|
|
#include <limits>
|
|
|
|
#include <openssl/ssl.h>
|
|
#include <openssl/x509.h>
|
|
#include <openssl/x509v3.h>
|
|
#include <openssl/evp.h>
|
|
#include <openssl/err.h>
|
|
#include <openssl/pem.h>
|
|
#include <openssl/rand.h>
|
|
#include <openssl/bn.h>
|
|
|
|
#include <boost/asio.hpp>
|
|
#include <boost/asio/ssl.hpp>
|
|
|
|
#include <glib/gi18n.h>
|
|
|
|
#include "block_page.hpp"
|
|
#include "ca_installer.hpp"
|
|
|
|
namespace asio = boost::asio;
|
|
using tcp = asio::ip::tcp;
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace TlsIntercept {
|
|
static constexpr long CTX_CACHE_TTL_SEC = 30L * 24 * 3600;
|
|
|
|
static inline void set_no_expiry(X509* cert) {
|
|
ASN1_TIME* not_after = X509_getm_notAfter(cert);
|
|
ASN1_GENERALIZEDTIME_set_string(not_after, "99991231235959Z");
|
|
}
|
|
|
|
static inline void set_realistic_validity(X509* cert) {
|
|
X509_gmtime_adj(X509_getm_notBefore(cert), -43200L);
|
|
X509_gmtime_adj(X509_getm_notAfter(cert), 397L * 24 * 3600);
|
|
}
|
|
|
|
namespace detail {
|
|
|
|
using Clock = std::chrono::steady_clock;
|
|
static inline fs::path ca_dir() {
|
|
const char* h = std::getenv("HOME");
|
|
return fs::path(h ? h : "/tmp") / ".local/share/BastionGuard/certs";
|
|
}
|
|
static inline fs::path ca_cert_path() { return ca_dir() / "intercept-ca.crt.pem"; }
|
|
static inline fs::path ca_key_path() { return ca_dir() / "intercept-ca.key.pem"; }
|
|
static inline std::string drain_errors() {
|
|
std::ostringstream o; unsigned long e; bool first = true;
|
|
while ((e = ERR_get_error())) {
|
|
char b[256]; ERR_error_string_n(e, b, sizeof(b));
|
|
if (!first) o << " | "; o << b; first = false;
|
|
}
|
|
return first ? "no error" : o.str();
|
|
}
|
|
|
|
static inline EVP_PKEY* gen_rsa(int bits) {
|
|
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr);
|
|
if (!ctx) return nullptr;
|
|
EVP_PKEY* k = nullptr;
|
|
if (EVP_PKEY_keygen_init(ctx) > 0 &&
|
|
EVP_PKEY_CTX_set_rsa_keygen_bits(ctx, bits) > 0 &&
|
|
EVP_PKEY_keygen(ctx, &k) > 0) {
|
|
EVP_PKEY_CTX_free(ctx); return k;
|
|
}
|
|
EVP_PKEY_CTX_free(ctx); if (k) EVP_PKEY_free(k); return nullptr;
|
|
}
|
|
|
|
static inline bool add_ext(X509* cert, X509V3_CTX* v3ctx, int nid, const char* val) {
|
|
X509_EXTENSION* ext = X509V3_EXT_conf_nid(nullptr, v3ctx, nid, const_cast<char*>(val));
|
|
if (!ext) return false;
|
|
bool ok = X509_add_ext(cert, ext, -1) == 1;
|
|
X509_EXTENSION_free(ext); return ok;
|
|
}
|
|
|
|
static inline bool write_pem(const fs::path& path, std::function<int(BIO*)> writer) {
|
|
fs::create_directories(path.parent_path());
|
|
fs::path tmp = path.string() + ".tmp";
|
|
BIO* bio = BIO_new_file(tmp.c_str(), "w");
|
|
if (!bio) return false;
|
|
bool ok = writer(bio) > 0;
|
|
BIO_free(bio);
|
|
if (!ok) { fs::remove(tmp); return false; }
|
|
fs::permissions(tmp,
|
|
fs::perms::owner_read | fs::perms::owner_write,
|
|
fs::perm_options::replace);
|
|
fs::rename(tmp, path);
|
|
return true;
|
|
}
|
|
|
|
struct IntermediateCA {
|
|
X509* cert = nullptr;
|
|
EVP_PKEY* key = nullptr;
|
|
~IntermediateCA() {
|
|
if (cert) X509_free(cert);
|
|
if (key) EVP_PKEY_free(key);
|
|
}
|
|
};
|
|
|
|
static inline std::shared_ptr<IntermediateCA> generate_ca() {
|
|
std::cerr << _("[tls-intercept] generazione CA intermedia RSA 4096...\n");
|
|
|
|
EVP_PKEY* key = gen_rsa(4096);
|
|
if (!key) { std::cerr << "[tls-intercept] CA keygen failed\n"; return nullptr; }
|
|
|
|
X509* cert = X509_new();
|
|
if (!cert) { EVP_PKEY_free(key); return nullptr; }
|
|
|
|
X509_set_version(cert, 2);
|
|
|
|
unsigned char sbuf[20]; RAND_bytes(sbuf, sizeof(sbuf)); sbuf[0] &= 0x7F;
|
|
BIGNUM* bn = BN_bin2bn(sbuf, sizeof(sbuf), nullptr);
|
|
BN_to_ASN1_INTEGER(bn, X509_get_serialNumber(cert));
|
|
BN_free(bn);
|
|
|
|
X509_gmtime_adj(X509_getm_notBefore(cert), -3600L);
|
|
set_no_expiry(cert);
|
|
X509_set_pubkey(cert, key);
|
|
|
|
X509_NAME* name = X509_get_subject_name(cert);
|
|
X509_NAME_add_entry_by_txt(name, "O", MBSTRING_ASC,
|
|
reinterpret_cast<const unsigned char*>("BastionGuard"), -1, -1, 0);
|
|
X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC,
|
|
reinterpret_cast<const unsigned char*>("BastionGuard Intercept CA"), -1, -1, 0);
|
|
X509_set_issuer_name(cert, name);
|
|
|
|
X509V3_CTX v3ctx;
|
|
X509V3_set_ctx(&v3ctx, cert, cert, nullptr, nullptr, 0);
|
|
add_ext(cert, &v3ctx, NID_basic_constraints, "critical,CA:TRUE,pathlen:0");
|
|
add_ext(cert, &v3ctx, NID_key_usage, "critical,keyCertSign,cRLSign");
|
|
add_ext(cert, &v3ctx, NID_subject_key_identifier, "hash");
|
|
add_ext(cert, &v3ctx, NID_authority_key_identifier, "keyid:always");
|
|
|
|
if (X509_sign(cert, key, EVP_sha256()) <= 0) {
|
|
std::cerr << "[tls-intercept] CA sign failed: " << drain_errors() << "\n";
|
|
X509_free(cert); EVP_PKEY_free(key); return nullptr;
|
|
}
|
|
|
|
// Salva su disco
|
|
if (!write_pem(ca_cert_path(), [&](BIO* b){ return PEM_write_bio_X509(b, cert); }) ||
|
|
!write_pem(ca_key_path(), [&](BIO* b){
|
|
return PEM_write_bio_PrivateKey(b, key, nullptr, nullptr, 0, nullptr, nullptr); }))
|
|
{
|
|
std::cerr << "[tls-intercept] CA disk write failed\n";
|
|
X509_free(cert); EVP_PKEY_free(key); return nullptr;
|
|
}
|
|
|
|
std::cerr << _("[tls-intercept] ✅ CA intermedia generata\n");
|
|
auto ca = std::make_shared<IntermediateCA>(); ca->cert = cert; ca->key = key;
|
|
return ca;
|
|
}
|
|
|
|
// ── Carica CA dal disco ───────────────────────────────────────────────────────
|
|
static inline std::shared_ptr<IntermediateCA> load_ca() {
|
|
FILE* fc = fopen(ca_cert_path().c_str(), "r");
|
|
FILE* fk = fopen(ca_key_path().c_str(), "r");
|
|
if (!fc || !fk) { if (fc) fclose(fc); if (fk) fclose(fk); return nullptr; }
|
|
X509* cert = PEM_read_X509(fc, nullptr, nullptr, nullptr);
|
|
EVP_PKEY* key = PEM_read_PrivateKey(fk, nullptr, nullptr, nullptr);
|
|
fclose(fc); fclose(fk);
|
|
if (!cert || !key) { if (cert) X509_free(cert); if (key) EVP_PKEY_free(key); return nullptr; }
|
|
auto ca = std::make_shared<IntermediateCA>(); ca->cert = cert; ca->key = key;
|
|
return ca;
|
|
}
|
|
|
|
static inline bool ca_needs_rotation() {
|
|
if (!fs::exists(ca_cert_path()) || !fs::exists(ca_key_path())) return true;
|
|
FILE* f = fopen(ca_cert_path().c_str(), "r");
|
|
if (!f) return true;
|
|
X509* cert = PEM_read_X509(f, nullptr, nullptr, nullptr);
|
|
fclose(f);
|
|
if (!cert) return true;
|
|
X509_free(cert);
|
|
return false;
|
|
}
|
|
|
|
static inline void install_generated_ca() {
|
|
const fs::path system_cert = "/etc/BastionGuard/certs/BastionGuard-ca.crt.pem";
|
|
try {
|
|
fs::create_directories(system_cert.parent_path());
|
|
fs::copy_file(ca_cert_path(), system_cert,
|
|
fs::copy_options::overwrite_existing);
|
|
fs::permissions(system_cert,
|
|
fs::perms::owner_read | fs::perms::owner_write |
|
|
fs::perms::group_read | fs::perms::others_read,
|
|
fs::perm_options::replace);
|
|
std::cerr << _("[tls-intercept] CA copiata in /etc/BastionGuard/certs/\n");
|
|
} catch (const std::exception& e) {
|
|
std::cerr << "[tls-intercept] CA copy to /etc failed: " << e.what() << "\n";
|
|
}
|
|
CaInstaller::ensure_trusted();
|
|
}
|
|
|
|
static std::shared_ptr<IntermediateCA> g_ca;
|
|
static std::mutex g_ca_mutex;
|
|
|
|
static inline std::shared_ptr<IntermediateCA> get_ca() {
|
|
std::lock_guard lock(g_ca_mutex);
|
|
if (g_ca) return g_ca;
|
|
|
|
if (!ca_needs_rotation()) {
|
|
g_ca = load_ca();
|
|
if (g_ca) {
|
|
std::cerr << _("[tls-intercept] CA caricata dal disco\n");
|
|
install_generated_ca();
|
|
return g_ca;
|
|
}
|
|
}
|
|
|
|
g_ca = generate_ca();
|
|
if (g_ca) install_generated_ca();
|
|
return g_ca;
|
|
}
|
|
|
|
static inline std::shared_ptr<asio::ssl::context>
|
|
make_ssl_ctx_for_host(const std::string& host, const std::shared_ptr<IntermediateCA>& ca)
|
|
{
|
|
EVP_PKEY* leaf_key = gen_rsa(2048);
|
|
if (!leaf_key) return nullptr;
|
|
|
|
X509* cert = X509_new();
|
|
if (!cert) { EVP_PKEY_free(leaf_key); return nullptr; }
|
|
|
|
X509_set_version(cert, 2);
|
|
unsigned char sbuf[20];
|
|
RAND_bytes(sbuf, sizeof(sbuf));
|
|
sbuf[0] &= 0x7F;
|
|
BIGNUM* bn = BN_bin2bn(sbuf, sizeof(sbuf), nullptr);
|
|
BN_to_ASN1_INTEGER(bn, X509_get_serialNumber(cert));
|
|
BN_free(bn);
|
|
set_realistic_validity(cert);
|
|
X509_set_pubkey(cert, leaf_key);
|
|
|
|
X509_NAME* subject = X509_get_subject_name(cert);
|
|
X509_NAME_add_entry_by_txt(subject, "CN", MBSTRING_ASC,
|
|
reinterpret_cast<const unsigned char*>(host.c_str()), -1, -1, 0);
|
|
X509_set_issuer_name(cert, X509_get_subject_name(ca->cert));
|
|
|
|
X509V3_CTX v3ctx;
|
|
X509V3_set_ctx(&v3ctx, ca->cert, cert, nullptr, nullptr, 0);
|
|
add_ext(cert, &v3ctx, NID_basic_constraints, "critical,CA:FALSE");
|
|
add_ext(cert, &v3ctx, NID_key_usage, "critical,digitalSignature,keyEncipherment");
|
|
add_ext(cert, &v3ctx, NID_ext_key_usage, "serverAuth");
|
|
add_ext(cert, &v3ctx, NID_subject_key_identifier, "hash");
|
|
add_ext(cert, &v3ctx, NID_authority_key_identifier, "keyid:always");
|
|
|
|
std::string san = "DNS:" + host;
|
|
if (host.rfind("www.", 0) == 0) san += ",DNS:" + host.substr(4);
|
|
else san += ",DNS:www." + host;
|
|
add_ext(cert, &v3ctx, NID_subject_alt_name, san.c_str());
|
|
|
|
if (X509_sign(cert, ca->key, EVP_sha256()) <= 0) {
|
|
std::cerr << "[tls-intercept] leaf sign failed: " << drain_errors() << "\n";
|
|
X509_free(cert); EVP_PKEY_free(leaf_key); return nullptr;
|
|
}
|
|
|
|
auto ssl_ctx = std::make_shared<asio::ssl::context>(asio::ssl::context::tls_server);
|
|
SSL_CTX* native = ssl_ctx->native_handle();
|
|
|
|
SSL_CTX_set_min_proto_version(native, TLS1_2_VERSION);
|
|
SSL_CTX_set_options(native,
|
|
SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3 |
|
|
SSL_OP_NO_COMPRESSION | SSL_OP_CIPHER_SERVER_PREFERENCE);
|
|
SSL_CTX_set_mode(native, SSL_MODE_AUTO_RETRY);
|
|
SSL_CTX_set_cipher_list(native, "HIGH:!aNULL:!MD5");
|
|
#ifdef TLS1_3_VERSION
|
|
SSL_CTX_set_ciphersuites(native,
|
|
"TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256");
|
|
#endif
|
|
|
|
bool ok = SSL_CTX_use_certificate(native, cert) > 0 &&
|
|
SSL_CTX_use_PrivateKey(native, leaf_key) > 0 &&
|
|
SSL_CTX_check_private_key(native) > 0;
|
|
if (ok) SSL_CTX_add_extra_chain_cert(native, X509_dup(ca->cert));
|
|
|
|
X509_free(cert);
|
|
EVP_PKEY_free(leaf_key);
|
|
|
|
if (!ok) {
|
|
std::cerr << "[tls-intercept] SSL_CTX setup failed: " << drain_errors() << "\n";
|
|
return nullptr;
|
|
}
|
|
|
|
char buf[256];
|
|
std::snprintf(buf, sizeof(buf),
|
|
_("[tls-intercept] leaf cert pronto per: %s\n"), host.c_str());
|
|
std::cerr << buf;
|
|
return ssl_ctx;
|
|
}
|
|
|
|
struct CacheEntry {
|
|
std::shared_ptr<asio::ssl::context> ctx;
|
|
Clock::time_point expires_at;
|
|
};
|
|
|
|
static std::shared_mutex g_ctx_mutex;
|
|
static std::unordered_map<std::string, CacheEntry> g_ctx_cache;
|
|
|
|
static inline void evict_expired() {
|
|
const auto now = Clock::now();
|
|
for (auto it = g_ctx_cache.begin(); it != g_ctx_cache.end(); )
|
|
it = (it->second.expires_at <= now) ? g_ctx_cache.erase(it) : ++it;
|
|
}
|
|
|
|
static inline std::shared_ptr<asio::ssl::context>
|
|
get_or_create_ctx(const std::string& host)
|
|
{
|
|
{
|
|
std::shared_lock lock(g_ctx_mutex);
|
|
auto it = g_ctx_cache.find(host);
|
|
if (it != g_ctx_cache.end() && it->second.expires_at > Clock::now())
|
|
return it->second.ctx;
|
|
}
|
|
|
|
auto ca = get_ca();
|
|
if (!ca) return nullptr;
|
|
|
|
auto new_ctx = make_ssl_ctx_for_host(host, ca);
|
|
if (!new_ctx) return nullptr;
|
|
|
|
{
|
|
std::unique_lock lock(g_ctx_mutex);
|
|
evict_expired();
|
|
g_ctx_cache[host] = {
|
|
new_ctx,
|
|
Clock::now() + std::chrono::seconds(CTX_CACHE_TTL_SEC)
|
|
};
|
|
}
|
|
return new_ctx;
|
|
}
|
|
|
|
static inline std::string make_tls_response(const std::string& host,
|
|
const std::string& type)
|
|
{
|
|
const std::string full = BlockPage::make(host, type);
|
|
const auto sep = full.find("\r\n\r\n");
|
|
const std::string body = (sep != std::string::npos) ? full.substr(sep + 4) : full;
|
|
std::ostringstream oss;
|
|
oss << "HTTP/1.1 200 OK\r\n"
|
|
<< "Content-Type: text/html; charset=UTF-8\r\n"
|
|
<< "Content-Length: " << body.size() << "\r\n"
|
|
<< "Cache-Control: no-store, no-cache\r\n"
|
|
<< "X-Frame-Options: DENY\r\n"
|
|
<< "X-BastionGuard: intercepted\r\n"
|
|
<< "Connection: close\r\n\r\n"
|
|
<< body;
|
|
return oss.str();
|
|
}
|
|
|
|
} // namespace detail
|
|
|
|
|
|
static inline std::string extract_path_from_request(const std::string& raw) {
|
|
// Cerca la prima riga (fino a \r\n o \n)
|
|
const auto eol = raw.find('\n');
|
|
const std::string line = (eol != std::string::npos)
|
|
? raw.substr(0, eol) : raw;
|
|
|
|
|
|
const auto s1 = line.find(' ');
|
|
if (s1 == std::string::npos) return "/";
|
|
const auto s2 = line.find(' ', s1 + 1);
|
|
const std::string path = (s2 != std::string::npos)
|
|
? line.substr(s1 + 1, s2 - s1 - 1)
|
|
: line.substr(s1 + 1);
|
|
|
|
if (!path.empty() && path.back() == '\r')
|
|
return path.substr(0, path.size() - 1);
|
|
|
|
return path.empty() ? "/" : path;
|
|
}
|
|
|
|
static inline std::shared_ptr<asio::ssl::context>
|
|
make_stealth_upstream_ctx(const std::string& host)
|
|
{
|
|
auto ctx = std::make_shared<asio::ssl::context>(asio::ssl::context::tls_client);
|
|
SSL_CTX* native = ctx->native_handle();
|
|
|
|
// Disabilita protocolli obsoleti e opzioni che non usa Chrome
|
|
SSL_CTX_set_options(native,
|
|
SSL_OP_NO_SSLv2 | SSL_OP_NO_SSLv3 |
|
|
SSL_OP_NO_TLSv1 | SSL_OP_NO_TLSv1_1 |
|
|
SSL_OP_NO_COMPRESSION);
|
|
|
|
SSL_CTX_set_min_proto_version(native, TLS1_2_VERSION);
|
|
|
|
SSL_CTX_set_cipher_list(native,
|
|
"TLS_AES_128_GCM_SHA256:"
|
|
"TLS_AES_256_GCM_SHA384:"
|
|
"TLS_CHACHA20_POLY1305_SHA256:"
|
|
"ECDHE-ECDSA-AES128-GCM-SHA256:"
|
|
"ECDHE-RSA-AES128-GCM-SHA256:"
|
|
"ECDHE-ECDSA-AES256-GCM-SHA384:"
|
|
"ECDHE-RSA-AES256-GCM-SHA384:"
|
|
"ECDHE-ECDSA-CHACHA20-POLY1305:"
|
|
"ECDHE-RSA-CHACHA20-POLY1305:"
|
|
"ECDHE-RSA-AES128-SHA:"
|
|
"ECDHE-RSA-AES256-SHA:"
|
|
"AES128-GCM-SHA256:"
|
|
"AES256-GCM-SHA384:"
|
|
"AES128-SHA:"
|
|
"AES256-SHA");
|
|
|
|
#ifdef TLS1_3_VERSION
|
|
SSL_CTX_set_ciphersuites(native,
|
|
"TLS_AES_128_GCM_SHA256:"
|
|
"TLS_AES_256_GCM_SHA384:"
|
|
"TLS_CHACHA20_POLY1305_SHA256");
|
|
#endif
|
|
// Il proxy trasparente sotto usa HTTP/1.1 byte-for-byte. Non negoziare
|
|
// h2 upstream: inviare una request HTTP/1.1 su una connessione ALPN h2
|
|
// corromperebbe il flusso.
|
|
static const unsigned char alpn[] = "\x08http/1.1";
|
|
SSL_CTX_set_alpn_protos(native, alpn, sizeof(alpn) - 1);
|
|
SSL_CTX_set_default_verify_paths(native);
|
|
SSL_CTX_set_verify(native, SSL_VERIFY_PEER, nullptr);
|
|
(void)host;
|
|
|
|
return ctx;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Fetch Metadata / transaction passthrough
|
|
//
|
|
// Una navigazione "sicura" non e' sempre una pagina top-level. 3-D Secure,
|
|
// SCA, OTP e diversi flow bancari usano iframe, fetch/XHR o form POST verso
|
|
// ACS/issuer. Trasformare queste richieste in una nuova navigazione CEF perde
|
|
// POST body, cookie e contesto della transazione.
|
|
//
|
|
// Regola generica:
|
|
// * top-level GET/HEAD document -> comportamento BastionGuard tradizionale
|
|
// (apri SecureBrowser e mostra block page)
|
|
// * iframe/subresource -> inoltro HTTPS trasparente e verificato
|
|
// * qualunque metodo stateful -> inoltro HTTPS trasparente e verificato
|
|
// (POST/PUT/PATCH/DELETE, anche popup)
|
|
//
|
|
// In questo modo non servono eccezioni per "3ds4.*", "acs.*", ecc.
|
|
// -----------------------------------------------------------------------------
|
|
static inline std::string lower_ascii(std::string v) {
|
|
std::transform(v.begin(), v.end(), v.begin(),
|
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
return v;
|
|
}
|
|
|
|
static inline std::string trim_ascii(std::string v) {
|
|
while (!v.empty() && std::isspace(static_cast<unsigned char>(v.front())))
|
|
v.erase(v.begin());
|
|
while (!v.empty() && std::isspace(static_cast<unsigned char>(v.back())))
|
|
v.pop_back();
|
|
return v;
|
|
}
|
|
|
|
static inline std::string request_method_from_raw(const std::string& raw) {
|
|
const auto eol = raw.find("\r\n");
|
|
const std::string line = raw.substr(0, eol);
|
|
const auto sp = line.find(' ');
|
|
if (sp == std::string::npos) return {};
|
|
return lower_ascii(line.substr(0, sp));
|
|
}
|
|
|
|
static inline std::string header_value_ci(const std::string& raw,
|
|
const std::string& wanted) {
|
|
const std::string wanted_l = lower_ascii(wanted);
|
|
const auto first_eol = raw.find("\r\n");
|
|
if (first_eol == std::string::npos) return {};
|
|
std::size_t pos = first_eol + 2;
|
|
|
|
while (pos < raw.size()) {
|
|
const auto eol = raw.find("\r\n", pos);
|
|
if (eol == std::string::npos || eol == pos) break;
|
|
const auto colon = raw.find(':', pos);
|
|
if (colon != std::string::npos && colon < eol) {
|
|
std::string name = lower_ascii(raw.substr(pos, colon - pos));
|
|
if (name == wanted_l)
|
|
return trim_ascii(raw.substr(colon + 1, eol - colon - 1));
|
|
}
|
|
pos = eol + 2;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
static inline bool is_stateful_method(const std::string& method_l) {
|
|
return !(method_l == "get" || method_l == "head" || method_l == "options");
|
|
}
|
|
|
|
static inline bool should_passthrough_transaction(const std::string& raw) {
|
|
const std::string method = request_method_from_raw(raw);
|
|
if (method.empty()) return false; // fail verso il comportamento storico
|
|
|
|
// Mai trasformare una POST/PUT/PATCH/DELETE in una GET dentro un altro
|
|
// browser: perderemmo i dati della transazione.
|
|
if (is_stateful_method(method)) return true;
|
|
|
|
const std::string dest = lower_ascii(header_value_ci(raw, "sec-fetch-dest"));
|
|
if (dest == "iframe" || dest == "frame" || dest == "embed" ||
|
|
dest == "object" || dest == "empty" || dest == "script" ||
|
|
dest == "style" || dest == "image" || dest == "font" ||
|
|
dest == "audio" || dest == "video" || dest == "track" ||
|
|
dest == "worker" || dest == "sharedworker" ||
|
|
dest == "serviceworker" || dest == "manifest") {
|
|
return true;
|
|
}
|
|
|
|
const std::string mode = lower_ascii(header_value_ci(raw, "sec-fetch-mode"));
|
|
if (mode == "cors" || mode == "no-cors" || mode == "same-origin" ||
|
|
mode == "websocket") {
|
|
return true;
|
|
}
|
|
|
|
const std::string purpose = lower_ascii(header_value_ci(raw, "purpose"));
|
|
const std::string sec_purpose = lower_ascii(header_value_ci(raw, "sec-purpose"));
|
|
if (purpose.find("prefetch") != std::string::npos ||
|
|
sec_purpose.find("prefetch") != std::string::npos) {
|
|
return true;
|
|
}
|
|
|
|
// dest=document + mode=navigate, oppure header Fetch Metadata assenti:
|
|
// conserva il comportamento top-level esistente.
|
|
return false;
|
|
}
|
|
|
|
static inline bool parse_content_length(const std::string& raw,
|
|
std::size_t& out_len) {
|
|
const std::string v = trim_ascii(header_value_ci(raw, "content-length"));
|
|
if (v.empty()) return false;
|
|
errno = 0;
|
|
char* end = nullptr;
|
|
const unsigned long long n = std::strtoull(v.c_str(), &end, 10);
|
|
if (errno != 0 || end == v.c_str() || (end && *end != '\0') ||
|
|
n > std::numeric_limits<std::size_t>::max()) {
|
|
return false;
|
|
}
|
|
out_len = static_cast<std::size_t>(n);
|
|
return true;
|
|
}
|
|
|
|
static inline bool is_chunked_request(const std::string& raw) {
|
|
const std::string te = lower_ascii(header_value_ci(raw, "transfer-encoding"));
|
|
return te.find("chunked") != std::string::npos;
|
|
}
|
|
|
|
static inline bool read_more(asio::ssl::stream<tcp::socket>& tls,
|
|
std::string& raw,
|
|
std::size_t min_total,
|
|
std::size_t max_total) {
|
|
std::array<char, 16 * 1024> buf{};
|
|
while (raw.size() < min_total) {
|
|
if (raw.size() >= max_total) return false;
|
|
boost::system::error_code ec;
|
|
const std::size_t room = std::min<std::size_t>(buf.size(), max_total - raw.size());
|
|
const std::size_t n = tls.read_some(asio::buffer(buf.data(), room), ec);
|
|
if (n) raw.append(buf.data(), n);
|
|
if (ec) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static inline bool read_complete_chunked(asio::ssl::stream<tcp::socket>& tls,
|
|
std::string& raw,
|
|
std::size_t body_start,
|
|
std::size_t max_total) {
|
|
std::size_t cur = body_start;
|
|
std::array<char, 16 * 1024> buf{};
|
|
|
|
auto ensure_line = [&](std::size_t from, std::size_t& eol) -> bool {
|
|
for (;;) {
|
|
eol = raw.find("\r\n", from);
|
|
if (eol != std::string::npos) return true;
|
|
if (raw.size() >= max_total) return false;
|
|
boost::system::error_code ec;
|
|
const std::size_t room = std::min<std::size_t>(buf.size(), max_total - raw.size());
|
|
const std::size_t n = tls.read_some(asio::buffer(buf.data(), room), ec);
|
|
if (n) raw.append(buf.data(), n);
|
|
if (ec) return false;
|
|
}
|
|
};
|
|
|
|
for (;;) {
|
|
std::size_t eol = 0;
|
|
if (!ensure_line(cur, eol)) return false;
|
|
std::string size_line = raw.substr(cur, eol - cur);
|
|
const auto semi = size_line.find(';');
|
|
if (semi != std::string::npos) size_line.erase(semi);
|
|
size_line = trim_ascii(size_line);
|
|
if (size_line.empty()) return false;
|
|
|
|
errno = 0;
|
|
char* end = nullptr;
|
|
const unsigned long long chunk = std::strtoull(size_line.c_str(), &end, 16);
|
|
if (errno != 0 || end == size_line.c_str() || (end && *end != '\0') ||
|
|
chunk > max_total) {
|
|
return false;
|
|
}
|
|
|
|
cur = eol + 2;
|
|
if (chunk == 0) {
|
|
// Trailer section: zero o piu' header, terminati da una riga vuota.
|
|
for (;;) {
|
|
if (!ensure_line(cur, eol)) return false;
|
|
if (eol == cur) return true;
|
|
cur = eol + 2;
|
|
}
|
|
}
|
|
|
|
const std::size_t need = cur + static_cast<std::size_t>(chunk) + 2;
|
|
if (need < cur || need > max_total) return false;
|
|
if (!read_more(tls, raw, need, max_total)) return false;
|
|
if (raw.compare(cur + static_cast<std::size_t>(chunk), 2, "\r\n") != 0)
|
|
return false;
|
|
cur = need;
|
|
}
|
|
}
|
|
|
|
static inline bool read_complete_http1_request(asio::ssl::stream<tcp::socket>& tls,
|
|
std::string& raw) {
|
|
static constexpr std::size_t MAX_REQUEST = 32u * 1024u * 1024u;
|
|
const auto hdr_end_pos = raw.find("\r\n\r\n");
|
|
if (hdr_end_pos == std::string::npos) return false;
|
|
const std::size_t body_start = hdr_end_pos + 4;
|
|
|
|
std::size_t content_len = 0;
|
|
if (parse_content_length(raw, content_len)) {
|
|
if (content_len > MAX_REQUEST || body_start > MAX_REQUEST - content_len)
|
|
return false;
|
|
return read_more(tls, raw, body_start + content_len, MAX_REQUEST);
|
|
}
|
|
|
|
if (is_chunked_request(raw))
|
|
return read_complete_chunked(tls, raw, body_start, MAX_REQUEST);
|
|
|
|
return true;
|
|
}
|
|
|
|
static inline std::string force_connection_close(const std::string& raw) {
|
|
const auto hdr_end = raw.find("\r\n\r\n");
|
|
if (hdr_end == std::string::npos) return raw;
|
|
|
|
const std::string headers = raw.substr(0, hdr_end);
|
|
const std::string body = raw.substr(hdr_end + 4);
|
|
std::istringstream in(headers);
|
|
std::ostringstream out;
|
|
std::string line;
|
|
bool first = true;
|
|
while (std::getline(in, line)) {
|
|
if (!line.empty() && line.back() == '\r') line.pop_back();
|
|
if (first) {
|
|
out << line << "\r\n";
|
|
first = false;
|
|
continue;
|
|
}
|
|
const auto colon = line.find(':');
|
|
const std::string name = colon == std::string::npos
|
|
? std::string{} : lower_ascii(trim_ascii(line.substr(0, colon)));
|
|
if (name == "connection" || name == "proxy-connection" || name == "keep-alive")
|
|
continue;
|
|
out << line << "\r\n";
|
|
}
|
|
out << "Connection: close\r\n\r\n";
|
|
out << body;
|
|
return out.str();
|
|
}
|
|
|
|
static inline bool forward_transaction_https(asio::ssl::stream<tcp::socket>& client_tls,
|
|
const std::string& host,
|
|
std::string raw_request) {
|
|
if (!read_complete_http1_request(client_tls, raw_request)) {
|
|
std::cerr << "[tls-intercept] transaction request incomplete/too large for "
|
|
<< host << "\n";
|
|
return false;
|
|
}
|
|
|
|
raw_request = force_connection_close(raw_request);
|
|
|
|
try {
|
|
asio::io_context io;
|
|
tcp::resolver resolver(io);
|
|
auto endpoints = resolver.resolve(host, "443");
|
|
tcp::socket upstream_socket(io);
|
|
asio::connect(upstream_socket, endpoints);
|
|
|
|
auto upstream_ctx = make_stealth_upstream_ctx(host);
|
|
asio::ssl::stream<tcp::socket> upstream(std::move(upstream_socket), *upstream_ctx);
|
|
|
|
SSL* ssl = upstream.native_handle();
|
|
if (SSL_set_tlsext_host_name(ssl, host.c_str()) != 1) {
|
|
std::cerr << "[tls-intercept] SNI setup failed for " << host << "\n";
|
|
return false;
|
|
}
|
|
X509_VERIFY_PARAM* param = SSL_get0_param(ssl);
|
|
X509_VERIFY_PARAM_set_hostflags(param, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS);
|
|
if (X509_VERIFY_PARAM_set1_host(param, host.c_str(), 0) != 1) {
|
|
std::cerr << "[tls-intercept] hostname verification setup failed for "
|
|
<< host << "\n";
|
|
return false;
|
|
}
|
|
|
|
upstream.set_verify_mode(asio::ssl::verify_peer);
|
|
upstream.handshake(asio::ssl::stream_base::client);
|
|
|
|
asio::write(upstream, asio::buffer(raw_request));
|
|
|
|
std::array<char, 64 * 1024> buf{};
|
|
for (;;) {
|
|
boost::system::error_code rec;
|
|
const std::size_t n = upstream.read_some(asio::buffer(buf), rec);
|
|
if (n) {
|
|
boost::system::error_code wec;
|
|
asio::write(client_tls, asio::buffer(buf.data(), n), wec);
|
|
if (wec) return false;
|
|
}
|
|
if (rec) {
|
|
if (rec != asio::error::eof &&
|
|
rec != asio::ssl::error::stream_truncated) {
|
|
std::cerr << "[tls-intercept] upstream read failed for " << host
|
|
<< ": " << rec.message() << "\n";
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
boost::system::error_code sd_ec;
|
|
upstream.shutdown(sd_ec);
|
|
return true;
|
|
} catch (const std::exception& e) {
|
|
std::cerr << "[tls-intercept] transaction passthrough failed for " << host
|
|
<< ": " << e.what() << "\n";
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static inline void send_passthrough_failure(asio::ssl::stream<tcp::socket>& tls) {
|
|
static constexpr char body[] = "BastionGuard: upstream secure transaction failed\n";
|
|
std::ostringstream oss;
|
|
oss << "HTTP/1.1 502 Bad Gateway\r\n"
|
|
<< "Content-Type: text/plain; charset=UTF-8\r\n"
|
|
<< "Content-Length: " << (sizeof(body) - 1) << "\r\n"
|
|
<< "Cache-Control: no-store\r\n"
|
|
<< "Connection: close\r\n\r\n"
|
|
<< body;
|
|
const std::string resp = oss.str();
|
|
boost::system::error_code ec;
|
|
asio::write(tls, asio::buffer(resp), ec);
|
|
}
|
|
|
|
static inline void do_intercept(tcp::socket sock,
|
|
const std::string& host,
|
|
const std::string& type,
|
|
std::function<void(const std::string&)> on_url_ready = nullptr)
|
|
{
|
|
std::cerr << "[tls-intercept] intercepting " << host << "\n";
|
|
|
|
auto ssl_ctx = detail::get_or_create_ctx(host);
|
|
if (!ssl_ctx) {
|
|
std::cerr << "[tls-intercept] ctx setup failed for " << host << "\n";
|
|
boost::system::error_code ignore;
|
|
sock.close(ignore);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
asio::ssl::stream<tcp::socket> tls(std::move(sock), *ssl_ctx);
|
|
|
|
boost::system::error_code hs_ec;
|
|
tls.handshake(asio::ssl::stream_base::server, hs_ec);
|
|
if (hs_ec) {
|
|
std::cerr << "[tls-intercept] handshake failed for " << host
|
|
<< ": " << hs_ec.message() << "\n";
|
|
boost::system::error_code ignore;
|
|
tls.lowest_layer().close(ignore);
|
|
return;
|
|
}
|
|
|
|
std::cerr << "[tls-intercept] handshake ok for " << host << "\n";
|
|
|
|
asio::streambuf rbuf;
|
|
boost::system::error_code read_ec;
|
|
asio::read_until(tls, rbuf, "\r\n\r\n", read_ec);
|
|
if (read_ec) {
|
|
std::cerr << "[tls-intercept] request header read failed for " << host
|
|
<< ": " << read_ec.message() << "\n";
|
|
boost::system::error_code ignore;
|
|
tls.lowest_layer().close(ignore);
|
|
return;
|
|
}
|
|
|
|
const std::string raw{
|
|
asio::buffers_begin(rbuf.data()),
|
|
asio::buffers_end(rbuf.data())
|
|
};
|
|
|
|
if (should_passthrough_transaction(raw)) {
|
|
const std::string method = request_method_from_raw(raw);
|
|
const std::string dest = lower_ascii(header_value_ci(raw, "sec-fetch-dest"));
|
|
std::cerr << "[tls-intercept] iframe/stateful passthrough: host=" << host
|
|
<< " method=" << method << " dest=" << dest << "\n";
|
|
if (!forward_transaction_https(tls, host, raw))
|
|
send_passthrough_failure(tls);
|
|
|
|
boost::system::error_code sd_ec;
|
|
tls.shutdown(sd_ec);
|
|
boost::system::error_code ignore;
|
|
tls.lowest_layer().close(ignore);
|
|
return;
|
|
}
|
|
|
|
// Solo una vera navigazione top-level GET/HEAD viene trasformata in
|
|
// apertura SecureBrowser. Il callback NON viene chiamato per iframe,
|
|
// fetch/XHR o POST, quindi nessuna sessione/gate fittizia viene creata.
|
|
if (on_url_ready) {
|
|
const std::string path = extract_path_from_request(raw);
|
|
const std::string full_url = "https://" + host + path;
|
|
std::cerr << "[tls-intercept] top-level secure URL: " << full_url << "\n";
|
|
on_url_ready(full_url);
|
|
}
|
|
|
|
const std::string resp = detail::make_tls_response(host, type);
|
|
boost::system::error_code write_ec;
|
|
asio::write(tls, asio::buffer(resp), write_ec);
|
|
|
|
if (write_ec)
|
|
std::cerr << "[tls-intercept] write error for " << host
|
|
<< ": " << write_ec.message() << "\n";
|
|
else
|
|
std::cerr << "[tls-intercept] page served for " << host << "\n";
|
|
|
|
boost::system::error_code sd_ec;
|
|
tls.shutdown(sd_ec);
|
|
boost::system::error_code ignore;
|
|
tls.lowest_layer().close(ignore);
|
|
|
|
} catch (const std::exception& e) {
|
|
std::cerr << "[tls-intercept] exception for " << host
|
|
<< ": " << e.what() << "\n";
|
|
boost::system::error_code ignore;
|
|
sock.close(ignore);
|
|
}
|
|
}
|
|
|
|
} // namespace TlsIntercept
|