From fbab72c182663f42652364ac75b745787420127a Mon Sep 17 00:00:00 2001 From: ithewei Date: Mon, 21 Sep 2026 23:30:24 +0800 Subject: [PATCH 1/4] feat(http): support https over proxy via HTTP CONNECT The HttpClient built-in (non-curl) path advertised setHttpsProxy but for an https target it fell back to plain absolute-URI forwarding and skipped TLS, so the request was sent in the clear (and rejected by real HTTPS proxies). Add a proper HTTP CONNECT tunnel, reusing the io-layer proxy framework from #885. event layer (reusable by any io client): - hloop.h: add PROXY_PROTOCOL_HTTP_CONNECT to proxy_protocol_e. - socks5.{h,c}: http_connect_build_request() (CONNECT authority-form request + Host + optional Basic Proxy-Authorization); proxy_conn_t rbuf grown to 1024 for HTTP response headers. - nio.c: http_connect_handshake state (accumulate until CRLFCRLF, require 2xx), dispatched by proxy_handshake_start; rename socks5_* handshake helpers to proxy_* since they are now shared. Handshake sends via raw proxy_send (no upper-layer write_cb, no premature hssl_write), matching SOCKS5. HttpClient: - HttpMessage: HttpRequest gains tunnel_proxy_host/port/username/password and SetTunnelProxy()/IsTunnelProxy(), distinct from the plain-HTTP bit. - make_request: https+proxy -> SetTunnelProxy (CONNECT tunnel, end-to-end TLS); http+proxy -> absolute-URI forward + optional Basic Proxy-Authorization. - sync: extract http_client_ssl_handshake(); add blocking http_client_http_connect + http_client_connect_tunnel (connect proxy -> CONNECT -> TLS to origin). - async: doTask connects to the proxy for tunnels; doTaskWithAddr sets hio_set_proxy(HTTP_CONNECT) + enableSSL(origin SNI); tunnels are never pooled. Fix a latent UAF: snapshot IsTunnelProxy() before successCallback() frees the task/req. - curl path: map tunnel proxy to CURLOPT_PROXY(+PROXYUSERPWD); curl issues its own CONNECT. - add http_client_set_proxy_auth / HttpClient::setProxyAuth. Verified end-to-end (sync + async) against a CONNECT-capable proxy and an https origin: http-forward, https-CONNECT, CONNECT+Basic-auth all return 200; bad proxy creds are rejected. SOCKS5 (#885) regression re-checked: real proxy (ipv4/domain target) and fragmented fake proxy still deliver data. Co-authored-by: TRAE CLI --- docs/cn/HttpClient.md | 6 +- event/hevent.c | 7 +- event/hloop.h | 12 +- event/nio.c | 122 ++++++++++++---- event/socks5.c | 35 +++++ event/socks5.h | 18 ++- http/HttpMessage.cpp | 4 + http/HttpMessage.h | 22 ++- http/client/AsyncHttpClient.cpp | 43 ++++-- http/client/HttpClient.cpp | 243 +++++++++++++++++++++++++------- http/client/HttpClient.h | 6 + 11 files changed, 416 insertions(+), 102 deletions(-) diff --git a/docs/cn/HttpClient.md b/docs/cn/HttpClient.md index d67b57ed2..1eedc3403 100644 --- a/docs/cn/HttpClient.md +++ b/docs/cn/HttpClient.md @@ -21,12 +21,14 @@ class HttpClient { // 获取请求头部 const char* getHeader(const char* key); - // 设置http代理 + // 设置http代理(明文HTTP走绝对URI转发) int setHttpProxy(const char* host, int port); - // 设置https代理 + // 设置https代理(HTTPS走HTTP CONNECT隧道,与目标端到端TLS) int setHttpsProxy(const char* host, int port); // 添加不走代理 int addNoProxy(const char* host); + // 设置代理认证(http转发用Basic头,CONNECT隧道用Proxy-Authorization) + int setProxyAuth(const char* username, const char* password); // 同步发送 int send(HttpRequest* req, HttpResponse* resp); diff --git a/event/hevent.c b/event/hevent.c index cd554f285..2332e8758 100644 --- a/event/hevent.c +++ b/event/hevent.c @@ -499,8 +499,11 @@ const char* hio_get_hostname(hio_t* io) { int hio_set_proxy(hio_t* io, proxy_setting_t* setting) { if (io == NULL || setting == NULL) return -1; - // only SOCKS5 is implemented so far - if (setting->protocol != PROXY_PROTOCOL_SOCKS5) return -1; + // implemented: SOCKS5, HTTP CONNECT + if (setting->protocol != PROXY_PROTOCOL_SOCKS5 && + setting->protocol != PROXY_PROTOCOL_HTTP_CONNECT) { + return -1; + } if (io->proxy == NULL) { HV_ALLOC_SIZEOF(io->proxy); if (io->proxy == NULL) return -1; diff --git a/event/hloop.h b/event/hloop.h index cc6aec42b..ec7541f97 100644 --- a/event/hloop.h +++ b/event/hloop.h @@ -359,17 +359,19 @@ HV_EXPORT const char* hio_get_hostname(hio_t* io); // already the proxy connection). // // The setting is copied. Leave username empty for no auth, or set -// username/password for auth (SOCKS5 => RFC 1929). Only PROXY_PROTOCOL_SOCKS5 -// is implemented so far. +// username/password for auth (SOCKS5 => RFC 1929, HTTP CONNECT => Basic). +// Implemented protocols: PROXY_PROTOCOL_SOCKS5, PROXY_PROTOCOL_HTTP_CONNECT. // NOTE: set before hio_connect(). typedef enum { - PROXY_PROTOCOL_NONE = 0, - PROXY_PROTOCOL_SOCKS5 = 1, + PROXY_PROTOCOL_NONE = 0, + PROXY_PROTOCOL_SOCKS5 = 1, + PROXY_PROTOCOL_HTTP_CONNECT = 2, // HTTP CONNECT tunnel (RFC 7231 4.3.6) } proxy_protocol_e; typedef struct proxy_setting_s { int protocol; // proxy_protocol_e - char proxy_host[256]; // proxy host (SOCKS5: unused, socket is the proxy) + char proxy_host[256]; // proxy host (unused by the io layer: the socket + // is already the proxy connection; kept for ref) int proxy_port; char target_host[256]; // final target the proxy should CONNECT to int target_port; diff --git a/event/nio.c b/event/nio.c index 202e14519..5dbfaa5a1 100644 --- a/event/nio.c +++ b/event/nio.c @@ -263,9 +263,9 @@ enum socks5_state_e { static void socks5_handshake(hio_t* io); -static void socks5_fail(hio_t* io) { +static void proxy_fail(hio_t* io) { if (io->error == 0) io->error = ERR_CONNECT; - hlogw("connfd=%d socks5 handshake error", io->fd); + hlogw("connfd=%d proxy handshake error", io->fd); hio_close(io); } @@ -277,16 +277,16 @@ static void socks5_expect(hio_t* io, int state, int want) { s5->want = want; } -// Raw handshake send. The SOCKS5 handshake runs immediately after the TCP +// Raw handshake send. The proxy handshake runs immediately after the TCP // connection to the proxy is established, when the socket send buffer is empty -// and the messages are tiny (<= 513 bytes), so a short write is not expected. -// We deliberately do NOT use hio_write() here: it would invoke the upper-layer -// write_cb (leaking handshake bytes, including credentials, to the application -// before onConnection), dispatch to hssl_write() with a not-yet-created SSL -// handle for a TLS target, and enqueue on EAGAIN via hio_add() which would -// clobber the handshake read handler. A short write or error is treated as -// fatal and closes the connection. -static int socks5_send(hio_t* io, const void* buf, int len) { +// and the messages are small, so a short write is not expected. We deliberately +// do NOT use hio_write() here: it would invoke the upper-layer write_cb +// (leaking handshake bytes, including credentials, to the application before +// onConnection), dispatch to hssl_write() with a not-yet-created SSL handle for +// a TLS target, and enqueue on EAGAIN via hio_add() which would clobber the +// handshake read handler. A short write or error is treated as fatal and closes +// the connection. +static int proxy_send(hio_t* io, const void* buf, int len) { int flag = 0; #ifdef MSG_NOSIGNAL flag |= MSG_NOSIGNAL; @@ -300,15 +300,15 @@ static void socks5_send_connect(hio_t* io) { proxy_conn_t* s5 = io->proxy; unsigned char buf[300]; int n = socks5_build_connect_request(s5, buf); - if (n < 0) { socks5_fail(io); return; } - if (socks5_send(io, buf, n) != 0) { socks5_fail(io); return; } + if (n < 0) { proxy_fail(io); return; } + if (proxy_send(io, buf, n) != 0) { proxy_fail(io); return; } socks5_expect(io, S5_RECV_REPLY_HEAD, 4); } // hand off the established proxy tunnel to the upper layer: stop the handshake // read handler, then run the SSL handshake / connect_cb. io->read_cb was never // touched, so the upper-layer Channel read callback stays intact. -static void socks5_established(hio_t* io) { +static void proxy_established(hio_t* io) { hio_del(io, HV_READ); nio_connect_established(io); } @@ -321,29 +321,29 @@ static void socks5_dispatch(hio_t* io) { switch (s5->state) { case S5_RECV_METHOD: // VER METHOD - if (buf[0] != SOCKS5_VERSION) { socks5_fail(io); return; } + if (buf[0] != SOCKS5_VERSION) { proxy_fail(io); return; } if (buf[1] == SOCKS5_AUTH_NONE) { socks5_send_connect(io); } else if (buf[1] == SOCKS5_AUTH_USERPASS && s5->setting.username[0]) { unsigned char req[640]; int n = socks5_build_auth_request(s5, req); - if (socks5_send(io, req, n) != 0) { socks5_fail(io); return; } + if (proxy_send(io, req, n) != 0) { proxy_fail(io); return; } socks5_expect(io, S5_RECV_AUTH, 2); } else { - socks5_fail(io); // no acceptable method + proxy_fail(io); // no acceptable method } return; case S5_RECV_AUTH: // VER STATUS (0 == success) - if (buf[1] != 0x00) { socks5_fail(io); return; } + if (buf[1] != 0x00) { proxy_fail(io); return; } socks5_send_connect(io); return; case S5_RECV_REPLY_HEAD: { // VER REP RSV ATYP - if (buf[0] != SOCKS5_VERSION) { socks5_fail(io); return; } - if (buf[1] != SOCKS5_REP_SUCCESS) { io->error = ERR_CONNECT; socks5_fail(io); return; } + if (buf[0] != SOCKS5_VERSION) { proxy_fail(io); return; } + if (buf[1] != SOCKS5_REP_SUCCESS) { io->error = ERR_CONNECT; proxy_fail(io); return; } unsigned char atyp = buf[3]; if (atyp == SOCKS5_ATYP_IPV4) { socks5_expect(io, S5_RECV_REPLY_ADDR, 4 + 2); // addr + port @@ -354,14 +354,14 @@ static void socks5_dispatch(hio_t* io) { // first requiring the length byte. socks5_expect(io, S5_RECV_REPLY_DADDR, 1); } else { - socks5_fail(io); + proxy_fail(io); } return; } case S5_RECV_REPLY_ADDR: // bound addr+port consumed; tunnel is up - socks5_established(io); + proxy_established(io); return; case S5_RECV_REPLY_DADDR: @@ -372,11 +372,11 @@ static void socks5_dispatch(hio_t* io) { socks5_expect(io, S5_RECV_REPLY_DADDR, dlen + 2); return; } - socks5_established(io); + proxy_established(io); return; default: - socks5_fail(io); + proxy_fail(io); return; } } @@ -387,14 +387,14 @@ static void socks5_handshake(hio_t* io) { proxy_conn_t* s5 = io->proxy; while (s5->rlen < s5->want) { int need = s5->want - s5->rlen; - if (s5->want > (int)sizeof(s5->rbuf)) { socks5_fail(io); return; } + if (s5->want > (int)sizeof(s5->rbuf)) { proxy_fail(io); return; } int n = recv(io->fd, (char*)s5->rbuf + s5->rlen, need, 0); - if (n == 0) { socks5_fail(io); return; } // peer closed + if (n == 0) { proxy_fail(io); return; } // peer closed if (n < 0) { int err = socket_errno(); if (err == EAGAIN || err == EINTR) return; // wait for more io->error = err; - socks5_fail(io); + proxy_fail(io); return; } s5->rlen += n; @@ -407,17 +407,81 @@ static void socks5_handshake_start(hio_t* io) { proxy_conn_t* s5 = io->proxy; unsigned char buf[8]; int n = socks5_build_method_request(s5, buf); - if (socks5_send(io, buf, n) != 0) { socks5_fail(io); return; } + if (proxy_send(io, buf, n) != 0) { proxy_fail(io); return; } socks5_expect(io, S5_RECV_METHOD, 2); hio_add(io, socks5_handshake, HV_READ); } -// Dispatch the proxy handshake by protocol (only SOCKS5 implemented so far). +// HTTP CONNECT handshake (RFC 7231 4.3.6): send a CONNECT request, then read +// response headers until the blank line "\r\n\r\n". A 2xx status establishes +// the tunnel. Like the SOCKS5 handshake this uses a dedicated recv() into +// s5->rbuf via hio_add (never touches io->read_cb) and is robust to +// fragmentation. Because CONNECT response headers carry no body, all bytes up +// to and including the blank line belong to the handshake; any bytes after it +// would already be tunnel data (a well-behaved proxy sends none until the +// client speaks first, which our clients do -- TLS ClientHello / request). +static void http_connect_handshake(hio_t* io) { + proxy_conn_t* p = io->proxy; + // read more into the accumulator (non-destructive across calls) + while (p->rlen < (int)sizeof(p->rbuf)) { + int n = recv(io->fd, (char*)p->rbuf + p->rlen, (int)sizeof(p->rbuf) - p->rlen, 0); + if (n == 0) { proxy_fail(io); return; } // peer closed + if (n < 0) { + int err = socket_errno(); + if (err == EAGAIN || err == EINTR) break; // wait for more + io->error = err; + proxy_fail(io); + return; + } + p->rlen += n; + // look for end of headers + if (p->rlen >= 4) { + for (int i = 3; i < p->rlen; ++i) { + if (p->rbuf[i-3]=='\r' && p->rbuf[i-2]=='\n' && + p->rbuf[i-1]=='\r' && p->rbuf[i]=='\n') { + // parse status line: "HTTP/1.x SP CODE SP ..." + // find first space, then 3-digit code + int code = 0; + char* sp = (char*)memchr(p->rbuf, ' ', p->rlen); + if (sp) code = atoi(sp + 1); + if (code >= 200 && code < 300) { + proxy_established(io); + } else { + hlogw("connfd=%d http proxy CONNECT failed: %d", io->fd, code); + io->error = ERR_CONNECT; + proxy_fail(io); + } + return; + } + } + } + } + if (p->rlen >= (int)sizeof(p->rbuf)) { + // headers too large, no blank line found + proxy_fail(io); + } +} + +// Kick off the HTTP CONNECT handshake once the TCP connection to the proxy is up. +static void http_connect_start(hio_t* io) { + proxy_conn_t* p = io->proxy; + char buf[1024]; + int n = http_connect_build_request(p, buf, (int)sizeof(buf)); + if (n < 0) { proxy_fail(io); return; } + if (proxy_send(io, buf, n) != 0) { proxy_fail(io); return; } + p->rlen = 0; + hio_add(io, http_connect_handshake, HV_READ); +} + +// Dispatch the proxy handshake by protocol. static void proxy_handshake_start(hio_t* io) { switch (io->proxy->setting.protocol) { case PROXY_PROTOCOL_SOCKS5: socks5_handshake_start(io); return; + case PROXY_PROTOCOL_HTTP_CONNECT: + http_connect_start(io); + return; default: io->error = ERR_INVALID_PARAM; hio_close(io); diff --git a/event/socks5.c b/event/socks5.c index c924e7b69..cd1344eef 100644 --- a/event/socks5.c +++ b/event/socks5.c @@ -1,8 +1,10 @@ #include "socks5.h" #include +#include #include "hsocket.h" // is_ipv4 / is_ipv6 / inet_pton via hplatform +#include "base64.h" // hv_base64_encode // Build the SOCKS5 method-selection request. // +----+----------+----------+ @@ -74,3 +76,36 @@ int socks5_build_connect_request(const proxy_conn_t* s5, unsigned char* buf) { buf[n++] = (unsigned char)(port & 0xFF); return n; } + +// Build an HTTP CONNECT request (RFC 7231 4.3.6). The request-target is the +// authority form "host:port"; a Basic Proxy-Authorization header is added when +// credentials are present. +int http_connect_build_request(const proxy_conn_t* p, char* buf, int bufsize) { + const char* host = p->setting.target_host; + int port = p->setting.target_port; + int n = 0; + int r = snprintf(buf + n, bufsize - n, + "CONNECT %s:%d HTTP/1.1\r\nHost: %s:%d\r\n", + host, port, host, port); + if (r < 0 || r >= bufsize - n) return -1; + n += r; + + if (p->setting.username[0]) { + // credentials = "user:pass" + char cred[520]; + int c = snprintf(cred, sizeof(cred), "%s:%s", + p->setting.username, p->setting.password); + if (c < 0 || c >= (int)sizeof(cred)) return -1; + char b64[768]; + int b = hv_base64_encode((const unsigned char*)cred, (unsigned int)c, b64); + b64[b] = '\0'; + r = snprintf(buf + n, bufsize - n, "Proxy-Authorization: Basic %s\r\n", b64); + if (r < 0 || r >= bufsize - n) return -1; + n += r; + } + + r = snprintf(buf + n, bufsize - n, "\r\n"); + if (r < 0 || r >= bufsize - n) return -1; + n += r; + return n; +} diff --git a/event/socks5.h b/event/socks5.h index 9ca2a4c80..1e4d0af4d 100644 --- a/event/socks5.h +++ b/event/socks5.h @@ -29,15 +29,17 @@ #define SOCKS5_REP_SUCCESS 0x00 // Internal per-connection runtime state for the proxy handshake (held on -// hio_t). Not part of the public configuration. +// hio_t). Not part of the public configuration. Shared by SOCKS5 and HTTP +// CONNECT. typedef struct proxy_conn_s { proxy_setting_t setting; // copied proxy config (target + auth) int state; // socks5_state_e (see nio.c) - // handshake read accumulator: SOCKS5 replies may be fragmented across TCP + // handshake read accumulator: replies may be fragmented across TCP // segments, so bytes are buffered here until a full message is available. - unsigned char rbuf[300]; // max reply: 4 + 1 + 255 + 2 (domain bind) + // SOCKS5 max reply is small; HTTP CONNECT response headers can be larger. + unsigned char rbuf[1024]; int rlen; // bytes currently in rbuf - int want; // bytes needed to complete the current step + int want; // bytes needed to complete the current step (SOCKS5) } proxy_conn_t; BEGIN_EXTERN_C @@ -47,6 +49,14 @@ int socks5_build_method_request (const proxy_conn_t* s5, unsigned char* buf); int socks5_build_auth_request (const proxy_conn_t* s5, unsigned char* buf); int socks5_build_connect_request(const proxy_conn_t* s5, unsigned char* buf); +// Build an HTTP CONNECT request into buf (size bufsize). Sends +// CONNECT target_host:target_port HTTP/1.1 +// Host: target_host:target_port +// [Proxy-Authorization: Basic base64(user:pass)] +// (blank line) +// Returns bytes written (<0 on error / truncation). +int http_connect_build_request(const proxy_conn_t* p, char* buf, int bufsize); + END_EXTERN_C #endif // HV_SOCKS5_H_ diff --git a/http/HttpMessage.cpp b/http/HttpMessage.cpp index fa86c7800..14263353d 100644 --- a/http/HttpMessage.cpp +++ b/http/HttpMessage.cpp @@ -660,6 +660,10 @@ void HttpRequest::Init() { redirect = 1; proxy = 0; cancel = 0; + tunnel_proxy_host.clear(); + tunnel_proxy_port = 0; + tunnel_proxy_username.clear(); + tunnel_proxy_password.clear(); } void HttpRequest::Reset() { diff --git a/http/HttpMessage.h b/http/HttpMessage.h index 5be241957..aad52de40 100644 --- a/http/HttpMessage.h +++ b/http/HttpMessage.h @@ -386,8 +386,16 @@ class HV_EXPORT HttpRequest : public HttpMessage { uint32_t retry_count; uint32_t retry_delay; // unit: ms unsigned redirect: 1; - unsigned proxy : 1; + unsigned proxy : 1; // absolute-URI forward proxy (plain HTTP) unsigned cancel : 1; + // CONNECT-tunnel proxy (for https-over-proxy): when tunnel_proxy_host is set, + // the client connects to the proxy and issues an HTTP CONNECT to + // host:port, then does TLS end-to-end against the origin. Distinct from the + // `proxy` bit above, which is the plain-HTTP absolute-URI forward proxy. + std::string tunnel_proxy_host; + int tunnel_proxy_port; + std::string tunnel_proxy_username; + std::string tunnel_proxy_password; HttpRequest(); @@ -448,6 +456,18 @@ class HV_EXPORT HttpRequest : public HttpMessage { void SetProxy(const char* host, int port); bool IsProxy() { return proxy; } + // CONNECT-tunnel proxy (used for https-over-proxy). Unlike SetProxy (plain + // HTTP absolute-URI forwarding), this connects to the proxy and issues an + // HTTP CONNECT to the origin, then does end-to-end TLS with the origin. + void SetTunnelProxy(const char* host, int port, + const char* username = NULL, const char* password = NULL) { + tunnel_proxy_host = host ? host : ""; + tunnel_proxy_port = port; + tunnel_proxy_username = username ? username : ""; + tunnel_proxy_password = password ? password : ""; + } + bool IsTunnelProxy() { return !tunnel_proxy_host.empty(); } + // Auth void SetAuth(const std::string& auth); void SetBasicAuth(const std::string& username, const std::string& password); diff --git a/http/client/AsyncHttpClient.cpp b/http/client/AsyncHttpClient.cpp index e16073aa9..78fd24679 100644 --- a/http/client/AsyncHttpClient.cpp +++ b/http/client/AsyncHttpClient.cpp @@ -34,16 +34,24 @@ int AsyncHttpClient::doTask(const HttpClientTaskPtr& task) { } req->ParseUrl(); + // Where to open the TCP connection: normally the origin, but for an HTTP + // CONNECT tunnel (https over proxy) it is the proxy. The origin is then + // reached via the proxy's CONNECT (see doTaskWithAddr / hio_set_proxy). const char* host = req->host.c_str(); + int port = req->port; + if (req->IsTunnelProxy()) { + host = req->tunnel_proxy_host.c_str(); + port = req->tunnel_proxy_port; + } // If host is a numeric IP (or UDS), resolve synchronously (fast path). // Otherwise resolve the hostname asynchronously via EventLoop::resolveDns // so the event loop is never blocked by getaddrinfo. resolveDns returns a // use-after-free-proof DnsID and owns the underlying hdns_t lifetime. - if (req->port < 0 || is_ipaddr(host)) { + if (port < 0 || is_ipaddr(host)) { sockaddr_u peeraddr; memset(&peeraddr, 0, sizeof(peeraddr)); - int ret = sockaddr_set_ipport(&peeraddr, host, req->port); + int ret = sockaddr_set_ipport(&peeraddr, host, port); if (ret != 0) { hloge("unknown host %s", host); return -20; @@ -53,7 +61,6 @@ int AsyncHttpClient::doTask(const HttpClientTaskPtr& task) { hdns_setting_t opt; if (req->connect_timeout > 0) opt.timeout_ms = req->connect_timeout * 1000; - int port = req->port; DnsID id = EventLoopThread::loop()->resolveDns(host, [this, task, port](int status, int naddrs, const sockaddr_u* addrs) { if (status != HDNS_STATUS_OK || naddrs <= 0) { @@ -90,7 +97,6 @@ int AsyncHttpClient::doTaskWithAddr(const HttpClientTaskPtr& task, const sockadd return -10; } - const char* host = req->host.c_str(); sockaddr_u peeraddr = *paddr; int connfd = -1; @@ -114,11 +120,24 @@ int AsyncHttpClient::doTaskWithAddr(const HttpClientTaskPtr& task, const sockadd assert(connio != NULL); hio_set_peeraddr(connio, &peeraddr.sa, sockaddr_len(&peeraddr)); addChannel(connio); - // https - if (req->IsHttps() && !req->IsProxy()) { + // https over proxy: HTTP CONNECT tunnel to the origin, then TLS with it. + if (req->IsTunnelProxy()) { + proxy_setting_t proxy; + proxy.protocol = PROXY_PROTOCOL_HTTP_CONNECT; + hv_strncpy(proxy.target_host, req->host.c_str(), sizeof(proxy.target_host)); + proxy.target_port = req->port; + if (!req->tunnel_proxy_username.empty()) { + hv_strncpy(proxy.username, req->tunnel_proxy_username.c_str(), sizeof(proxy.username)); + hv_strncpy(proxy.password, req->tunnel_proxy_password.c_str(), sizeof(proxy.password)); + } + hio_set_proxy(connio, &proxy); + } + // https: enable TLS against the origin (also for the tunnel case, run + // after the CONNECT handshake completes, with SNI = origin host). + if (req->IsHttps()) { hio_enable_ssl(connio); - if (!is_ipaddr(host)) { - hio_set_hostname(connio, host); + if (!is_ipaddr(req->host.c_str())) { + hio_set_hostname(connio, req->host.c_str()); } } } @@ -159,6 +178,9 @@ int AsyncHttpClient::doTaskWithAddr(const HttpClientTaskPtr& task, const sockadd auto& req = ctx->task->req; auto& resp = ctx->resp; bool keepalive = req->IsKeepAlive() && resp->IsKeepAlive(); + // Snapshot before any callback: successCallback() clears ctx->task, + // which frees the request `req` references (dangling afterwards). + bool is_tunnel = req->IsTunnelProxy(); if (req->redirect && HTTP_STATUS_IS_REDIRECT(resp->status_code)) { std::string location = resp->headers["Location"]; if (!location.empty()) { @@ -174,11 +196,14 @@ int AsyncHttpClient::doTaskWithAddr(const HttpClientTaskPtr& task, const sockadd } else { ctx->successCallback(); } - if (keepalive) { + if (keepalive && !is_tunnel) { // NOTE: add into conn_pools to reuse // hlogd("add into conn_pools"); conn_pools[channel->peeraddr()].add(channel->fd()); } else { + // A CONNECT tunnel is bound to one origin; the pool is keyed by + // peeraddr (the proxy), so reusing it for a different origin + // would send to the wrong target. Never pool tunnels. channel->close(); } } diff --git a/http/client/HttpClient.cpp b/http/client/HttpClient.cpp index 9917dd2d5..cfdfaf23a 100644 --- a/http/client/HttpClient.cpp +++ b/http/client/HttpClient.cpp @@ -12,6 +12,7 @@ #include "hstring.h" #include "hsocket.h" #include "hssl.h" +#include "base64.h" #include "HttpParser.h" // for async @@ -32,6 +33,9 @@ struct http_client_s { // https_proxy std::string https_proxy_host; int https_proxy_port; + // proxy auth (Basic for http, SOCKS5/CONNECT tunnel auth) + std::string proxy_username; + std::string proxy_password; // no_proxy StringList no_proxy_hosts; //private: @@ -166,6 +170,12 @@ int http_client_add_no_proxy(http_client_t* cli, const char* host) { return 0; } +int http_client_set_proxy_auth(http_client_t* cli, const char* username, const char* password) { + cli->proxy_username = username ? username : ""; + cli->proxy_password = password ? password : ""; + return 0; +} + static int http_client_make_request(http_client_t* cli, HttpRequest* req) { if (req->url.empty() || *req->url.c_str() == '/') { req->scheme = cli->https ? "https" : "http"; @@ -190,8 +200,21 @@ static int http_client_make_request(http_client_t* cli, HttpRequest* req) { } } if (use_proxy) { - req->SetProxy(https ? cli->https_proxy_host.c_str() : cli->http_proxy_host.c_str(), - https ? cli->https_proxy_port : cli->http_proxy_port); + if (https) { + // https over proxy: use an HTTP CONNECT tunnel (end-to-end TLS with + // the origin), NOT absolute-URI forwarding. + req->SetTunnelProxy(cli->https_proxy_host.c_str(), cli->https_proxy_port, + cli->proxy_username.empty() ? NULL : cli->proxy_username.c_str(), + cli->proxy_password.empty() ? NULL : cli->proxy_password.c_str()); + } else { + // plain http over proxy: absolute-URI forward proxy. + req->SetProxy(cli->http_proxy_host.c_str(), cli->http_proxy_port); + if (!cli->proxy_username.empty()) { + std::string cred = cli->proxy_username + ":" + cli->proxy_password; + req->headers["Proxy-Authorization"] = + "Basic " + hv::Base64Encode((const unsigned char*)cred.data(), cred.size()); + } + } } if (req->timeout == 0) { @@ -207,6 +230,119 @@ static int http_client_make_request(http_client_t* cli, HttpRequest* req) { return 0; } +// Client-side TLS handshake on an already-connected fd. sni_host is the origin +// host used for SNI. On failure frees cli->ssl and returns an error (<0); the +// caller owns/closes connfd. +static int http_client_ssl_handshake(http_client_t* cli, int connfd, const char* sni_host, + int blocktime, unsigned int start_time) { + // cli->ssl_ctx > g_ssl_ctx > hssl_ctx_new + hssl_ctx_t ssl_ctx = NULL; + if (cli->ssl_ctx) { + ssl_ctx = cli->ssl_ctx; + } else if (g_ssl_ctx) { + ssl_ctx = g_ssl_ctx; + } else { + cli->ssl_ctx = ssl_ctx = hssl_ctx_new(NULL); + cli->alloced_ssl_ctx = true; + } + if (ssl_ctx == NULL) { + return NABS(ERR_NEW_SSL_CTX); + } + cli->ssl = hssl_new(ssl_ctx, connfd); + if (cli->ssl == NULL) { + return NABS(ERR_NEW_SSL); + } + if (sni_host && !is_ipaddr(sni_host)) { + hssl_set_sni_hostname(cli->ssl, sni_host); + } +#ifdef WITH_OPENSSL + // Offer ALPN "h2" only when HTTP/2 is intended, so an h2-capable server + // negotiates it (real https servers require ALPN, not prior-knowledge). + // Set it per-connection on the SSL object (not the shared ctx), so it + // works with any ctx source (user/global/allocated) and never leaks h2 + // into other clients or http/1.1 requests. + if (cli->http_version == 2) { + static unsigned char s_alpn_protos[] = "\x02h2\x08http/1.1"; + hssl_set_alpn_protos(cli->ssl, s_alpn_protos, sizeof(s_alpn_protos) - 1); + } +#endif + unsigned int elapsed = gettick_ms() - start_time; + int ssl_timeout = blocktime - (int)elapsed; + if (ssl_timeout <= 0) { + hssl_free(cli->ssl); + cli->ssl = NULL; + return NABS(ETIMEDOUT); + } + so_rcvtimeo(connfd, ssl_timeout); + int ret = hssl_connect(cli->ssl); + if (ret != 0) { + fprintf(stderr, "* ssl handshake failed: %d\n", ret); + hloge("ssl handshake failed: %d", ret); + hssl_free(cli->ssl); + cli->ssl = NULL; + return NABS(ret); + } + return 0; +} + +// Blocking HTTP CONNECT handshake to a proxy on connfd: sends +// CONNECT origin_host:origin_port HTTP/1.1 ... [Proxy-Authorization] \r\n\r\n +// then reads the response headers and requires a 2xx status. Returns 0 on +// success; the caller owns/closes connfd. +static int http_client_http_connect(int connfd, const char* origin_host, int origin_port, + const std::string& user, const std::string& pass, + int blocktime) { + std::string reqstr = hv::asprintf("CONNECT %s:%d HTTP/1.1\r\nHost: %s:%d\r\n", + origin_host, origin_port, origin_host, origin_port); + if (!user.empty()) { + std::string cred = user + ":" + pass; + reqstr += "Proxy-Authorization: Basic " + + hv::Base64Encode((const unsigned char*)cred.data(), cred.size()) + "\r\n"; + } + reqstr += "\r\n"; + + unsigned int start = gettick_ms(); + size_t total = 0; + while (total < reqstr.size()) { + int left = blocktime - (int)(gettick_ms() - start); + if (left <= 0) return NABS(ETIMEDOUT); + so_sndtimeo(connfd, left); + int nsend = send(connfd, reqstr.data() + total, reqstr.size() - total, 0); + if (nsend <= 0) { + if (socket_errno() == EINTR) continue; + return NABS(socket_errno()); + } + total += nsend; + } + + // read response headers until CRLFCRLF + char buf[1024]; + int rlen = 0; + while (rlen < (int)sizeof(buf)) { + int left = blocktime - (int)(gettick_ms() - start); + if (left <= 0) return NABS(ETIMEDOUT); + so_rcvtimeo(connfd, left); + int nrecv = recv(connfd, buf + rlen, sizeof(buf) - rlen, 0); + if (nrecv == 0) return NABS(ERR_CONNECT); // peer closed + if (nrecv < 0) { + if (socket_errno() == EINTR) continue; + return NABS(socket_errno()); + } + rlen += nrecv; + for (int i = 3; i < rlen; ++i) { + if (buf[i-3]=='\r' && buf[i-2]=='\n' && buf[i-1]=='\r' && buf[i]=='\n') { + int code = 0; + const char* sp = (const char*)memchr(buf, ' ', rlen); + if (sp) code = atoi(sp + 1); + if (code >= 200 && code < 300) return 0; + hloge("http proxy CONNECT failed: %d", code); + return NABS(ERR_CONNECT); + } + } + } + return NABS(ERR_CONNECT); // headers too large, no blank line +} + int http_client_connect(http_client_t* cli, const char* host, int port, int https, int timeout) { cli->Close(); int blocktime = DEFAULT_CONNECT_TIMEOUT; @@ -222,56 +358,49 @@ int http_client_connect(http_client_t* cli, const char* host, int port, int http tcp_nodelay(connfd, 1); if (https && cli->ssl == NULL) { - // cli->ssl_ctx > g_ssl_ctx > hssl_ctx_new - hssl_ctx_t ssl_ctx = NULL; - if (cli->ssl_ctx) { - ssl_ctx = cli->ssl_ctx; - } else if (g_ssl_ctx) { - ssl_ctx = g_ssl_ctx; - } else { - cli->ssl_ctx = ssl_ctx = hssl_ctx_new(NULL); - cli->alloced_ssl_ctx = true; - } - if (ssl_ctx == NULL) { - closesocket(connfd); - return NABS(ERR_NEW_SSL_CTX); - } - cli->ssl = hssl_new(ssl_ctx, connfd); - if (cli->ssl == NULL) { - closesocket(connfd); - return NABS(ERR_NEW_SSL); - } - if (!is_ipaddr(host)) { - hssl_set_sni_hostname(cli->ssl, host); - } -#ifdef WITH_OPENSSL - // Offer ALPN "h2" only when HTTP/2 is intended, so an h2-capable server - // negotiates it (real https servers require ALPN, not prior-knowledge). - // Set it per-connection on the SSL object (not the shared ctx), so it - // works with any ctx source (user/global/allocated) and never leaks h2 - // into other clients or http/1.1 requests. - if (cli->http_version == 2) { - static unsigned char s_alpn_protos[] = "\x02h2\x08http/1.1"; - hssl_set_alpn_protos(cli->ssl, s_alpn_protos, sizeof(s_alpn_protos) - 1); - } -#endif - unsigned int elapsed = gettick_ms() - start_time; - int ssl_timeout = blocktime - (int)elapsed; - if (ssl_timeout <= 0) { - hssl_free(cli->ssl); - cli->ssl = NULL; + int ret = http_client_ssl_handshake(cli, connfd, host, blocktime, start_time); + if (ret != 0) { closesocket(connfd); - return NABS(ETIMEDOUT); + return ret; } - so_rcvtimeo(connfd, ssl_timeout); - int ret = hssl_connect(cli->ssl); + } + + cli->fd = connfd; + cli->keepalive_requests = 0; + return connfd; +} + +// Connect through an HTTP CONNECT tunnel: TCP-connect to the proxy, issue a +// blocking CONNECT to the origin, then (for https) TLS end-to-end with the +// origin. Used by the sync exec path for https-over-proxy. +static int http_client_connect_tunnel(http_client_t* cli, HttpRequest* req, int timeout) { + cli->Close(); + int blocktime = DEFAULT_CONNECT_TIMEOUT; + if (timeout > 0) { + blocktime = MIN(timeout*1000, blocktime); + } + unsigned int start_time = gettick_ms(); + int connfd = ConnectTimeout(req->tunnel_proxy_host.c_str(), req->tunnel_proxy_port, blocktime); + if (connfd < 0) { + hloge("connect proxy %s:%d failed!", req->tunnel_proxy_host.c_str(), req->tunnel_proxy_port); + return connfd; + } + tcp_nodelay(connfd, 1); + + int left = blocktime - (int)(gettick_ms() - start_time); + if (left <= 0) { closesocket(connfd); return NABS(ETIMEDOUT); } + int ret = http_client_http_connect(connfd, req->host.c_str(), req->port, + req->tunnel_proxy_username, req->tunnel_proxy_password, left); + if (ret != 0) { + closesocket(connfd); + return ret; + } + + if (req->IsHttps() && cli->ssl == NULL) { + ret = http_client_ssl_handshake(cli, connfd, req->host.c_str(), blocktime, start_time); if (ret != 0) { - fprintf(stderr, "* ssl handshake failed: %d\n", ret); - hloge("ssl handshake failed: %d", ret); - hssl_free(cli->ssl); - cli->ssl = NULL; closesocket(connfd); - return NABS(ret); + return ret; } } @@ -354,7 +483,13 @@ static int http_client_exec(http_client_t* cli, HttpRequest* req, HttpResponse* cli->port = req->port; cli->http_version = req->http_major; // gates the ALPN "h2" offer in connect connect: - connfd = http_client_connect(cli, req->host.c_str(), req->port, https, connect_timeout); + if (req->IsTunnelProxy()) { + // https over proxy: connect to the proxy, HTTP CONNECT to the + // origin, then TLS end-to-end with the origin. + connfd = http_client_connect_tunnel(cli, req, connect_timeout); + } else { + connfd = http_client_connect(cli, req->host.c_str(), req->port, https, connect_timeout); + } if (connfd < 0) { return connfd; } @@ -574,10 +709,18 @@ static int http_client_exec_curl(http_client_t* cli, HttpRequest* req, HttpRespo } CURL* curl = cli->curl; - // proxy + // proxy: plain-http forward proxy (req->host is the proxy) or CONNECT + // tunnel (tunnel_proxy_* for https). libcurl handles both, incl. CONNECT. if (req->IsProxy()) { curl_easy_setopt(curl, CURLOPT_PROXY, req->host.c_str()); curl_easy_setopt(curl, CURLOPT_PROXYPORT, req->port); + } else if (req->IsTunnelProxy()) { + curl_easy_setopt(curl, CURLOPT_PROXY, req->tunnel_proxy_host.c_str()); + curl_easy_setopt(curl, CURLOPT_PROXYPORT, req->tunnel_proxy_port); + if (!req->tunnel_proxy_username.empty()) { + std::string userpwd = req->tunnel_proxy_username + ":" + req->tunnel_proxy_password; + curl_easy_setopt(curl, CURLOPT_PROXYUSERPWD, userpwd.c_str()); + } } // SSL diff --git a/http/client/HttpClient.h b/http/client/HttpClient.h index 984096d59..74fcd8ed0 100644 --- a/http/client/HttpClient.h +++ b/http/client/HttpClient.h @@ -53,6 +53,8 @@ HV_EXPORT int http_client_set_http_proxy(http_client_t* cli, const char* host, i HV_EXPORT int http_client_set_https_proxy(http_client_t* cli, const char* host, int port); // no_proxy HV_EXPORT int http_client_add_no_proxy(http_client_t* cli, const char* host); +// proxy auth (Basic for http forward proxy / HTTP CONNECT tunnel) +HV_EXPORT int http_client_set_proxy_auth(http_client_t* cli, const char* username, const char* password); // sync HV_EXPORT int http_client_send(http_client_t* cli, HttpRequest* req, HttpResponse* resp); @@ -124,6 +126,10 @@ class HttpClient { int addNoProxy(const char* host) { return http_client_add_no_proxy(client_.get(), host); } + // proxy auth (Basic for http forward proxy / HTTP CONNECT tunnel) + int setProxyAuth(const char* username, const char* password) { + return http_client_set_proxy_auth(client_.get(), username, password); + } // sync int send(HttpRequest* req, HttpResponse* resp) { From 548fdcbedaec9141bdbf1759af993c5d256cc660 Mon Sep 17 00:00:00 2001 From: ithewei Date: Mon, 21 Sep 2026 23:36:03 +0800 Subject: [PATCH 2/4] fix(socks5): self-contained base64 for CONNECT auth (no util/ dep) The event layer builds with only -I. -Ibase -Issl -Ievent (core srcdirs), so including util/base64.h broke 'make examples' (fatal: base64.h not found). Inline a minimal base64 encoder in socks5.c for the Proxy-Authorization header instead of depending on util/. Co-authored-by: TRAE CLI --- event/socks5.c | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/event/socks5.c b/event/socks5.c index cd1344eef..fe3f3f0da 100644 --- a/event/socks5.c +++ b/event/socks5.c @@ -4,7 +4,39 @@ #include #include "hsocket.h" // is_ipv4 / is_ipv6 / inet_pton via hplatform -#include "base64.h" // hv_base64_encode + +// Minimal base64 encoder for the HTTP CONNECT Proxy-Authorization header. +// NOTE: implemented locally (not via util/base64.h) because the event layer +// must not depend on util/ (core builds only add -I. -Ibase -Issl -Ievent). +// Writes ceil(len/3)*4 bytes to out (no NUL terminator); returns bytes written. +static int socks5_base64_encode(const unsigned char* in, int len, char* out) { + static const char tbl[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + int n = 0, i = 0; + while (i + 3 <= len) { + unsigned v = (in[i] << 16) | (in[i+1] << 8) | in[i+2]; + out[n++] = tbl[(v >> 18) & 0x3F]; + out[n++] = tbl[(v >> 12) & 0x3F]; + out[n++] = tbl[(v >> 6) & 0x3F]; + out[n++] = tbl[v & 0x3F]; + i += 3; + } + int rem = len - i; + if (rem == 1) { + unsigned v = in[i] << 16; + out[n++] = tbl[(v >> 18) & 0x3F]; + out[n++] = tbl[(v >> 12) & 0x3F]; + out[n++] = '='; + out[n++] = '='; + } else if (rem == 2) { + unsigned v = (in[i] << 16) | (in[i+1] << 8); + out[n++] = tbl[(v >> 18) & 0x3F]; + out[n++] = tbl[(v >> 12) & 0x3F]; + out[n++] = tbl[(v >> 6) & 0x3F]; + out[n++] = '='; + } + return n; +} // Build the SOCKS5 method-selection request. // +----+----------+----------+ @@ -97,7 +129,7 @@ int http_connect_build_request(const proxy_conn_t* p, char* buf, int bufsize) { p->setting.username, p->setting.password); if (c < 0 || c >= (int)sizeof(cred)) return -1; char b64[768]; - int b = hv_base64_encode((const unsigned char*)cred, (unsigned int)c, b64); + int b = socks5_base64_encode((const unsigned char*)cred, c, b64); b64[b] = '\0'; r = snprintf(buf + n, bufsize - n, "Proxy-Authorization: Basic %s\r\n", b64); if (r < 0 || r >= bufsize - n) return -1; From 0acbb4f33332854c6a2e9406a1e08f1704be164e Mon Sep 17 00:00:00 2001 From: ithewei Date: Mon, 21 Sep 2026 23:58:28 +0800 Subject: [PATCH 3/4] fix(proxy): robust handshake short-write + scope Proxy-Authorization Address Copilot review on #886: - nio.c proxy_send: a positive short write is legal TCP behavior (and HTTP CONNECT requests are larger than SOCKS5 messages). Loop until all bytes are sent instead of treating a short write as fatal, backing off briefly on EAGAIN (bounded ~5s, portable hv_msleep rather than poll()). - HttpClient make_request: always erase any prior Proxy-Authorization before deciding the proxy mode. This keeps the credential transport-only: it never crosses a CONNECT tunnel into the origin request (redirect/https reuse), and it no longer lingers after setProxyAuth(NULL, NULL) or when a request object is reused. It is regenerated only in the plain-HTTP forward-proxy branch, where the request is sent to the proxy itself. Re-verified sync + async: http-forward, https-CONNECT, CONNECT+Basic-auth all 200; bad proxy creds rejected. Co-authored-by: TRAE CLI --- event/nio.c | 42 +++++++++++++++++++++++++++++--------- http/client/HttpClient.cpp | 13 ++++++++++-- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/event/nio.c b/event/nio.c index 5dbfaa5a1..d4bb9847a 100644 --- a/event/nio.c +++ b/event/nio.c @@ -278,21 +278,43 @@ static void socks5_expect(hio_t* io, int state, int want) { } // Raw handshake send. The proxy handshake runs immediately after the TCP -// connection to the proxy is established, when the socket send buffer is empty -// and the messages are small, so a short write is not expected. We deliberately -// do NOT use hio_write() here: it would invoke the upper-layer write_cb -// (leaking handshake bytes, including credentials, to the application before -// onConnection), dispatch to hssl_write() with a not-yet-created SSL handle for -// a TLS target, and enqueue on EAGAIN via hio_add() which would clobber the -// handshake read handler. A short write or error is treated as fatal and closes -// the connection. +// connection to the proxy is established, when the socket send buffer is empty. +// We deliberately do NOT use hio_write() here: it would invoke the upper-layer +// write_cb (leaking handshake bytes, including credentials, to the application +// before onConnection), dispatch to hssl_write() with a not-yet-created SSL +// handle for a TLS target, and enqueue on EAGAIN via hio_add() which would +// clobber the handshake read handler. Handshake messages are small (SOCKS5 +// <= 513 bytes; HTTP CONNECT typically < 1KB) but a positive short write is +// still legal, so loop until all bytes are sent, briefly backing off on EAGAIN +// (bounded, ~5s total). Returns 0 on success, -1 on error. static int proxy_send(hio_t* io, const void* buf, int len) { int flag = 0; #ifdef MSG_NOSIGNAL flag |= MSG_NOSIGNAL; #endif - int n = send(io->fd, (const char*)buf, len, flag); - return n == len ? 0 : -1; + const char* p = (const char*)buf; + int sent = 0; + int backoff = 0; // ms waited so far on EAGAIN + while (sent < len) { + int n = send(io->fd, p + sent, len - sent, flag); + if (n > 0) { + sent += n; + backoff = 0; + continue; + } + if (n < 0) { + int err = socket_errno(); + if (err == EINTR) continue; + if (err == EAGAIN) { + if (backoff >= 5000) return -1; // give up after ~5s + hv_msleep(1); + ++backoff; + continue; + } + } + return -1; // 0 (peer closed) or fatal error + } + return 0; } // send the SOCKS5 CONNECT request and wait for the 4-byte reply header. diff --git a/http/client/HttpClient.cpp b/http/client/HttpClient.cpp index cfdfaf23a..a3b5d8869 100644 --- a/http/client/HttpClient.cpp +++ b/http/client/HttpClient.cpp @@ -199,15 +199,24 @@ static int http_client_make_request(http_client_t* cli, HttpRequest* req) { } } } + // Proxy-Authorization is only meaningful for the plain-HTTP absolute-URI + // forward proxy (the request is sent to the proxy itself). Clear any value + // first so it (a) never crosses a CONNECT tunnel to the origin and leaks the + // proxy credentials, and (b) does not linger after credentials are cleared + // via setProxyAuth(NULL, NULL) or when a request object is reused. It is + // then regenerated only in the forward-proxy branch below. + req->headers.erase("Proxy-Authorization"); if (use_proxy) { if (https) { // https over proxy: use an HTTP CONNECT tunnel (end-to-end TLS with - // the origin), NOT absolute-URI forwarding. + // the origin), NOT absolute-URI forwarding. Credentials travel in + // the CONNECT request itself (transport-only). req->SetTunnelProxy(cli->https_proxy_host.c_str(), cli->https_proxy_port, cli->proxy_username.empty() ? NULL : cli->proxy_username.c_str(), cli->proxy_password.empty() ? NULL : cli->proxy_password.c_str()); } else { - // plain http over proxy: absolute-URI forward proxy. + // plain http over proxy: absolute-URI forward proxy. The request is + // sent to the proxy, so Proxy-Authorization is correctly consumed by it. req->SetProxy(cli->http_proxy_host.c_str(), cli->http_proxy_port); if (!cli->proxy_username.empty()) { std::string cred = cli->proxy_username + ":" + cli->proxy_password; From 778c8de150d064912c5f7cce4015ee6d277001dc Mon Sep 17 00:00:00 2001 From: ithewei Date: Tue, 22 Sep 2026 00:30:37 +0800 Subject: [PATCH 4/4] fix(proxy): address round-3 review (IPv6, tunnel data, buffer, pool, modes) Round-3 Copilot review on #886: - IPv6 authority form: bracket IPv6 literals as [addr]:port in both the io-layer http_connect_build_request (socks5.c) and the sync http_client_http_connect (HttpClient.cpp) request-target and Host header (RFC 3986). - nio.c http_connect_handshake: MSG_PEEK to locate the \r\n\r\n terminator, then drain exactly the header bytes, leaving any post-terminator bytes in the socket. A server-first origin protocol (SMTP/IMAP/FTP greeting) arriving in the same segment as the CONNECT response is no longer swallowed. - nio.c http_connect_start: request buffer 1024 -> 2048 so a max-size (255/255) credential's Basic header cannot be rejected as truncation. - AsyncHttpClient: tunnels bypass conn_pool LOOKUP (not just insertion), so a pooled plain-HTTP connection to the same proxy addr is never reused for a tunnel (wrong-transport bug). - Proxy modes made mutually exclusive: SetProxy clears tunnel_*, SetTunnelProxy clears the proxy bit; make_request resets both modes + Proxy-Authorization before routing each request/redirect, so a reused HttpRequest can't carry a stale route across a redirect / no_proxy host / scheme switch. - curl path: reset CURLOPT_PROXY/PROXYUSERPWD each request (reused easy handle otherwise leaks prior proxy creds to a new/void proxy). - Revert proxy_send to a single send() (small handshake msg, empty sndbuf); the previous EAGAIN hv_msleep loop could stall the event-loop thread. Verified: sync + async http-forward, https-CONNECT, CONNECT+Basic-auth all 200; bad proxy creds rejected (-1015, proxy logs auth_ok=False). Co-authored-by: TRAE CLI --- event/nio.c | 129 ++++++++++++++++---------------- event/socks5.c | 16 +++- http/HttpMessage.cpp | 5 ++ http/HttpMessage.h | 2 + http/client/AsyncHttpClient.cpp | 16 ++-- http/client/HttpClient.cpp | 32 ++++++-- 6 files changed, 118 insertions(+), 82 deletions(-) diff --git a/event/nio.c b/event/nio.c index d4bb9847a..0366bf4ca 100644 --- a/event/nio.c +++ b/event/nio.c @@ -278,43 +278,23 @@ static void socks5_expect(hio_t* io, int state, int want) { } // Raw handshake send. The proxy handshake runs immediately after the TCP -// connection to the proxy is established, when the socket send buffer is empty. +// connection to the proxy is established, when the socket send buffer is empty +// and the message is tiny (SOCKS5 <= 513 bytes; HTTP CONNECT < ~1.3KB), far +// smaller than the default send buffer, so a single send() transfers it all. // We deliberately do NOT use hio_write() here: it would invoke the upper-layer // write_cb (leaking handshake bytes, including credentials, to the application // before onConnection), dispatch to hssl_write() with a not-yet-created SSL // handle for a TLS target, and enqueue on EAGAIN via hio_add() which would -// clobber the handshake read handler. Handshake messages are small (SOCKS5 -// <= 513 bytes; HTTP CONNECT typically < 1KB) but a positive short write is -// still legal, so loop until all bytes are sent, briefly backing off on EAGAIN -// (bounded, ~5s total). Returns 0 on success, -1 on error. +// clobber the handshake read handler (io has a single cb slot). A short write +// cannot happen here in practice; if it somehow does, it is treated as fatal +// (return -1) rather than blocking the event loop. static int proxy_send(hio_t* io, const void* buf, int len) { int flag = 0; #ifdef MSG_NOSIGNAL flag |= MSG_NOSIGNAL; #endif - const char* p = (const char*)buf; - int sent = 0; - int backoff = 0; // ms waited so far on EAGAIN - while (sent < len) { - int n = send(io->fd, p + sent, len - sent, flag); - if (n > 0) { - sent += n; - backoff = 0; - continue; - } - if (n < 0) { - int err = socket_errno(); - if (err == EINTR) continue; - if (err == EAGAIN) { - if (backoff >= 5000) return -1; // give up after ~5s - hv_msleep(1); - ++backoff; - continue; - } - } - return -1; // 0 (peer closed) or fatal error - } - return 0; + int n = send(io->fd, (const char*)buf, len, flag); + return n == len ? 0 : -1; } // send the SOCKS5 CONNECT request and wait for the 4-byte reply header. @@ -436,58 +416,77 @@ static void socks5_handshake_start(hio_t* io) { // HTTP CONNECT handshake (RFC 7231 4.3.6): send a CONNECT request, then read // response headers until the blank line "\r\n\r\n". A 2xx status establishes -// the tunnel. Like the SOCKS5 handshake this uses a dedicated recv() into -// s5->rbuf via hio_add (never touches io->read_cb) and is robust to -// fragmentation. Because CONNECT response headers carry no body, all bytes up -// to and including the blank line belong to the handshake; any bytes after it -// would already be tunnel data (a well-behaved proxy sends none until the -// client speaks first, which our clients do -- TLS ClientHello / request). +// the tunnel. Like the SOCKS5 handshake this uses a dedicated recv() via +// hio_add (never touches io->read_cb) and is robust to fragmentation. +// +// CONNECT responses carry no body, but a server-first origin protocol (SMTP, +// IMAP, FTP...) may send its greeting immediately after the tunnel opens, so +// those bytes can arrive in the same segment as the response headers. To avoid +// swallowing them, we MSG_PEEK to locate the header terminator, then drain +// EXACTLY the header bytes with a real recv(); anything after "\r\n\r\n" stays +// in the socket for the upper-layer read path. static void http_connect_handshake(hio_t* io) { proxy_conn_t* p = io->proxy; - // read more into the accumulator (non-destructive across calls) - while (p->rlen < (int)sizeof(p->rbuf)) { - int n = recv(io->fd, (char*)p->rbuf + p->rlen, (int)sizeof(p->rbuf) - p->rlen, 0); - if (n == 0) { proxy_fail(io); return; } // peer closed + for (;;) { + int cap = (int)sizeof(p->rbuf) - p->rlen; + if (cap <= 0) { proxy_fail(io); return; } // headers too large + // peek (non-destructive): inspect what is available without consuming. + int n = recv(io->fd, (char*)p->rbuf + p->rlen, cap, MSG_PEEK); + if (n == 0) { proxy_fail(io); return; } // peer closed if (n < 0) { int err = socket_errno(); - if (err == EAGAIN || err == EINTR) break; // wait for more + if (err == EAGAIN || err == EINTR) return; // wait for more io->error = err; proxy_fail(io); return; } - p->rlen += n; - // look for end of headers - if (p->rlen >= 4) { - for (int i = 3; i < p->rlen; ++i) { - if (p->rbuf[i-3]=='\r' && p->rbuf[i-2]=='\n' && - p->rbuf[i-1]=='\r' && p->rbuf[i]=='\n') { - // parse status line: "HTTP/1.x SP CODE SP ..." - // find first space, then 3-digit code - int code = 0; - char* sp = (char*)memchr(p->rbuf, ' ', p->rlen); - if (sp) code = atoi(sp + 1); - if (code >= 200 && code < 300) { - proxy_established(io); - } else { - hlogw("connfd=%d http proxy CONNECT failed: %d", io->fd, code); - io->error = ERR_CONNECT; - proxy_fail(io); - } - return; - } - } + int have = p->rlen + n; + // search for "\r\n\r\n" in the peeked window (rescan from a safe offset) + int start = p->rlen >= 3 ? p->rlen - 3 : 0; + int term = -1; + for (int i = start + 3; i < have; ++i) { + if (p->rbuf[i-3]=='\r' && p->rbuf[i-2]=='\n' && + p->rbuf[i-1]=='\r' && p->rbuf[i]=='\n') { term = i; break; } + } + if (term < 0) { + // no full header yet: consume the peeked bytes into the accumulator + // (they are all header bytes) and keep reading. + int got = recv(io->fd, (char*)p->rbuf + p->rlen, n, 0); + if (got <= 0) { proxy_fail(io); return; } + p->rlen += got; + continue; } - } - if (p->rlen >= (int)sizeof(p->rbuf)) { - // headers too large, no blank line found - proxy_fail(io); + // full header present. Drain exactly up to and including the terminator, + // leaving any trailing tunnel/greeting bytes in the socket. + int header_len = term + 1; // bytes from socket start + int to_drain = header_len - p->rlen; // not yet consumed + if (to_drain > 0) { + int got = recv(io->fd, (char*)p->rbuf + p->rlen, to_drain, 0); + if (got != to_drain) { proxy_fail(io); return; } + p->rlen += got; + } + // parse status line: "HTTP/1.x SP CODE SP ..." + int code = 0; + char* sp = (char*)memchr(p->rbuf, ' ', p->rlen); + if (sp) code = atoi(sp + 1); + if (code >= 200 && code < 300) { + proxy_established(io); + } else { + hlogw("connfd=%d http proxy CONNECT failed: %d", io->fd, code); + io->error = ERR_CONNECT; + proxy_fail(io); + } + return; } } // Kick off the HTTP CONNECT handshake once the TCP connection to the proxy is up. static void http_connect_start(hio_t* io) { proxy_conn_t* p = io->proxy; - char buf[1024]; + // Max request: "CONNECT " + authority(<=262) + " HTTP/1.1\r\nHost: " + + // authority + "\r\nProxy-Authorization: Basic " + base64(255:255)=~684 + + // "\r\n\r\n" ~= 1.3KB. 2048 leaves headroom. + char buf[2048]; int n = http_connect_build_request(p, buf, (int)sizeof(buf)); if (n < 0) { proxy_fail(io); return; } if (proxy_send(io, buf, n) != 0) { proxy_fail(io); return; } diff --git a/event/socks5.c b/event/socks5.c index fe3f3f0da..5ab7a0026 100644 --- a/event/socks5.c +++ b/event/socks5.c @@ -110,15 +110,23 @@ int socks5_build_connect_request(const proxy_conn_t* s5, unsigned char* buf) { } // Build an HTTP CONNECT request (RFC 7231 4.3.6). The request-target is the -// authority form "host:port"; a Basic Proxy-Authorization header is added when -// credentials are present. +// authority form "host:port"; an IPv6 literal is bracketed ("[addr]:port") per +// RFC 3986. A Basic Proxy-Authorization header is added when credentials are +// present. int http_connect_build_request(const proxy_conn_t* p, char* buf, int bufsize) { const char* host = p->setting.target_host; int port = p->setting.target_port; + // bracket IPv6 literals in authority form + char authority[300]; + if (is_ipv6(host)) { + snprintf(authority, sizeof(authority), "[%s]:%d", host, port); + } else { + snprintf(authority, sizeof(authority), "%s:%d", host, port); + } int n = 0; int r = snprintf(buf + n, bufsize - n, - "CONNECT %s:%d HTTP/1.1\r\nHost: %s:%d\r\n", - host, port, host, port); + "CONNECT %s HTTP/1.1\r\nHost: %s\r\n", + authority, authority); if (r < 0 || r >= bufsize - n) return -1; n += r; diff --git a/http/HttpMessage.cpp b/http/HttpMessage.cpp index 14263353d..90963ab8a 100644 --- a/http/HttpMessage.cpp +++ b/http/HttpMessage.cpp @@ -779,6 +779,11 @@ void HttpRequest::SetProxy(const char* host, int port) { this->host = host; this->port = port; proxy = 1; + // mutually exclusive with the CONNECT-tunnel mode + tunnel_proxy_host.clear(); + tunnel_proxy_port = 0; + tunnel_proxy_username.clear(); + tunnel_proxy_password.clear(); } void HttpRequest::SetAuth(const std::string& auth) { diff --git a/http/HttpMessage.h b/http/HttpMessage.h index aad52de40..4856bcd97 100644 --- a/http/HttpMessage.h +++ b/http/HttpMessage.h @@ -459,8 +459,10 @@ class HV_EXPORT HttpRequest : public HttpMessage { // CONNECT-tunnel proxy (used for https-over-proxy). Unlike SetProxy (plain // HTTP absolute-URI forwarding), this connects to the proxy and issues an // HTTP CONNECT to the origin, then does end-to-end TLS with the origin. + // Mutually exclusive with the forward-proxy mode: clears the `proxy` bit. void SetTunnelProxy(const char* host, int port, const char* username = NULL, const char* password = NULL) { + proxy = 0; // not an absolute-URI forward proxy tunnel_proxy_host = host ? host : ""; tunnel_proxy_port = port; tunnel_proxy_username = username ? username : ""; diff --git a/http/client/AsyncHttpClient.cpp b/http/client/AsyncHttpClient.cpp index 78fd24679..ebc800b6b 100644 --- a/http/client/AsyncHttpClient.cpp +++ b/http/client/AsyncHttpClient.cpp @@ -100,13 +100,19 @@ int AsyncHttpClient::doTaskWithAddr(const HttpClientTaskPtr& task, const sockadd sockaddr_u peeraddr = *paddr; int connfd = -1; - // first get from conn_pools + // Reuse a pooled keep-alive connection when possible. NOT for tunnels: the + // pool is keyed by peeraddr (the proxy), and a pooled plain HTTP-forward (or + // different-origin tunnel) connection to the same proxy would bypass the + // per-connection hio_set_proxy/SSL setup below and send over the wrong + // transport. Tunnels always open a fresh connection. char strAddr[SOCKADDR_STRLEN] = {0}; SOCKADDR_STR(&peeraddr, strAddr); - auto iter = conn_pools.find(strAddr); - if (iter != conn_pools.end()) { - // hlogd("get from conn_pools"); - iter->second.get(connfd); + if (!req->IsTunnelProxy()) { + auto iter = conn_pools.find(strAddr); + if (iter != conn_pools.end()) { + // hlogd("get from conn_pools"); + iter->second.get(connfd); + } } if (connfd < 0) { diff --git a/http/client/HttpClient.cpp b/http/client/HttpClient.cpp index a3b5d8869..ef225eef0 100644 --- a/http/client/HttpClient.cpp +++ b/http/client/HttpClient.cpp @@ -199,12 +199,19 @@ static int http_client_make_request(http_client_t* cli, HttpRequest* req) { } } } - // Proxy-Authorization is only meaningful for the plain-HTTP absolute-URI - // forward proxy (the request is sent to the proxy itself). Clear any value - // first so it (a) never crosses a CONNECT tunnel to the origin and leaks the - // proxy credentials, and (b) does not linger after credentials are cleared - // via setProxyAuth(NULL, NULL) or when a request object is reused. It is - // then regenerated only in the forward-proxy branch below. + // Recompute proxy routing from scratch for THIS request. HttpClient reuses + // one HttpRequest across redirects and repeated sends, so any proxy mode + // left over from a previous route must be cleared first -- otherwise a prior + // tunnel/forward setting could survive a redirect to a no_proxy host or a + // scheme switch and pick the wrong transport. Also drop any generated + // Proxy-Authorization so it never crosses a CONNECT tunnel to the origin and + // never lingers after setProxyAuth(NULL, NULL); it is regenerated below only + // for the plain-HTTP forward proxy (where the request goes to the proxy). + req->proxy = 0; + req->tunnel_proxy_host.clear(); + req->tunnel_proxy_port = 0; + req->tunnel_proxy_username.clear(); + req->tunnel_proxy_password.clear(); req->headers.erase("Proxy-Authorization"); if (use_proxy) { if (https) { @@ -301,8 +308,12 @@ static int http_client_ssl_handshake(http_client_t* cli, int connfd, const char* static int http_client_http_connect(int connfd, const char* origin_host, int origin_port, const std::string& user, const std::string& pass, int blocktime) { - std::string reqstr = hv::asprintf("CONNECT %s:%d HTTP/1.1\r\nHost: %s:%d\r\n", - origin_host, origin_port, origin_host, origin_port); + // authority form; bracket IPv6 literals per RFC 3986 + std::string authority = is_ipv6(origin_host) + ? hv::asprintf("[%s]:%d", origin_host, origin_port) + : hv::asprintf("%s:%d", origin_host, origin_port); + std::string reqstr = hv::asprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n", + authority.c_str(), authority.c_str()); if (!user.empty()) { std::string cred = user + ":" + pass; reqstr += "Proxy-Authorization: Basic " + @@ -720,6 +731,11 @@ static int http_client_exec_curl(http_client_t* cli, HttpRequest* req, HttpRespo // proxy: plain-http forward proxy (req->host is the proxy) or CONNECT // tunnel (tunnel_proxy_* for https). libcurl handles both, incl. CONNECT. + // cli->curl is reused across requests, so always reset proxy options first + // (a stale PROXY / PROXYUSERPWD would otherwise leak into a later request + // that uses a different or no proxy). + curl_easy_setopt(curl, CURLOPT_PROXY, ""); + curl_easy_setopt(curl, CURLOPT_PROXYUSERPWD, ""); if (req->IsProxy()) { curl_easy_setopt(curl, CURLOPT_PROXY, req->host.c_str()); curl_easy_setopt(curl, CURLOPT_PROXYPORT, req->port);