diff --git a/README-websocket.md b/README-websocket.md index b7dc337b..ef9bf917 100644 --- a/README-websocket.md +++ b/README-websocket.md @@ -170,8 +170,10 @@ bool is_valid() const; // only when the handshake fully succeeded; on failure it describes what went // wrong: // res.error() httplib::Error identifying the failing layer -// res.status() HTTP status of the upgrade response (-1 if none) -// res.headers() headers of the upgrade response +// res.status() HTTP status of the upgrade response (-1 if none), +// or of the proxy's CONNECT reply when error() is +// Error::ProxyConnection +// res.headers() headers of that same response // res.ssl_error() TLS error detail (wss://, SSL builds only) // res.ssl_backend_error() backend-specific TLS error code (SSL builds only) Result connect(); @@ -187,6 +189,18 @@ void close(CloseStatus status = CloseStatus::Normal, const std::string &reason = ""); bool is_open() const; +// Proxy - the same setters Client has. connect() opens a CONNECT tunnel +// through the proxy for ws:// as well as wss:// (RFC 6455 4.1); the proxy +// credentials are sent only on the CONNECT request, never through the tunnel. +// Hosts matching set_no_proxy() are connected to directly. +void set_proxy(const std::string &host, int port); +void set_proxy_basic_auth(const std::string &username, + const std::string &password); +void set_proxy_bearer_token_auth(const std::string &token); +void set_no_proxy(const std::vector &patterns); +void set_proxy_digest_auth(const std::string &username, + const std::string &password); // SSL builds only + // Timeouts void set_read_timeout(time_t sec, time_t usec = 0); void set_write_timeout(time_t sec, time_t usec = 0); diff --git a/docs-src/pages/en/cookbook/c16-proxy.md b/docs-src/pages/en/cookbook/c16-proxy.md index fc88217f..d79ab5b4 100644 --- a/docs-src/pages/en/cookbook/c16-proxy.md +++ b/docs-src/pages/en/cookbook/c16-proxy.md @@ -69,6 +69,20 @@ Hostname matching is case-insensitive and uses a dot-boundary rule, so an entry Malformed entries are silently dropped. Port-specific entries such as `example.com:8080` are not supported (cpp-httplib's other host-keyed APIs are also keyed on hostname only). +## Use a proxy for WebSocket + +`ws::WebSocketClient` has the same proxy setters. + +```cpp +httplib::ws::WebSocketClient cli("wss://ws.example.com/chat"); +cli.set_proxy("proxy.internal", 8080); +cli.set_proxy_basic_auth("user", "password"); + +auto res = cli.connect(); +``` + +For both `ws://` and `wss://`, the client opens a tunnel with the CONNECT method and then performs the WebSocket handshake through it. `set_no_proxy()` works the same way. + ## Read proxy settings from the environment cpp-httplib doesn't touch `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` on its own — the config API is always explicit, the same way `set_ca_cert_path()` is. If you'd like that behavior, read the variables in your application and feed them to `set_proxy()` and `set_no_proxy()`. diff --git a/docs-src/pages/ja/cookbook/c16-proxy.md b/docs-src/pages/ja/cookbook/c16-proxy.md index a064be09..ce7f29e5 100644 --- a/docs-src/pages/ja/cookbook/c16-proxy.md +++ b/docs-src/pages/ja/cookbook/c16-proxy.md @@ -69,6 +69,20 @@ cli.set_no_proxy({"internal.corp", "10.0.0.0/8", ".dev.local"}); 不正な書式のエントリは黙って捨てられます。`example.com:8080`のようなポート指定エントリはサポート外です(cpp-httplibの他のホストキーAPIもホスト名のみを扱う設計のため)。 +## WebSocketでもプロキシを使う + +`ws::WebSocketClient`にも同じプロキシ設定があります。 + +```cpp +httplib::ws::WebSocketClient cli("wss://ws.example.com/chat"); +cli.set_proxy("proxy.internal", 8080); +cli.set_proxy_basic_auth("user", "password"); + +auto res = cli.connect(); +``` + +`ws://`でも`wss://`でも、CONNECTメソッドでトンネルを張ってからWebSocketのハンドシェイクを行います。`set_no_proxy()`も同じように使えます。 + ## 環境変数からプロキシ設定を読み込む cpp-httplib本体は`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`を読みません。`set_ca_cert_path()`と同じで、設定APIは常に明示的にしています。環境変数を反映させたい場合は、アプリ側で読んで`set_proxy()`や`set_no_proxy()`に渡してください。 diff --git a/httplib.h b/httplib.h index d5c70bdf..02c03845 100644 --- a/httplib.h +++ b/httplib.h @@ -4535,7 +4535,8 @@ enum ReadResult : int { Fail = 0, Text = 1, Binary = 2, Timeout = 3 }; // Result of WebSocketClient::connect(). Truthy only when the WebSocket // upgrade handshake fully succeeded. On failure error() identifies the // failing layer; status()/headers() expose the server's upgrade response -// when one was received (status() is -1 otherwise). +// when one was received (status() is -1 otherwise), or the proxy's reply to +// CONNECT when error() is ProxyConnection. class Result { public: Result() = default; @@ -4694,6 +4695,12 @@ public: void set_interface(const std::string &intf); void set_hostname_addr_map(std::map addr_map); + void set_proxy(const std::string &host, int port); + void set_proxy_basic_auth(const std::string &username, + const std::string &password); + void set_proxy_bearer_token_auth(const std::string &token); + void set_no_proxy(const std::vector &patterns); + #ifdef CPPHTTPLIB_SSL_ENABLED struct PemMemory { const char *cert_pem; @@ -4715,13 +4722,20 @@ public: void set_server_certificate_verifier(tls::VerifyCallback verifier); void set_session_verifier( std::function verifier); + void set_proxy_digest_auth(const std::string &username, + const std::string &password); #endif private: void shutdown_and_close(); + std::pair handshake_timeout() const; bool create_stream(std::unique_ptr &strm, Error &error, int &ssl_error, uint64_t &ssl_backend_error); void prepare_default_headers(Request &req); + bool is_proxy_enabled() const; + bool create_and_connect_socket(Error &error); + bool send_proxy_connect(Request &req, Response &proxy_res, Error &error); + bool connect_with_proxy(Response &proxy_res, Error &error); std::string host_; int port_; @@ -4751,6 +4765,13 @@ private: // hostname; only the connection target changes, never the identity. std::map addr_map_; + std::string proxy_host_; + int proxy_port_ = -1; + std::string proxy_basic_auth_username_; + std::string proxy_basic_auth_password_; + std::string proxy_bearer_token_auth_token_; + std::vector no_proxy_entries_; + #ifdef CPPHTTPLIB_SSL_ENABLED bool is_ssl_ = false; tls::ctx_t tls_ctx_ = nullptr; @@ -4763,6 +4784,8 @@ private: bool server_certificate_verification_ = true; bool server_hostname_verification_ = true; std::function session_verifier_; + std::string proxy_digest_auth_username_; + std::string proxy_digest_auth_password_; #endif }; @@ -8463,31 +8486,30 @@ struct WebSocketUpgradeResponse { std::string selected_subprotocol; }; +// Reads a response's status line and headers, leaving any body unread. An +// unparsable status line is no stream error; it just leaves `status` as is. +inline bool read_response_head(Stream &strm, int &status, Headers &headers) { + const auto bufsiz = 2048; + char buf[bufsiz]; + stream_line_reader line_reader(strm, buf, bufsiz); + if (!line_reader.getline()) { return false; } + + std::string version; + std::string reason; + if (!parse_status_line(line_reader.ptr(), version, status, reason)) { + return true; + } + return read_headers(strm, headers); +} + inline bool read_websocket_upgrade_response(Stream &strm, const std::string &expected_accept, const std::string &offered_subprotocols, WebSocketUpgradeResponse &upgrade) { - // Read status line - const auto bufsiz = 2048; - char buf[bufsiz]; - stream_line_reader line_reader(strm, buf, bufsiz); - if (!line_reader.getline()) { - upgrade.error = Error::Read; - return false; - } - - std::string version; - std::string reason; - if (!parse_status_line(line_reader.ptr(), version, upgrade.status, reason)) { - upgrade.error = Error::WebSocketHandshake; - return false; - } - - // Read the headers even for a rejection so the caller can see why the - // server refused the upgrade. A non-101 response may carry a body; it is - // deliberately left unread since the caller closes the socket right away. - if (!read_headers(strm, upgrade.headers)) { + // The headers of a rejection are kept so the caller can see why the server + // refused the upgrade. + if (!read_response_head(strm, upgrade.status, upgrade.headers)) { upgrade.error = Error::Read; return false; } @@ -10545,6 +10567,25 @@ inline bool is_host(const std::string &s) { } // namespace fields +// Built in memory first, like ClientImpl::write_request, so a rejected header +// never leaves a partial request on the wire. +inline bool write_request_head(Stream &strm, Request &req, Error &error) { + BufferStream bstrm; + if (write_request_line(bstrm, req.method, req.path) < 0) { + error = Error::Write; + return false; + } + if (!check_and_write_headers(bstrm, req.headers, write_headers, error)) { + return false; + } + const auto &data = bstrm.get_buffer(); + if (!write_data(strm, data.data(), data.size())) { + error = Error::Write; + return false; + } + return true; +} + inline bool perform_websocket_handshake(Stream &strm, Request &req, WebSocketUpgradeResponse &upgrade) { // Generate a random 16-byte Sec-WebSocket-Key (RFC 6455 Section 4.1) @@ -10562,28 +10603,7 @@ inline bool perform_websocket_handshake(Stream &strm, Request &req, req.headers.emplace("Sec-WebSocket-Key", client_key); req.headers.emplace("Sec-WebSocket-Version", "13"); - // Build the request in memory first, like ClientImpl::write_request does. - // Writing straight to the socket would leak a request line onto the wire - // before check_and_write_headers gets a chance to reject an invalid header, - // and would emit one small write per header. - BufferStream bstrm; - - if (write_request_line(bstrm, req.method, req.path) < 0) { - upgrade.error = Error::Write; - return false; - } - - auto error = Error::Success; - if (!check_and_write_headers(bstrm, req.headers, write_headers, error)) { - upgrade.error = error; - return false; - } - - const auto &data = bstrm.get_buffer(); - if (!write_data(strm, data.data(), data.size())) { - upgrade.error = Error::Write; - return false; - } + if (!write_request_head(strm, req, upgrade.error)) { return false; } // Verify 101 response and Sec-WebSocket-Accept header auto expected_accept = websocket_accept_key(client_key); @@ -11900,6 +11920,27 @@ make_bearer_token_authentication_header(const std::string &token, return std::make_pair(key, std::move(field)); } +namespace detail { + +// Basic auth when a username or password is set, otherwise a bearer token, +// unless the request already carries the header. +inline void add_authorization_header(Request &req, const std::string &username, + const std::string &password, + const std::string &token, bool is_proxy) { + if (req.has_header(is_proxy ? "Proxy-Authorization" : "Authorization")) { + return; + } + if (!username.empty() || !password.empty()) { + req.headers.insert( + make_basic_authentication_header(username, password, is_proxy)); + } else if (!token.empty()) { + req.headers.insert( + make_bearer_token_authentication_header(token, is_proxy)); + } +} + +} // namespace detail + // Request implementation inline size_t Request::get_header_value_u64(const std::string &key, size_t def, size_t id) const { @@ -12993,6 +13034,21 @@ inline bool parse_no_proxy_entry(const std::string &token, NoProxyEntry &out) { return true; } +inline std::vector +parse_no_proxy_entries(const std::vector &patterns) { + std::vector entries; + entries.reserve(patterns.size()); + for (const auto &p : patterns) { + auto trimmed = trim_copy(p); + if (trimmed.empty()) { continue; } + NoProxyEntry entry; + if (parse_no_proxy_entry(trimmed, entry)) { + entries.push_back(std::move(entry)); + } + } + return entries; +} + inline NormalizedTarget normalize_target(const std::string &host) { NormalizedTarget t; std::string h = host; @@ -16415,31 +16471,19 @@ inline bool ClientImpl::write_request(Stream &strm, Request &req, // it opens is read by the origin. Each credential goes only to its own hop. auto is_connect = req.method == "CONNECT"; - if (!is_connect && !req.has_header("Authorization")) { - if (!basic_auth_password_.empty() || !basic_auth_username_.empty()) { - req.headers.insert(make_basic_authentication_header( - basic_auth_username_, basic_auth_password_, false)); - } else if (!bearer_token_auth_token_.empty()) { - req.headers.insert(make_bearer_token_authentication_header( - bearer_token_auth_token_, false)); - } + if (!is_connect) { + detail::add_authorization_header(req, basic_auth_username_, + basic_auth_password_, + bearer_token_auth_token_, false); } // Proxy-Authorization is only sent when the proxy reads this message — // otherwise NO_PROXY-matched requests, and requests inside a TLS tunnel, // would leak proxy credentials to the destination server. if (is_proxy_enabled_for_host(host_) && (!is_ssl() || is_connect)) { - if ((!proxy_basic_auth_username_.empty() || - !proxy_basic_auth_password_.empty()) && - !req.has_header("Proxy-Authorization")) { - req.headers.insert(make_basic_authentication_header( - proxy_basic_auth_username_, proxy_basic_auth_password_, true)); - } - if (!proxy_bearer_token_auth_token_.empty() && - !req.has_header("Proxy-Authorization")) { - req.headers.insert(make_bearer_token_authentication_header( - proxy_bearer_token_auth_token_, true)); - } + detail::add_authorization_header(req, proxy_basic_auth_username_, + proxy_basic_auth_password_, + proxy_bearer_token_auth_token_, true); } // Request line and headers @@ -17924,17 +17968,7 @@ inline void ClientImpl::set_proxy_bearer_token_auth(const std::string &token) { } inline void ClientImpl::set_no_proxy(const std::vector &patterns) { - std::vector parsed; - parsed.reserve(patterns.size()); - for (const auto &p : patterns) { - auto trimmed = detail::trim_copy(p); - if (trimmed.empty()) { continue; } - detail::NoProxyEntry entry; - if (detail::parse_no_proxy_entry(trimmed, entry)) { - parsed.push_back(std::move(entry)); - } - } - no_proxy_entries_ = std::move(parsed); + no_proxy_entries_ = detail::parse_no_proxy_entries(patterns); std::lock_guard guard(socket_mutex_); disconnect(/*gracefully=*/true); } @@ -23527,6 +23561,15 @@ inline void WebSocketClient::shutdown_and_close() { } } +// The handshakes belong to establishing the connection, so an unset read +// timeout leaves them bounded by the connection timeout instead of forever. +inline std::pair WebSocketClient::handshake_timeout() const { + if (read_timeout_sec_ == 0 && read_timeout_usec_ == 0) { + return {connection_timeout_sec_, connection_timeout_usec_}; + } + return {read_timeout_sec_, read_timeout_usec_}; +} + inline bool WebSocketClient::create_stream(std::unique_ptr &strm, Error &error, int &ssl_error, uint64_t &ssl_backend_error) { @@ -23536,10 +23579,6 @@ inline bool WebSocketClient::create_stream(std::unique_ptr &strm, auto unbounded = read_timeout_sec_ == 0 && read_timeout_usec_ == 0; time_t strm_read_sec = unbounded ? -1 : read_timeout_sec_; time_t strm_read_usec = unbounded ? 0 : read_timeout_usec_; - // The handshake belongs to establishing the connection, so an unset read - // timeout leaves it bounded by the connection timeout instead of forever. - time_t hs_sec = unbounded ? connection_timeout_sec_ : read_timeout_sec_; - time_t hs_usec = unbounded ? connection_timeout_usec_ : read_timeout_usec_; #ifdef CPPHTTPLIB_SSL_ENABLED if (is_ssl_) { // A plain flag rather than SSLClient::load_certs()'s call_once: connect() @@ -23557,11 +23596,12 @@ inline bool WebSocketClient::create_stream(std::unique_ptr &strm, options.server_hostname_verification = server_hostname_verification_; options.session_verifier = session_verifier_; + auto timeout = handshake_timeout(); detail::ClientTlsSessionError tls_error; if (!detail::setup_client_tls_session(host_, tls_ctx_, tls_session_, sock_, server_certificate_verification_, - hs_sec, hs_usec, &tls_error, - options)) { + timeout.first, timeout.second, + &tls_error, options)) { error = tls_error.error; ssl_error = tls_error.ssl_error; ssl_backend_error = tls_error.backend_error; @@ -23577,8 +23617,6 @@ inline bool WebSocketClient::create_stream(std::unique_ptr &strm, (void)error; (void)ssl_error; (void)ssl_backend_error; - (void)hs_sec; - (void)hs_usec; #endif strm = std::unique_ptr( new detail::SocketStream(sock_, strm_read_sec, strm_read_usec, @@ -23605,23 +23643,19 @@ inline Result WebSocketClient::connect() { if (!is_valid_) { return Result{Error::Connection, -1, Headers{}}; } shutdown_and_close(); - // Check is custom IP or hostname specified for host_ - std::string connect_host; - std::string ip; - detail::apply_addr_map(addr_map_, host_, connect_host, ip); - auto error = Error::Success; - sock_ = detail::create_client_socket( - connect_host, ip, port_, address_family_, tcp_nodelay_, ipv6_v6only_, - socket_options_, connection_timeout_sec_, connection_timeout_usec_, - read_timeout_sec_, read_timeout_usec_, write_timeout_sec_, - write_timeout_usec_, interface_, error); - - if (sock_ == INVALID_SOCKET) { - if (error == Error::Success) { error = Error::Connection; } + if (!create_and_connect_socket(error)) { return Result{error, -1, Headers{}}; } + if (is_proxy_enabled()) { + Response proxy_res; + if (!connect_with_proxy(proxy_res, error)) { + shutdown_and_close(); + return Result{error, proxy_res.status, std::move(proxy_res.headers)}; + } + } + std::unique_ptr strm; auto stream_error = Error::SSLConnection; int ssl_error = 0; @@ -23731,6 +23765,110 @@ inline void WebSocketClient::set_hostname_addr_map( addr_map_ = std::move(addr_map); } +inline bool WebSocketClient::is_proxy_enabled() const { + if (proxy_host_.empty() || proxy_port_ == -1) { return false; } + if (no_proxy_entries_.empty()) { return true; } + return !detail::host_matches_no_proxy(detail::normalize_target(host_), + no_proxy_entries_); +} + +inline bool WebSocketClient::create_and_connect_socket(Error &error) { + std::string connect_host; + std::string ip; + auto port = port_; + if (is_proxy_enabled()) { + connect_host = proxy_host_; + port = proxy_port_; + } else { + detail::apply_addr_map(addr_map_, host_, connect_host, ip); + } + + sock_ = detail::create_client_socket( + connect_host, ip, port, address_family_, tcp_nodelay_, ipv6_v6only_, + socket_options_, connection_timeout_sec_, connection_timeout_usec_, + read_timeout_sec_, read_timeout_usec_, write_timeout_sec_, + write_timeout_usec_, interface_, error); + if (sock_ != INVALID_SOCKET) { return true; } + if (error == Error::Success) { error = Error::Connection; } + return false; +} + +inline bool WebSocketClient::send_proxy_connect(Request &req, + Response &proxy_res, + Error &error) { + auto timeout = handshake_timeout(); + detail::SocketStream strm(sock_, timeout.first, timeout.second, + write_timeout_sec_, write_timeout_usec_); + + if (!detail::write_request_head(strm, req, error)) { return false; } + if (!detail::read_response_head(strm, proxy_res.status, proxy_res.headers)) { + error = Error::Read; + return false; + } + return true; +} + +// RFC 6455 4.1: ws:// is tunnelled with CONNECT as well, since a proxy need not +// forward an Upgrade request. +inline bool WebSocketClient::connect_with_proxy(Response &proxy_res, + Error &error) { + Request req; + req.method = "CONNECT"; + req.path = detail::make_host_and_port_string_always_port(host_, port_); + req.headers.emplace("Host", req.path); + detail::add_default_user_agent_header(req); + detail::add_authorization_header(req, proxy_basic_auth_username_, + proxy_basic_auth_password_, + proxy_bearer_token_auth_token_, true); + if (!send_proxy_connect(req, proxy_res, error)) { return false; } + +#ifdef CPPHTTPLIB_SSL_ENABLED + // A Digest challenge gets one retry on a fresh connection, like SSLClient. + std::map auth; + if (proxy_res.status == StatusCode::ProxyAuthenticationRequired_407 && + !proxy_digest_auth_username_.empty() && + !proxy_digest_auth_password_.empty() && + detail::parse_www_authenticate(proxy_res, auth, true)) { + proxy_res = Response(); + shutdown_and_close(); + if (!create_and_connect_socket(error)) { return false; } + + req.headers.erase("Proxy-Authorization"); + req.headers.insert(detail::make_digest_authentication_header( + req, req.path, auth, 1, detail::random_string(10), + proxy_digest_auth_username_, proxy_digest_auth_password_, true)); + if (!send_proxy_connect(req, proxy_res, error)) { return false; } + } +#endif + + if (proxy_res.status != StatusCode::OK_200) { + error = Error::ProxyConnection; + return false; + } + return true; +} + +inline void WebSocketClient::set_proxy(const std::string &host, int port) { + proxy_host_ = host; + proxy_port_ = port; +} + +inline void WebSocketClient::set_proxy_basic_auth(const std::string &username, + const std::string &password) { + proxy_basic_auth_username_ = username; + proxy_basic_auth_password_ = password; +} + +inline void +WebSocketClient::set_proxy_bearer_token_auth(const std::string &token) { + proxy_bearer_token_auth_token_ = token; +} + +inline void +WebSocketClient::set_no_proxy(const std::vector &patterns) { + no_proxy_entries_ = detail::parse_no_proxy_entries(patterns); +} + #ifdef CPPHTTPLIB_SSL_ENABLED inline void @@ -23781,6 +23919,13 @@ inline void WebSocketClient::enable_system_ca(bool enabled) { system_ca_mode_ = enabled ? SystemCAMode::Enabled : SystemCAMode::Disabled; } +inline void +WebSocketClient::set_proxy_digest_auth(const std::string &username, + const std::string &password) { + proxy_digest_auth_username_ = username; + proxy_digest_auth_password_ = password; +} + #endif // CPPHTTPLIB_SSL_ENABLED } // namespace ws diff --git a/test/test.cc b/test/test.cc index 2f16078b..cd9cdfba 100644 --- a/test/test.cc +++ b/test/test.cc @@ -29682,7 +29682,158 @@ TEST(NoProxyTest, KeepAliveSocketInvalidatedOnSetNoProxy) { EXPECT_EQ(1, target_hits.load()); } -#if defined(CPPHTTPLIB_SSL_ENABLED) && !defined(_WIN32) +// Forward declarations (see the base64_encode note). +namespace httplib { +namespace detail { +ssize_t select_read(socket_t sock, time_t sec, time_t usec); +int shutdown_socket(socket_t sock) noexcept; +} // namespace detail +} // namespace httplib + +namespace proxy_tunnel_test { + +const char *const kTunnelEstablished = + "HTTP/1.1 200 Connection established\r\n\r\n"; + +// Answers the head of a CONNECT request; a 200 opens the tunnel. +using ConnectReply = std::function; + +// Accepts CONNECT on 127.0.0.1, answers it with `reply` (200 by default) and, +// once tunnelled, byte-forwards to 127.0.0.1:forward_port. +class ScopedConnectProxy { +public: + explicit ScopedConnectProxy(int forward_port, ConnectReply reply = nullptr) + : forward_port_(forward_port), reply_(std::move(reply)) { + listen_sock_ = ::socket(AF_INET, SOCK_STREAM, 0); + if (listen_sock_ == INVALID_SOCKET) { return; } + auto a = loopback(0); + auto addr = reinterpret_cast(&a); + socklen_t alen = sizeof(a); + if (::bind(listen_sock_, addr, sizeof(a)) != 0 || + ::getsockname(listen_sock_, addr, &alen) != 0 || + ::listen(listen_sock_, 4) != 0) { + return; + } + port_ = ntohs(a.sin_port); + th_ = std::thread([this] { run(); }); + } + + ~ScopedConnectProxy() { + if (th_.joinable()) { + stop_ = true; + auto wake = connect_to(port_); + th_.join(); + if (wake != INVALID_SOCKET) { detail::close_socket(wake); } + } + if (listen_sock_ != INVALID_SOCKET) { detail::close_socket(listen_sock_); } + } + + int port() const { return port_; } + int connect_hits() const { return connect_hits_.load(); } + // Head of the last CONNECT request, as the proxy saw it on the wire. + std::string connect_request() const { + std::lock_guard lock(connect_request_mutex_); + return connect_request_; + } + +private: + static sockaddr_in loopback(int port) { + sockaddr_in a{}; + a.sin_family = AF_INET; + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + a.sin_port = htons(static_cast(port)); + return a; + } + + static socket_t connect_to(int port) { + auto sock = ::socket(AF_INET, SOCK_STREAM, 0); + if (sock == INVALID_SOCKET) { return sock; } + auto a = loopback(port); + if (::connect(sock, reinterpret_cast(&a), sizeof(a)) != 0) { + detail::close_socket(sock); + return INVALID_SOCKET; + } + return sock; + } + + static bool send_all(socket_t sock, const char *data, size_t size) { + while (size > 0) { + auto n = detail::send_socket(sock, data, size, 0); + if (n <= 0) { return false; } + data += n; + size -= static_cast(n); + } + return true; + } + + void run() { + for (;;) { + auto client = ::accept(listen_sock_, nullptr, nullptr); + if (stop_) { + if (client != INVALID_SOCKET) { detail::close_socket(client); } + return; + } + if (client == INVALID_SOCKET) { continue; } + serve(client); + detail::close_socket(client); + } + } + + void serve(socket_t client) { + std::string head; + char buf[2048]; + while (head.find("\r\n\r\n") == std::string::npos) { + if (detail::select_read(client, 5, 0) <= 0) { return; } + auto n = detail::read_socket(client, buf, sizeof(buf), 0); + if (n <= 0) { return; } + head.append(buf, static_cast(n)); + } + if (head.compare(0, 8, "CONNECT ") != 0) { return; } + connect_hits_++; + { + std::lock_guard lock(connect_request_mutex_); + connect_request_ = head; + } + + auto res = reply_ ? reply_(head) : std::string(kTunnelEstablished); + if (!send_all(client, res.data(), res.size()) || + res.compare(0, 12, "HTTP/1.1 200") != 0) { + return; + } + + auto origin = connect_to(forward_port_); + if (origin == INVALID_SOCKET) { return; } + + // When either side stops, shut both down so the other pump returns too. + auto pump = [&](socket_t from, socket_t to) { + char b[8192]; + for (;;) { + auto n = detail::read_socket(from, b, sizeof(b), 0); + if (n <= 0 || !send_all(to, b, static_cast(n))) { break; } + } + detail::shutdown_socket(client); + detail::shutdown_socket(origin); + }; + std::thread up([&] { pump(client, origin); }); + pump(origin, client); + up.join(); + detail::close_socket(origin); + } + + int forward_port_; + ConnectReply reply_; + socket_t listen_sock_ = INVALID_SOCKET; + int port_ = 0; + std::thread th_; + std::atomic stop_{false}; + std::atomic connect_hits_{0}; + mutable std::mutex connect_request_mutex_; + std::string connect_request_; +}; + +} // namespace proxy_tunnel_test + +#ifdef CPPHTTPLIB_SSL_ENABLED namespace proxy_tunnel_test { // SSLServer counterpart to no_proxy_test::ScopedServer. @@ -29708,130 +29859,6 @@ private: int port_ = 0; }; -// Accepts CONNECT, replies 200, byte-forwards to forward_port. -class ScopedConnectProxy { -public: - explicit ScopedConnectProxy(int forward_port) : forward_port_(forward_port) { - listen_fd_ = ::socket(AF_INET, SOCK_STREAM, 0); - if (listen_fd_ < 0) { return; } - int yes = 1; - ::setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); - sockaddr_in a{}; - a.sin_family = AF_INET; - a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - a.sin_port = 0; - if (::bind(listen_fd_, reinterpret_cast(&a), sizeof(a)) != 0) { - return; - } - socklen_t alen = sizeof(a); - if (::getsockname(listen_fd_, reinterpret_cast(&a), &alen) != - 0) { - return; - } - port_ = ntohs(a.sin_port); - if (::listen(listen_fd_, 1) != 0) { return; } - th_ = std::thread([this] { run(); }); - } - - ~ScopedConnectProxy() { - stop_ = true; - if (listen_fd_ >= 0) { - ::shutdown(listen_fd_, SHUT_RDWR); - ::close(listen_fd_); - } - if (th_.joinable()) { th_.join(); } - } - - int port() const { return port_; } - int connect_hits() const { return connect_hits_.load(); } - // Head of the last CONNECT request, as the proxy saw it on the wire. - std::string connect_request() const { - std::lock_guard lock(connect_request_mutex_); - return connect_request_; - } - -private: - void run() { - while (!stop_.load()) { - fd_set rfds; - FD_ZERO(&rfds); - FD_SET(listen_fd_, &rfds); - timeval tv{0, 100 * 1000}; - int sel = ::select(listen_fd_ + 1, &rfds, nullptr, nullptr, &tv); - if (sel <= 0) { continue; } - - int client_fd = ::accept(listen_fd_, nullptr, nullptr); - if (client_fd < 0) { continue; } - - std::string req; - char buf[2048]; - while (req.find("\r\n\r\n") == std::string::npos) { - ssize_t n = ::recv(client_fd, buf, sizeof(buf), 0); - if (n <= 0) { break; } - req.append(buf, static_cast(n)); - } - if (req.compare(0, 7, "CONNECT") != 0) { - ::close(client_fd); - continue; - } - connect_hits_++; - { - std::lock_guard lock(connect_request_mutex_); - connect_request_ = req; - } - - const char *ok = "HTTP/1.1 200 Connection established\r\n\r\n"; - ::send(client_fd, ok, std::strlen(ok), 0); - - int origin_fd = ::socket(AF_INET, SOCK_STREAM, 0); - sockaddr_in o{}; - o.sin_family = AF_INET; - o.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - o.sin_port = htons(static_cast(forward_port_)); - if (::connect(origin_fd, reinterpret_cast(&o), sizeof(o)) != - 0) { - ::close(client_fd); - ::close(origin_fd); - continue; - } - - auto forward = [](int from, int to) { - char b[8192]; - for (;;) { - ssize_t n = ::recv(from, b, sizeof(b), 0); - if (n <= 0) { break; } - ssize_t off = 0; - while (off < n) { - ssize_t s = ::send(to, b + off, static_cast(n - off), 0); - if (s <= 0) { - off = n; - break; - } - off += s; - } - } - ::shutdown(to, SHUT_WR); - }; - - std::thread t1([&] { forward(client_fd, origin_fd); }); - std::thread t2([&] { forward(origin_fd, client_fd); }); - t1.join(); - t2.join(); - ::close(client_fd); - ::close(origin_fd); - } - } - - int forward_port_ = -1; - int listen_fd_ = -1; - int port_ = 0; - std::thread th_; - std::atomic stop_{false}; - std::atomic connect_hits_{0}; - mutable std::mutex connect_request_mutex_; - std::string connect_request_; -}; - // Sends one request through the CONNECT proxy to the TLS origin with a // credential configured for each hop, and checks that each credential reaches // only the hop it was configured for: the proxy's on the CONNECT request, the @@ -29953,3 +29980,251 @@ TEST(ProxyTunnelTest, OriginReturning407InsideTunnelDoesNotLeakProxyDigest) { EXPECT_EQ(1, proxy.connect_hits()); } #endif + +namespace proxy_tunnel_test { + +// Tunnels only a CONNECT carrying `expected` as its Proxy-Authorization; +// anything else gets a 407 with `challenge`. +ConnectReply require_proxy_auth(const std::string &expected, + const std::string &challenge) { + return [=](const std::string &head) -> std::string { + if (head.find("\r\nProxy-Authorization: " + expected + "\r\n") != + std::string::npos) { + return kTunnelEstablished; + } + return "HTTP/1.1 407 Proxy Authentication Required\r\n" + "Proxy-Authenticate: " + + challenge + "\r\nContent-Length: 0\r\n\r\n"; + }; +} + +// Reports the Authorization and Proxy-Authorization headers of the upgrade +// request, then echoes. +void report_auth_then_echo(const Request &req, ws::WebSocket &ws) { + ws.send( + "authorization:" + req.get_header_value("Authorization") + + "|proxy-authorization:" + req.get_header_value("Proxy-Authorization")); + std::string msg; + while (ws.read(msg)) { + ws.send(msg); + } +} + +// A WebSocket origin on 127.0.0.1 serving report_auth_then_echo at /ws. +class ScopedWebSocketOrigin { +public: + ScopedWebSocketOrigin() { + server_.svr().WebSocket("/ws", report_auth_then_echo); + server_.listen(); + } + + int port() const { return server_.port(); } + // Pinned to 127.0.0.1, the only address the proxy forwards to. + std::string url() const { + return "ws://127.0.0.1:" + std::to_string(port()) + "/ws"; + } + +private: + no_proxy_test::ScopedServer server_; +}; + +// Connects through a proxy that requires `proxy_authz`, with `origin_authz` +// passed as a constructor header, and checks that each reaches only its hop. +void WebSocketCredentialsStayWithTheirHop( + const std::function &set_proxy_credentials, + const std::string &proxy_authz, const std::string &origin_authz) { + ScopedWebSocketOrigin origin; + ScopedConnectProxy proxy( + origin.port(), require_proxy_auth(proxy_authz, "Basic realm=\"proxy\"")); + ASSERT_NE(0, proxy.port()); + + ws::WebSocketClient client(origin.url(), {{"Authorization", origin_authz}}); + client.set_proxy("127.0.0.1", proxy.port()); + set_proxy_credentials(client); + auto res = client.connect(); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + + std::string msg; + ASSERT_TRUE(client.read(msg)); + EXPECT_EQ("authorization:" + origin_authz + "|proxy-authorization:", msg); + client.close(); + + EXPECT_EQ(1, proxy.connect_hits()); + EXPECT_EQ(std::string::npos, + proxy.connect_request().find("\r\nAuthorization: ")) + << "origin credentials must not be sent to the proxy"; +} + +} // namespace proxy_tunnel_test + +TEST(WebSocketProxyTest, TunnelsThroughConnect) { + proxy_tunnel_test::ScopedWebSocketOrigin origin; + proxy_tunnel_test::ScopedConnectProxy proxy(origin.port()); + ASSERT_NE(0, proxy.port()); + + ws::WebSocketClient client(origin.url()); + client.set_proxy("127.0.0.1", proxy.port()); + auto res = client.connect(); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + + std::string msg; + ASSERT_TRUE(client.read(msg)); + ASSERT_TRUE(client.send("hello")); + ASSERT_TRUE(client.read(msg)); + EXPECT_EQ("hello", msg); + client.close(); + + EXPECT_EQ(1, proxy.connect_hits()); + auto target = "127.0.0.1:" + std::to_string(origin.port()); + auto connect_req = proxy.connect_request(); + EXPECT_EQ(0u, connect_req.find("CONNECT " + target + " HTTP/1.1\r\n")); + EXPECT_NE(std::string::npos, + connect_req.find("\r\nHost: " + target + "\r\n")); +} + +TEST(WebSocketProxyTest, BasicCredentialsStayWithTheirHop) { + proxy_tunnel_test::WebSocketCredentialsStayWithTheirHop( + [](ws::WebSocketClient &cli) { + cli.set_proxy_basic_auth("proxy-user", "proxy-pass"); + }, + "Basic " + detail::base64_encode("proxy-user:proxy-pass"), + "Basic " + detail::base64_encode("origin-user:origin-pass")); +} + +TEST(WebSocketProxyTest, BearerCredentialsStayWithTheirHop) { + proxy_tunnel_test::WebSocketCredentialsStayWithTheirHop( + [](ws::WebSocketClient &cli) { + cli.set_proxy_bearer_token_auth("proxy-token"); + }, + "Bearer proxy-token", "Bearer origin-token"); +} + +TEST(WebSocketProxyTest, BasicCredentialsWithEmptyPasswordAreSent) { + proxy_tunnel_test::ScopedWebSocketOrigin origin; + proxy_tunnel_test::ScopedConnectProxy proxy( + origin.port(), proxy_tunnel_test::require_proxy_auth( + "Basic " + detail::base64_encode("proxy-user:"), + "Basic realm=\"proxy\"")); + ASSERT_NE(0, proxy.port()); + + ws::WebSocketClient client(origin.url()); + client.set_proxy("127.0.0.1", proxy.port()); + client.set_proxy_basic_auth("proxy-user", ""); + auto res = client.connect(); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + client.close(); + + EXPECT_EQ(1, proxy.connect_hits()); +} + +TEST(WebSocketProxyTest, ProxyRejectionReportsProxyConnection) { + proxy_tunnel_test::ScopedWebSocketOrigin origin; + proxy_tunnel_test::ScopedConnectProxy proxy( + origin.port(), proxy_tunnel_test::require_proxy_auth( + "Bearer proxy-token", "Basic realm=\"proxy\"")); + ASSERT_NE(0, proxy.port()); + + ws::WebSocketClient client(origin.url()); + client.set_proxy("127.0.0.1", proxy.port()); + auto res = client.connect(); + EXPECT_FALSE(res); + EXPECT_EQ(Error::ProxyConnection, res.error()); + EXPECT_EQ(StatusCode::ProxyAuthenticationRequired_407, res.status()); + EXPECT_EQ("Basic realm=\"proxy\"", + res.get_header_value("Proxy-Authenticate")); + EXPECT_FALSE(client.is_open()); +} + +TEST(WebSocketProxyTest, NoProxyBypassesProxy) { + proxy_tunnel_test::ScopedWebSocketOrigin origin; + proxy_tunnel_test::ScopedConnectProxy proxy(origin.port()); + ASSERT_NE(0, proxy.port()); + + ws::WebSocketClient client(origin.url()); + client.set_proxy("127.0.0.1", proxy.port()); + client.set_proxy_basic_auth("proxy-user", "proxy-pass"); + client.set_no_proxy({"127.0.0.1"}); + auto res = client.connect(); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + + std::string msg; + ASSERT_TRUE(client.read(msg)); + EXPECT_EQ("authorization:|proxy-authorization:", msg) + << "proxy credentials must not reach a bypassed origin"; + client.close(); + + EXPECT_EQ(0, proxy.connect_hits()); +} + +TEST(WebSocketProxyTest, ProxySettingAppliesOnReconnect) { + proxy_tunnel_test::ScopedWebSocketOrigin origin; + proxy_tunnel_test::ScopedConnectProxy proxy(origin.port()); + ASSERT_NE(0, proxy.port()); + + ws::WebSocketClient client(origin.url()); + client.set_proxy("127.0.0.1", proxy.port()); + ASSERT_TRUE(client.connect()); + ASSERT_TRUE(client.connect()); + client.close(); + + EXPECT_EQ(2, proxy.connect_hits()); +} + +#ifdef CPPHTTPLIB_SSL_ENABLED +TEST(WebSocketProxyTest, DigestRetriesConnectOnFreshSocket) { + proxy_tunnel_test::ScopedWebSocketOrigin origin; + proxy_tunnel_test::ScopedConnectProxy proxy( + origin.port(), [](const std::string &head) -> std::string { + if (head.find( + "\r\nProxy-Authorization: Digest username=\"proxy-user\"") != + std::string::npos) { + return proxy_tunnel_test::kTunnelEstablished; + } + return "HTTP/1.1 407 Proxy Authentication Required\r\n" + "Proxy-Authenticate: Digest realm=\"proxy\", qop=\"auth\", " + "nonce=\"abc\", algorithm=MD5\r\n" + "Content-Length: 0\r\n\r\n"; + }); + ASSERT_NE(0, proxy.port()); + + ws::WebSocketClient client(origin.url()); + client.set_proxy("127.0.0.1", proxy.port()); + client.set_proxy_digest_auth("proxy-user", "proxy-pass"); + auto res = client.connect(); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + + std::string msg; + ASSERT_TRUE(client.read(msg)); + EXPECT_EQ("authorization:|proxy-authorization:", msg); + client.close(); + + EXPECT_EQ(2, proxy.connect_hits()); +} + +TEST(WebSocketProxyTest, WssTunnelsThroughConnect) { + proxy_tunnel_test::ScopedSSLServer origin; + origin.svr().WebSocket("/ws", proxy_tunnel_test::report_auth_then_echo); + origin.listen(); + + proxy_tunnel_test::ScopedConnectProxy proxy( + origin.port(), proxy_tunnel_test::require_proxy_auth( + "Bearer proxy-token", "Bearer realm=\"proxy\"")); + ASSERT_NE(0, proxy.port()); + + ws::WebSocketClient client( + "wss://127.0.0.1:" + std::to_string(origin.port()) + "/ws"); + client.enable_server_certificate_verification(false); + client.set_proxy("127.0.0.1", proxy.port()); + client.set_proxy_bearer_token_auth("proxy-token"); + auto res = client.connect(); + ASSERT_TRUE(res) << "Error: " << to_string(res.error()); + + std::string msg; + ASSERT_TRUE(client.read(msg)); + EXPECT_EQ("authorization:|proxy-authorization:", msg) + << "Proxy-Authorization must not be sent inside the tunnel"; + client.close(); + + EXPECT_EQ(1, proxy.connect_hits()); +} +#endif