diff --git a/packages/runtime/src/scr_fetch.c b/packages/runtime/src/scr_fetch.c index fe1b92c0f..a7b540adb 100644 --- a/packages/runtime/src/scr_fetch.c +++ b/packages/runtime/src/scr_fetch.c @@ -10,6 +10,7 @@ 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080 #include "scr_runtime.h" +#include "scr_system_errors.h" #include "scr_url_internal.h" #include @@ -2888,14 +2889,21 @@ static int sf_hex_value(char c) { return -1; } +/* EnvHttpProxyAgent sends Basic proxy authentication only when the proxy + * URL has both a username and a password. */ +static bool sf_proxy_has_credentials(const ScrStr *userinfo) { + const char *colon = memchr(userinfo->data, ':', userinfo->len); + return colon != NULL && colon != userinfo->data && + colon + 1 < userinfo->data + userinfo->len; +} + /* EnvHttpProxyAgent derives Basic proxy authentication from the proxy URL's * decoded username/password. ScrUrl keeps the encoded userinfo as one string, - * so decode it here and add the empty-password separator when necessary. */ + * so decode it here. */ static ScrStr *sf_proxy_authorization(const ScrStr *userinfo) { char *plain = malloc(userinfo->len + 1); if (!plain) sf_oom(); size_t plain_len = 0; - bool has_separator = false; for (size_t i = 0; i < userinfo->len;) { char c = userinfo->data[i]; if (c == '%' && i + 2 < userinfo->len) { @@ -2907,11 +2915,9 @@ static ScrStr *sf_proxy_authorization(const ScrStr *userinfo) { continue; } } - if (c == ':') has_separator = true; plain[plain_len++] = c; i++; } - if (!has_separator) plain[plain_len++] = ':'; static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; @@ -4058,15 +4064,154 @@ static void sf_on_response(ScrClosure *cb, ScrHttpReq *res) { sf_release(t); } +typedef struct { int value; const char *name; } SfUvError; +#define SF_UV_ERROR(name, message) { SCR_NODE_UV__##name, #name }, +static const SfUvError sf_uv_errors[] = { SCR_NODE_UV_ERRNO_MAP(SF_UV_ERROR) }; +#undef SF_UV_ERROR + +/* libuv's errno value for a code name; getaddrinfo's ENOTFOUND is + * EAI_NONAME. 0 when the name is not a libuv code. */ +static int sf_uv_errno(const char *name) { + if (strcmp(name, "ENOTFOUND") == 0) return SCR_NODE_UV__EAI_NONAME; + for (size_t i = 0; i < sizeof sf_uv_errors / sizeof sf_uv_errors[0]; i++) { + if (strcmp(sf_uv_errors[i].name, name) == 0) return sf_uv_errors[i].value; + } + return 0; +} + +static const char sf_tunnel_prefix[] = "Proxy response ("; +static const char sf_not_http_prefix[] = "Response does not match the HTTP/1.1 protocol"; + +/* The cause Node's fetch gives a transport failure, built from the + * runtime's failure message. " [ ]" messages + * become Node's system error with code, errno and syscall (plus address + * and port for connect, hostname for getaddrinfo). A tunnel the proxy + * refused becomes undici's DOMException "Request was cancelled." whose + * cause is the AbortError carrying the proxy status, and a proxy reply + * that is not HTTP becomes the HTTPParserError. NULL (no cause) for + * every other message. +1. */ +static ScrDyn *sf_failure_cause(const ScrStr *message) { + if (message->len > sizeof sf_tunnel_prefix - 1 && + memcmp(message->data, sf_tunnel_prefix, sizeof sf_tunnel_prefix - 1) == 0) { + ScrError *aborted = scr_error_new(SCR_ERR_ERROR, (ScrStr *)message); + scr_str_release(aborted->name); + aborted->name = scr_str_new("AbortError", 10); + aborted->name_present = true; + scr_error_set_code(aborted, "UND_ERR_ABORTED"); + ScrDyn *inner = scr_dyn_from_error(aborted); + scr_error_release(aborted); + ScrDyn *options = scr_dyn_new_obj(); + ScrStr *name = scr_str_new("Error", 5); + scr_dyn_obj_set(options, "name", 4, scr_dyn_new_str(name)); + scr_str_release(name); + scr_dyn_obj_set(options, "cause", 5, inner); + ScrStr *text = scr_str_new("Request was cancelled.", 22); + ScrDyn *m = scr_dyn_new_str(text); + scr_str_release(text); + ScrError *dom = scr_domex_new(m, options); + scr_dyn_release(m); + scr_dyn_release(options); + ScrDyn *cause = scr_dyn_from_error(dom); + scr_error_release(dom); + return cause; + } + if (message->len >= sizeof sf_not_http_prefix - 1 && + memcmp(message->data, sf_not_http_prefix, sizeof sf_not_http_prefix - 1) == 0) { + ScrError *parser = scr_error_new(SCR_ERR_ERROR, (ScrStr *)message); + scr_str_release(parser->name); + parser->name = scr_str_new("HTTPParserError", 15); + parser->name_present = true; + ScrDyn *cause = scr_dyn_from_error(parser); + scr_error_release(parser); + return cause; + } + const char *data = message->data; + const char *space = memchr(data, ' ', message->len); + if (space == NULL || space == data) return NULL; + size_t syscall_len = (size_t)(space - data); + const char *code = space + 1; + const char *end = data + message->len; + const char *code_end = code; + while (code_end < end && *code_end != ' ') code_end++; + char code_buf[32]; + size_t code_len = (size_t)(code_end - code); + if (code_len < 2 || code_len >= sizeof code_buf || code[0] != 'E') return NULL; + for (size_t i = 0; i < code_len; i++) { + char c = code[i]; + if (!((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_')) return NULL; + } + memcpy(code_buf, code, code_len); + code_buf[code_len] = '\0'; + int uv = sf_uv_errno(code_buf); + if (uv == 0) return NULL; + ScrError *e = scr_error_new(SCR_ERR_ERROR, (ScrStr *)message); + scr_error_set_code(e, code_buf); + e->system_errno = uv; + e->system_call = scr_str_new(data, syscall_len); + ScrDyn *cause = scr_dyn_from_error(e); + scr_error_release(e); + const char *detail = code_end < end ? code_end + 1 : end; + size_t detail_len = (size_t)(end - detail); + if (syscall_len == 7 && memcmp(data, "connect", 7) == 0 && detail_len > 0) { + const char *colon = NULL; + for (const char *q = detail; q < end; q++) { + if (*q == ':') colon = q; + } + if (colon != NULL && colon > detail && colon + 1 < end) { + ScrStr *address = scr_str_new(detail, (size_t)(colon - detail)); + scr_dyn_obj_set(cause, "address", 7, scr_dyn_new_str(address)); + scr_str_release(address); + scr_dyn_obj_set(cause, "port", 4, scr_dyn_new_num(strtod(colon + 1, NULL))); + } + } else if (syscall_len == 11 && memcmp(data, "getaddrinfo", 11) == 0 && detail_len > 0) { + ScrStr *hostname = scr_str_new(detail, detail_len); + scr_dyn_obj_set(cause, "hostname", 8, scr_dyn_new_str(hostname)); + scr_str_release(hostname); + } + return cause; +} + +/* A plain Error cause. +1. */ +static ScrDyn *sf_plain_cause(const char *message) { + ScrStr *text = scr_str_new(message, strlen(message)); + ScrError *e = scr_error_new(SCR_ERR_ERROR, text); + scr_str_release(text); + ScrDyn *cause = scr_dyn_from_error(e); + scr_error_release(e); + return cause; +} + +/* Rejects with TypeError "fetch failed" whose cause is `cause` (moves; + * NULL rejects without a cause). */ +static void sf_reject_cause(SfTransfer *t, ScrDyn *cause) { + if (t->done || cause == NULL) { + scr_dyn_release(cause); + sf_reject(t, "fetch failed"); + return; + } + ScrError *e = NULL; + ScrDyn *reason = sf_type_reason("fetch failed", &e); + scr_error_set_cause(e, cause); + scr_dyn_release(cause); + scr_error_release(e); + sf_reject_reason(t, reason); + scr_dyn_release(reason); +} + +/* Rejects with TypeError "fetch failed", with Node's cause when the + * failure message has one. */ +static void sf_reject_failure(SfTransfer *t, const ScrStr *message) { + sf_reject_cause(t, message != NULL ? sf_failure_cause(message) : NULL); +} + static void sf_on_client_error(ScrClosure *cb, ScrStr *message) { - (void)message; SfTransfer *t = sf_from(cb); if (!t) return; if (!t->done) { if (t->response_sent && t->response_stream) { sf_response_terminate(t); } else { - sf_reject(t, "fetch failed"); + sf_reject_failure(t, message); } } sf_release(t); @@ -4150,25 +4295,77 @@ static bool sf_redirect(SfTransfer *t, int status, return true; } +/* A CONNECT tunnel for one request: the head sent to the proxy, and the + * target's TLS client context when the target is https. */ +typedef struct { + char *head; + size_t head_len; + void *tls_ctx; +} SfTunnel; + +/* undici's CONNECT head: the target as host:port, its Host value, and + * the proxy credentials from the proxy URL. Returns malloc'd storage. */ +static char *sf_tunnel_head(const ScrUrl *url, int port, const char *authority, + size_t authority_len, const ScrUrl *proxy, size_t *len_out) { + ScrStr *authorization = sf_proxy_has_credentials(proxy->userinfo) + ? sf_proxy_authorization(proxy->userinfo) + : NULL; + size_t cap = url->host->len + authority_len + 160 + (authorization ? authorization->len : 0); + char *head = malloc(cap); + if (!head) sf_oom(); + int n = snprintf(head, cap, "CONNECT %.*s:%d HTTP/1.1\r\nhost: %.*s\r\nconnection: close\r\n", + (int)url->host->len, url->host->data, port, (int)authority_len, authority); + if (authorization) { + n += snprintf(head + n, cap - (size_t)n, "proxy-authorization: %.*s\r\n", + (int)authorization->len, authorization->data); + scr_str_release(authorization); + } + n += snprintf(head + n, cap - (size_t)n, "proxy-connection: keep-alive\r\n\r\n"); + *len_out = (size_t)n; + return head; +} + +static SfTunnel *sf_tunnel_new(const ScrUrl *url, int port, const char *authority, + size_t authority_len, const ScrUrl *proxy, void *tls_ctx) { + SfTunnel *tunnel = malloc(sizeof *tunnel); + if (!tunnel) sf_oom(); + tunnel->head = sf_tunnel_head(url, port, authority, authority_len, proxy, &tunnel->head_len); + tunnel->tls_ctx = tls_ctx; + return tunnel; +} + +/* The client's wrap hook for a tunneled request: the CONNECT exchange + * runs on the proxy socket, then the target's TLS session (if any) runs + * inside the tunnel. */ +static void sf_tunnel_wrap(ScrNetSocket *sock, void *ctx) { + SfTunnel *tunnel = ctx; + scr_net_sock_set_tunnel(sock, tunnel->head, tunnel->head_len); + if (tunnel->tls_ctx) scr_tls_fetch_client_wrap(sock, tunnel->tls_ctx); + free(tunnel->head); + free(tunnel); +} + static bool sf_start_hop(SfTransfer *t) { ScrUrl *url = t->url; bool https = sf_eq_ci(url->scheme, "https"); int default_port = https ? 443 : 80; int port = sf_port(url, default_port); if (sf_bad_port(port)) { - sf_reject(t, "fetch failed"); + sf_reject_cause(t, sf_plain_cause("bad port")); return false; } bool invalid_proxy = false; ScrUrl *proxy = sf_proxy_for(url, https, port, &invalid_proxy); bool proxy_http = proxy && sf_eq_ci(proxy->scheme, "http"); bool proxy_https = proxy && sf_eq_ci(proxy->scheme, "https"); - if (invalid_proxy || (proxy && !proxy_http && !proxy_https) || - (https && proxy)) { + if (invalid_proxy || (proxy && !proxy_http && !proxy_https)) { scr_url_release(proxy); sf_reject(t, "fetch failed"); return false; } + /* A proxy carries every request through a CONNECT tunnel, as undici's + * ProxyAgent does; an https:// proxy runs the tunnel inside a TLS + * session to the proxy. */ size_t user_len = (size_t)scr_arr_len(t->headers); ScrArr *headers = scr_arr_new(SCR_ELEM_STR, user_len + 16); @@ -4214,13 +4411,6 @@ static bool sf_start_hop(SfTransfer *t) { scr_arr_push_ref(headers, name); scr_arr_push_ref(headers, value); } - if (proxy && proxy->userinfo->len > 0 && - !sf_pairs_have(t->headers, "proxy-authorization")) { - ScrStr *authorization = sf_proxy_authorization(proxy->userinfo); - sf_push_header_text(headers, "proxy-authorization", - authorization->data, authorization->len); - scr_str_release(authorization); - } if (!sf_pairs_have(t->headers, "accept")) { sf_push_header_text(headers, "accept", "*/*", 3); } @@ -4240,17 +4430,29 @@ static bool sf_start_hop(SfTransfer *t) { ScrStr *bare = sf_bare_host(proxy ? proxy->host : url->host); ScrStr *dial = scr_net_blocking_lookup(bare); - ScrStr *path = proxy ? sf_url_serialize(url) : sf_path(url); + ScrStr *path = sf_path(url); int dial_port = proxy ? sf_port(proxy, proxy_https ? 443 : 80) : port; - ScrStr *sni = - https ? sf_bare_host(url->host) - : proxy_https ? sf_bare_host(proxy->host) : NULL; - void *tls_ctx = - sni ? scr_tls_fetch_client_ctx(sni, true) : NULL; + ScrStr *sni = https ? sf_bare_host(url->host) : NULL; + void (*wrap)(ScrNetSocket *, void *) = NULL; + void *wrap_ctx = NULL; + if (proxy_https) { + size_t head_len = 0; + char *head = sf_tunnel_head(url, port, authority, (size_t)authority_len, proxy, &head_len); + wrap_ctx = scr_tls_fetch_tunnel_ctx(bare, sni, head, head_len); + wrap = &scr_tls_fetch_tunnel_wrap; + free(head); + } else if (proxy_http) { + void *tls_ctx = sni ? scr_tls_fetch_client_ctx(sni, true) : NULL; + wrap_ctx = sf_tunnel_new(url, port, authority, (size_t)authority_len, proxy, tls_ctx); + wrap = &sf_tunnel_wrap; + } else if (sni) { + wrap_ctx = scr_tls_fetch_client_ctx(sni, true); + wrap = &scr_tls_fetch_client_wrap; + } ScrHttpClientReq *client = scr_http_request_ex( dial, dial_port, path, t->method, 0, headers, false, NULL, NULL, - default_port, sni ? &scr_tls_fetch_client_wrap : NULL, tls_ctx); + default_port, wrap, wrap_ctx); scr_arr_release(headers); scr_str_release(path); scr_str_release(dial); diff --git a/packages/runtime/src/scr_http2.h b/packages/runtime/src/scr_http2.h index d5b1cae2d..e37e32c08 100644 --- a/packages/runtime/src/scr_http2.h +++ b/packages/runtime/src/scr_http2.h @@ -196,6 +196,13 @@ ScrHttpClientReq *scr_https_request_agent(ScrStr *host /*borrowed*/, double port * the fresh socket. The ctx moves into the transport at wrap time. */ void *scr_tls_fetch_client_ctx(ScrStr *host /*borrowed*/, bool reject_unauthorized); void scr_tls_fetch_client_wrap(ScrNetSocket *sock, void *ctx); +/* The fetch tunnel through an https:// proxy: TLS to the proxy, the + * CONNECT head (copied) inside it, then TLS to target_host inside the + * tunnel (NULL target_host: an http:// target's plaintext). The ctx moves + * into the socket's transport at wrap time. */ +void *scr_tls_fetch_tunnel_ctx(ScrStr *proxy_host /*borrowed*/, ScrStr *target_host /*borrowed, nullable*/, + const char *request, size_t len); +void scr_tls_fetch_tunnel_wrap(ScrNetSocket *sock, void *ctx); /* The loop-side registration (scr_async.c, always linked): `pending` * joins the exhaustion test (a live handle keeps the process alive, like * Node's active TCP handles), `dispatch` runs at every turn top like the diff --git a/packages/runtime/src/scr_net.c b/packages/runtime/src/scr_net.c index 64f5dee02..9aba35b96 100644 --- a/packages/runtime/src/scr_net.c +++ b/packages/runtime/src/scr_net.c @@ -593,8 +593,28 @@ struct ScrNetSocket { bool t_want_write; /* a handshake send hit EAGAIN: arm the write filter */ bool in_registry; struct ScrNetSocket *next; + /* an HTTP CONNECT tunnel (scr_net_sock_set_tunnel), set from the dial + * until the proxy answers 200: writes buffer and the transport + * handshake waits until the tunnel is up */ + struct ScrNetTunnel *tunnel; }; +/* The CONNECT head still being sent from out_off, and the proxy's + * response head as it arrives. */ +typedef struct ScrNetTunnel { + char *out; + size_t out_len, out_off; + char *in; + size_t in_len; +} ScrNetTunnel; + +static void scr_net_tunnel_free(ScrNetTunnel *t) { + if (t == NULL) return; + free(t->out); + free(t->in); + free(t); +} + #ifdef SCR_RC_AUDIT static SCR_TL long scr_net_live = 0; long scr_net_live_count(void) { return scr_net_live; } @@ -665,6 +685,7 @@ void scr_net_sock_release(ScrNetSocket *s) { free(s->dial_ips); scr_str_release(s->dial_err); scr_str_release(s->dial_host); + scr_net_tunnel_free(s->tunnel); free(s->wbuf); free(s->rbuf); if (s->pipe_dst) scr_net_sock_release(s->pipe_dst); @@ -910,6 +931,7 @@ static ScrNetSocket *scr_net_sock_new(void) { static void scr_net_sock_update_read(ScrNetSocket *s) { bool want = s->fd >= 0 && !s->connecting && !s->rd_eof && !s->user_paused && (s->data_ls.n > 0 || s->pipe_dst != NULL || s->native_data != NULL || + s->tunnel != NULL /* the proxy's CONNECT answer */ || s->flowing /* resume(): flow (and discard sans listeners) */ || s->readable_ls.n > 0 /* paused-mode consumer: bytes buffer */ || s->tops != NULL /* a transport is ALWAYS a consumer: the TLS @@ -935,7 +957,9 @@ static void scr_net_sock_update_read(ScrNetSocket *s) { } static void scr_net_sock_update_write(ScrNetSocket *s) { - bool want = s->fd >= 0 && (s->connecting || s->wlen > s->whead || s->t_want_write); + bool want = s->fd >= 0 && + (s->connecting || (s->tunnel != NULL ? s->tunnel->out_off < s->tunnel->out_len + : s->wlen > s->whead || s->t_want_write)); if (want && !s->write_armed) { scr_net_watch_write(s->fd, s, true); s->write_armed = true; @@ -960,7 +984,7 @@ static void scr_net_sock_close_fd(ScrNetSocket *s) { /* The FIN half: called when the write half is ENDING and the buffer just * drained (or was empty). Full close once the read half is done too. */ static void scr_net_sock_maybe_finish_write(ScrNetSocket *s) { - if (s->fd < 0 || !s->wr_ending || s->wr_done || s->wlen > s->whead) return; + if (s->fd < 0 || !s->wr_ending || s->wr_done || s->wlen > s->whead || s->tunnel != NULL) return; /* a transport says goodbye first (TLS close_notify), best-effort */ if (s->tops && s->t_est) s->tops->shutdown_write(s->tctx); shutdown(s->fd, SHUT_WR); @@ -1009,7 +1033,8 @@ static void scr_net_sock_write_raw(ScrNetSocket *s, const char *data, size_t len if (!pre_dial && (s->fd < 0 || s->wr_ending || s->close_emitted)) return; if (len == 0) return; s->bytes_written += len; /* accepted bytes (Node counts buffered ones too) */ - if (pre_dial || s->connecting || s->wlen > s->whead || (s->tops && !s->t_est)) { + if (pre_dial || s->connecting || s->tunnel != NULL || s->wlen > s->whead || + (s->tops && !s->t_est)) { /* mid-connect and mid-handshake bytes buffer; the flush after * 'connect'/establishment sends them */ scr_net_sock_buffer(s, data, len); @@ -1053,7 +1078,7 @@ void scr_net_sock_writev_native(ScrNetSocket *s, const ScrNetSlice *slices, size int cnt = 0; size_t total = 0; bool immediate = n <= 8 && s->fd >= 0 && !s->wr_ending && !s->close_emitted && - !s->connecting && s->wlen == s->whead && !s->tops; + !s->connecting && s->tunnel == NULL && s->wlen == s->whead && !s->tops; if (immediate) { for (size_t i = 0; i < n; i++) { if (slices[i].len == 0) continue; @@ -1106,6 +1131,7 @@ void scr_net_sock_writev_native(ScrNetSocket *s, const ScrNetSlice *slices, size /* Flush on write-readiness; drives the FIN when ENDING. */ static void scr_net_sock_flush(ScrNetSocket *s) { + if (s->tunnel != NULL) return; /* nothing moves until the proxy answers */ if (s->tops && !s->t_est) return; /* nothing moves until the handshake ends */ while (s->fd >= 0 && s->wlen > s->whead) { ssize_t sent = s->tops ? s->tops->xwrite(s->tctx, s->wbuf + s->whead, s->wlen - s->whead) @@ -1206,6 +1232,7 @@ static void scr_net_sock_transport_pump(ScrNetSocket *s); static void scr_net_sock_read(ScrNetSocket *s) { size_t reads_left = 32; /* at most 2 MiB with the 64 KiB read buffer */ + if (s->tunnel != NULL) return; /* readiness feeds the tunnel pump instead */ if (s->tops && !s->t_est) return; /* readiness feeds the handshake pump instead */ for (;;) { if (s->fd < 0 || s->user_paused) return; @@ -1298,6 +1325,8 @@ static void scr_net_sock_read(ScrNetSocket *s) { static void scr_net_sock_transport_pump(ScrNetSocket *s); static void scr_net_sock_dial_next(ScrNetSocket *s); +static void scr_net_sock_connected(ScrNetSocket *s); +static void scr_net_sock_tunnel_pump(ScrNetSocket *s); /* Connect completion (write-readiness while connecting). */ static void scr_net_sock_connect_done(ScrNetSocket *s) { @@ -1342,6 +1371,19 @@ static void scr_net_sock_connect_done(ScrNetSocket *s) { scr_str_release(s->dial_err); s->dial_err = NULL; } + if (s->tunnel != NULL) { + /* TCP to the proxy is up: the CONNECT exchange runs first */ + scr_net_sock_touch(s); + scr_net_sock_tunnel_pump(s); + return; + } + scr_net_sock_connected(s); +} + +/* The connection is usable (TCP up, and the proxy tunnel when there is + * one): the transport handshake starts, or 'connect' fires and buffered + * writes flush. */ +static void scr_net_sock_connected(ScrNetSocket *s) { scr_net_sock_update_write(s); /* keeps the filter iff bytes are buffered */ scr_net_sock_update_read(s); scr_net_sock_touch(s); @@ -1363,6 +1405,106 @@ static void scr_net_sock_connect_done(ScrNetSocket *s) { scr_net_sock_flush(s); } +/* Fails a tunnel being set up: the message is the socket's 'error'. */ +static void scr_net_sock_tunnel_fail(ScrNetSocket *s, const char *msg) { + if (!s->pending_err) s->pending_err = scr_str_new(msg, strlen(msg)); + s->had_error = true; + scr_net_sock_close_fd(s); +} + +int scr_net_tunnel_status(const char *head, size_t len) { + if (len >= 12 && memcmp(head, "HTTP/1.", 7) == 0 && head[8] == ' ' && + isdigit((unsigned char)head[9]) && isdigit((unsigned char)head[10]) && + isdigit((unsigned char)head[11])) { + return (head[9] - '0') * 100 + (head[10] - '0') * 10 + (head[11] - '0'); + } + return -1; +} + +void scr_net_tunnel_failure(int status, char *msg, size_t cap) { + if (status < 0) { + /* llhttp's message for a response that does not start with a status line */ + snprintf(msg, cap, "Response does not match the HTTP/1.1 protocol (Expected HTTP/, RTSP/ or ICE/)"); + } else { + snprintf(msg, cap, "Proxy response (%d) !== 200 when HTTP Tunneling", status); + } +} + +/* The proxy's response head is at most this long. */ +#define SCR_NET_TUNNEL_HEAD_MAX 16384u + +/* Drives the CONNECT exchange off readiness: sends the request head, + * then reads the proxy's response head. A 200 status makes the + * connection usable (bytes after the head belong to the tunnel); any + * other status fails the socket with undici's message. */ +static void scr_net_sock_tunnel_pump(ScrNetSocket *s) { + ScrNetTunnel *t = s->tunnel; + while (s->fd >= 0 && t->out_off < t->out_len) { + ssize_t sent = + scr_net_send(s->fd, t->out + t->out_off, t->out_len - t->out_off); + if (sent < 0) { + if (errno == EINTR) continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) break; + char msg[96]; + snprintf(msg, sizeof msg, "write %s", scr_net_errname(errno)); + scr_net_sock_tunnel_fail(s, msg); + return; + } + t->out_off += (size_t)sent; + } + scr_net_sock_update_write(s); + if (s->fd < 0 || t->out_off < t->out_len) return; + for (;;) { + char buf[4096]; + ssize_t n = read(s->fd, buf, sizeof buf); + if (n < 0) { + if (errno == EINTR) continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) { + scr_net_sock_update_read(s); + return; + } + char msg[96]; + snprintf(msg, sizeof msg, "read %s", scr_net_errname(errno)); + scr_net_sock_tunnel_fail(s, msg); + return; + } + if (n == 0) { + scr_net_sock_tunnel_fail(s, "socket hang up"); + return; + } + if (t->in_len + (size_t)n > SCR_NET_TUNNEL_HEAD_MAX) { + scr_net_sock_tunnel_fail(s, "Proxy response head is too large"); + return; + } + char *grown = realloc(t->in, t->in_len + (size_t)n); + if (!grown) scr_net_oom(); + t->in = grown; + memcpy(t->in + t->in_len, buf, (size_t)n); + t->in_len += (size_t)n; + size_t end = 0; + for (size_t i = 3; i < t->in_len; i++) { + if (memcmp(t->in + i - 3, "\r\n\r\n", 4) == 0) { + end = i + 1; + break; + } + } + if (end == 0) continue; + int status = scr_net_tunnel_status(t->in, end); + if (status != 200) { + char msg[128]; + scr_net_tunnel_failure(status, msg, sizeof msg); + scr_net_sock_tunnel_fail(s, msg); + return; + } + scr_net_sock_append_rbuf(s, t->in + end, t->in_len - end); + scr_net_tunnel_free(t); + s->tunnel = NULL; + scr_net_sock_touch(s); + scr_net_sock_connected(s); + return; + } +} + /* Drive the transport handshake off readiness until it settles. On * establishment: the protocol layer attaches (https' parser), a * deferred server 'connection' fires ('secureConnection' timing), the @@ -1371,7 +1513,7 @@ static void scr_net_sock_connect_done(ScrNetSocket *s) { * handshake flight delivers. Failure already tore the socket down via * scr_net_sock_transport_error. */ static void scr_net_sock_transport_pump(ScrNetSocket *s) { - if (!s->tops || s->t_est || s->fd < 0 || s->connecting) return; + if (!s->tops || s->t_est || s->fd < 0 || s->connecting || s->tunnel != NULL) return; int r = s->tops->handshake(s->tctx); if (r == 0) { scr_net_sock_update_read(s); @@ -3907,9 +4049,9 @@ ScrDyn *scr_net_sock_addr_field(ScrNetSocket *s, double field) { double scr_net_sock_bytes_read(ScrNetSocket *s) { return (double)s->bytes_read; } /* socket.connecting: a dial in progress (deferred, looking up, or - * awaiting the TCP handshake). */ + * awaiting the TCP handshake or the proxy tunnel). */ bool scr_net_sock_connecting(ScrNetSocket *s) { - return s->fd >= 0 ? s->connecting : (s->dial_deferred || s->lookup_wait); + return s->fd >= 0 ? s->connecting || s->tunnel != NULL : (s->dial_deferred || s->lookup_wait); } /* socket.pending: no open handle yet (or anymore), or still connecting. */ @@ -4253,7 +4395,7 @@ void scr_net_sock_on_connect(ScrNetSocket *s, ScrClosure *cb /*moves*/, bool onc scr_closure_release(cb); return; } - if (!s->connecting) { + if (!s->connecting && s->tunnel == NULL) { /* already connected: Node fires a late once('connect')… never — the * event is past. Release, like listeners after 'exit'. */ scr_closure_release(cb); @@ -4365,7 +4507,7 @@ bool scr_net_sock_writable(ScrNetSocket *s) { } bool scr_net_sock_established(ScrNetSocket *s) { - return s->fd >= 0 && !s->connecting && (!s->tops || s->t_est); + return s->fd >= 0 && !s->connecting && s->tunnel == NULL && (!s->tops || s->t_est); } /* The protocol layer's timeout/error hooks (scr_http.c's client and the @@ -4393,6 +4535,17 @@ void scr_net_sock_write_native(ScrNetSocket *s, const char *buf, size_t n) { /* ── the transport hooks (scr_tls.c) ─────────────────────────────────── */ +void scr_net_sock_set_tunnel(ScrNetSocket *s, const char *request, size_t len) { + if (s->fd < 0 || !s->connecting || s->tunnel != NULL) return; + ScrNetTunnel *t = calloc(1, sizeof *t); + if (!t) scr_net_oom(); + t->out = malloc(len); + if (!t->out) scr_net_oom(); + memcpy(t->out, request, len); + t->out_len = len; + s->tunnel = t; +} + void scr_net_sock_set_transport(ScrNetSocket *s, const ScrNetTransportOps *ops, void *tctx) { s->tops = ops; s->tctx = tctx; @@ -4811,6 +4964,7 @@ static void scr_net_dispatch(void) { * failure may have closed. */ if (evs[i].events & SCRP_WRITABLE) { if (s->connecting) scr_net_sock_connect_done(s); + else if (s->tunnel != NULL) scr_net_sock_tunnel_pump(s); else if (s->tops && !s->t_est) { s->t_want_write = false; scr_net_sock_transport_pump(s); @@ -4821,7 +4975,8 @@ static void scr_net_dispatch(void) { } } if ((evs[i].events & SCRP_READABLE) && !scr_exc_pending() && s->fd >= 0) { - if (s->tops && !s->t_est) scr_net_sock_transport_pump(s); + if (s->tunnel != NULL) scr_net_sock_tunnel_pump(s); + else if (s->tops && !s->t_est) scr_net_sock_transport_pump(s); else scr_net_sock_read(s); } } diff --git a/packages/runtime/src/scr_net.h b/packages/runtime/src/scr_net.h index 539c14cdb..12e30acb4 100644 --- a/packages/runtime/src/scr_net.h +++ b/packages/runtime/src/scr_net.h @@ -341,6 +341,18 @@ typedef struct ScrNetTransportOps { void (*free)(void *tctx); } ScrNetTransportOps; void scr_net_sock_set_transport(ScrNetSocket *s, const ScrNetTransportOps *ops, void *tctx); +/* Routes a client socket through an HTTP CONNECT proxy. Call it right + * after the dial (scr_net_connect to the proxy) and before any transport + * is set: once TCP is up, `request` (the CONNECT head, copied) is sent + * and the proxy's response head is read. A 200 status starts the + * transport handshake, or fires 'connect'; any other status fails the + * socket with "Proxy response () !== 200 when HTTP Tunneling". */ +void scr_net_sock_set_tunnel(ScrNetSocket *s, const char *request, size_t len); +/* The status code of a CONNECT response head (head[0..len) ends with the + * blank line), or -1 when it does not start with an HTTP/1.x status + * line; and undici's message for a status other than 200 (or -1). */ +int scr_net_tunnel_status(const char *head, size_t len); +void scr_net_tunnel_failure(int status, char *msg, size_t cap); int scr_net_sock_fd(ScrNetSocket *s); /* Drain up to n bytes of the socket's receive buffer (peeked/unshifted * bytes — the demux path); consumers call it before the fd. */ diff --git a/packages/runtime/src/scr_tls.c b/packages/runtime/src/scr_tls.c index 1f481c4fd..9cca0b051 100644 --- a/packages/runtime/src/scr_tls.c +++ b/packages/runtime/src/scr_tls.c @@ -1089,6 +1089,234 @@ void scr_tls_fetch_client_wrap(ScrNetSocket *sock, void *ctx) { scr_tls_cli_wrap(sock, ctx); } +/* ── the fetch tunnel through an https:// proxy ─────────────────────── + * One transport carries three stages over the proxy socket: the TLS + * session to the proxy (verified against the proxy host), the CONNECT + * exchange inside it, then the target's TLS session (verified against + * the target host) inside the tunnel. An http:// target uses the + * tunnel's plaintext directly. */ + +enum { SCR_TUN_OUTER = 0, SCR_TUN_SEND, SCR_TUN_HEAD, SCR_TUN_INNER, SCR_TUN_UP }; + +typedef struct { + ScrTlsSock *outer; /* the session to the proxy */ + ScrTlsSock *inner; /* the session to the target; NULL for http:// */ + int stage; + char *req; /* the CONNECT head */ + size_t req_len, req_off; + char *head; /* the proxy's response head as it arrives */ + size_t head_len; + char *extra; /* tunnel bytes that arrived with the response head */ + size_t extra_len, extra_off; +} ScrTlsTunnel; + +static ScrTlsSock *scr_tls_cli_sock(ScrNetSocket *sock, ScrTlsCli *cli) { + ScrTlsSock *t = calloc(1, sizeof *t); + if (!t) scr_tls_oom(); + mbedtls_ssl_init(&t->ssl); + if (mbedtls_ssl_setup(&t->ssl, &cli->conf) != 0) scr_tls_oom(); + (void)mbedtls_ssl_set_hostname(&t->ssl, cli->hostname); + mbedtls_ssl_set_verify(&t->ssl, &scr_tls_vrfy_cb, t); + t->sock = sock; + t->cli = cli; + return t; +} + +/* Reads tunnel plaintext: bytes that arrived with the proxy's head + * first, then the proxy session. mbedTLS return conventions. */ +static int scr_tls_tunnel_read(ScrTlsTunnel *tun, unsigned char *buf, size_t len) { + if (tun->extra_off < tun->extra_len) { + size_t n = tun->extra_len - tun->extra_off; + if (n > len) n = len; + memcpy(buf, tun->extra + tun->extra_off, n); + tun->extra_off += n; + return (int)n; + } + int r = mbedtls_ssl_read(&tun->outer->ssl, buf, len); + if (r == MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY) return 0; +#if defined(MBEDTLS_ERR_SSL_RECEIVED_NEW_SESSION_TICKET) + if (r == MBEDTLS_ERR_SSL_RECEIVED_NEW_SESSION_TICKET) return MBEDTLS_ERR_SSL_WANT_READ; +#endif + return r; +} + +/* The target session's BIOs: records travel inside the proxy session. */ +static int scr_tls_tunnel_bio_send(void *ctx, const unsigned char *buf, size_t len) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)ctx; + return mbedtls_ssl_write(&tun->outer->ssl, buf, len); +} + +static int scr_tls_tunnel_bio_recv(void *ctx, unsigned char *buf, size_t len) { + return scr_tls_tunnel_read((ScrTlsTunnel *)ctx, buf, len); +} + +static int scr_tls_tunnel_handshake(void *tctx) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)tctx; + ScrNetSocket *sock = tun->outer->sock; + if (tun->stage == SCR_TUN_OUTER) { + int r = scr_tls_handshake(tun->outer); + if (r <= 0) return r; + tun->outer->established = true; + tun->stage = SCR_TUN_SEND; + } + if (tun->stage == SCR_TUN_SEND) { + while (tun->req_off < tun->req_len) { + int r = mbedtls_ssl_write(&tun->outer->ssl, (const unsigned char *)tun->req + tun->req_off, + tun->req_len - tun->req_off); + if (r == MBEDTLS_ERR_SSL_WANT_READ || r == MBEDTLS_ERR_SSL_WANT_WRITE) return 0; + if (r < 0) { + scr_net_sock_transport_error(sock, "write EPIPE"); + return -1; + } + tun->req_off += (size_t)r; + } + tun->stage = SCR_TUN_HEAD; + } + if (tun->stage == SCR_TUN_HEAD) { + for (;;) { + unsigned char buf[4096]; + int r = scr_tls_tunnel_read(tun, buf, sizeof buf); + if (r == MBEDTLS_ERR_SSL_WANT_READ || r == MBEDTLS_ERR_SSL_WANT_WRITE) return 0; + if (r < 0 && r != MBEDTLS_ERR_SSL_CONN_EOF) { + scr_net_sock_transport_error(sock, "read ECONNRESET"); + return -1; + } + if (r <= 0) { + scr_net_sock_transport_error(sock, "socket hang up"); + return -1; + } + if (tun->head_len + (size_t)r > 16384) { + scr_net_sock_transport_error(sock, "Proxy response head is too large"); + return -1; + } + char *grown = realloc(tun->head, tun->head_len + (size_t)r); + if (!grown) scr_tls_oom(); + tun->head = grown; + memcpy(tun->head + tun->head_len, buf, (size_t)r); + tun->head_len += (size_t)r; + size_t end = 0; + for (size_t i = 3; i < tun->head_len; i++) { + if (memcmp(tun->head + i - 3, "\r\n\r\n", 4) == 0) { + end = i + 1; + break; + } + } + if (end == 0) continue; + int status = scr_net_tunnel_status(tun->head, end); + if (status != 200) { + char msg[128]; + scr_net_tunnel_failure(status, msg, sizeof msg); + scr_net_sock_transport_error(sock, msg); + return -1; + } + tun->extra = malloc(tun->head_len - end + 1); + if (!tun->extra) scr_tls_oom(); + memcpy(tun->extra, tun->head + end, tun->head_len - end); + tun->extra_len = tun->head_len - end; + tun->extra_off = 0; + free(tun->head); + tun->head = NULL; + tun->head_len = 0; + tun->stage = tun->inner != NULL ? SCR_TUN_INNER : SCR_TUN_UP; + break; + } + } + if (tun->stage == SCR_TUN_INNER) { + int r = scr_tls_handshake(tun->inner); + if (r <= 0) return r; + tun->stage = SCR_TUN_UP; + } + return 1; +} + +static ssize_t scr_tls_tunnel_xread(void *tctx, char *buf, size_t n) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)tctx; + if (tun->inner != NULL) return scr_tls_xread(tun->inner, buf, n); + if (tun->extra_off < tun->extra_len) { + return scr_tls_tunnel_read(tun, (unsigned char *)buf, n); + } + return scr_tls_xread(tun->outer, buf, n); +} + +static ssize_t scr_tls_tunnel_xwrite(void *tctx, const char *buf, size_t n) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)tctx; + return scr_tls_xwrite(tun->inner != NULL ? tun->inner : tun->outer, buf, n); +} + +static void scr_tls_tunnel_on_established(void *tctx) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)tctx; + if (tun->inner != NULL) tun->inner->established = true; +} + +static bool scr_tls_tunnel_pending(void *tctx) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)tctx; + return tun->extra_off < tun->extra_len || (tun->inner != NULL && scr_tls_pending(tun->inner)) || + scr_tls_pending(tun->outer); +} + +static void scr_tls_tunnel_shutdown_write(void *tctx) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)tctx; + if (tun->inner != NULL) scr_tls_shutdown_write(tun->inner); + scr_tls_shutdown_write(tun->outer); +} + +static void scr_tls_tunnel_free(void *tctx) { + ScrTlsTunnel *tun = (ScrTlsTunnel *)tctx; + if (tun->inner != NULL) scr_tls_free(tun->inner); + scr_tls_free(tun->outer); + free(tun->req); + free(tun->head); + free(tun->extra); + free(tun); +} + +static const ScrNetTransportOps SCR_TLS_TUNNEL_OPS = { + .xread = scr_tls_tunnel_xread, + .xwrite = scr_tls_tunnel_xwrite, + .handshake = scr_tls_tunnel_handshake, + .on_established = scr_tls_tunnel_on_established, + .pending = scr_tls_tunnel_pending, + .shutdown_write = scr_tls_tunnel_shutdown_write, + .free = scr_tls_tunnel_free, +}; + +typedef struct { + ScrTlsCli *proxy; /* moves into the transport */ + ScrTlsCli *target; /* NULL for an http:// target */ + char *req; + size_t req_len; +} ScrTlsTunnelCtx; + +void *scr_tls_fetch_tunnel_ctx(ScrStr *proxy_host /*borrowed*/, ScrStr *target_host /*borrowed, nullable*/, + const char *request, size_t len) { + ScrTlsTunnelCtx *ctx = calloc(1, sizeof *ctx); + if (!ctx) scr_tls_oom(); + ctx->proxy = scr_tls_cli_new(proxy_host, true, NULL, 0); + ctx->target = target_host != NULL ? scr_tls_cli_new(target_host, true, NULL, 0) : NULL; + ctx->req = malloc(len); + if (!ctx->req) scr_tls_oom(); + memcpy(ctx->req, request, len); + ctx->req_len = len; + return ctx; +} + +void scr_tls_fetch_tunnel_wrap(ScrNetSocket *sock, void *vctx) { + ScrTlsTunnelCtx *ctx = (ScrTlsTunnelCtx *)vctx; + ScrTlsTunnel *tun = calloc(1, sizeof *tun); + if (!tun) scr_tls_oom(); + tun->outer = scr_tls_cli_sock(sock, ctx->proxy); + mbedtls_ssl_set_bio(&tun->outer->ssl, tun->outer, scr_tls_bio_send, scr_tls_bio_recv, NULL); + if (ctx->target != NULL) { + tun->inner = scr_tls_cli_sock(sock, ctx->target); + mbedtls_ssl_set_bio(&tun->inner->ssl, tun, scr_tls_tunnel_bio_send, scr_tls_tunnel_bio_recv, + NULL); + } + tun->req = ctx->req; + tun->req_len = ctx->req_len; + free(ctx); + scr_net_sock_set_transport(sock, &SCR_TLS_TUNNEL_OPS, tun); +} + ScrHttpClientReq *scr_https_request(ScrStr *host /*borrowed*/, double port, ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, diff --git a/tests/fixtures/fetch/servers.mjs b/tests/fixtures/fetch/servers.mjs index c0fe8a0d5..d3f04b153 100644 --- a/tests/fixtures/fetch/servers.mjs +++ b/tests/fixtures/fetch/servers.mjs @@ -370,6 +370,7 @@ export async function startFetchServers() { // CONNECT tunnels (undici's ProxyAgent shape — it tunnels even plain // http) splice raw sockets. One count per proxied request either way. let proxiedRequests = 0; + let tunneledRequests = 0; const proxyAuthorizations = []; const proxyRequest = (req, res) => { // Relative-path requests are never proxy traffic — the standalone @@ -410,6 +411,7 @@ export async function startFetchServers() { }; const proxyConnect = (req, clientSocket, head) => { proxiedRequests++; + tunneledRequests++; proxyAuthorizations.push(req.headers["proxy-authorization"] ?? ""); const [host, portStr] = (req.url ?? "").split(":"); const upstream = connect(Number(portStr ?? "80"), host ?? "127.0.0.1", () => { @@ -450,6 +452,7 @@ export async function startFetchServers() { proxyUrl, secureProxyUrl, proxiedRequests: () => proxiedRequests, + tunneledRequests: () => tunneledRequests, proxyAuthorizations: () => proxyAuthorizations.slice(), close: async () => { await new Promise((resolve) => server.close(() => resolve())); diff --git a/tests/fixtures/fetch/static-proxy-error/main.mts b/tests/fixtures/fetch/static-proxy-error/main.mts new file mode 100644 index 000000000..26b57c579 --- /dev/null +++ b/tests/fixtures/fetch/static-proxy-error/main.mts @@ -0,0 +1,25 @@ +// Prints a failed fetch's error and its cause chain: name, message and +// code at each level, plus the system error fields Node sets. +function describe(value: unknown): string { + const error = value as { + name?: unknown; + message?: unknown; + code?: unknown; + errno?: unknown; + syscall?: unknown; + address?: unknown; + port?: unknown; + }; + return `${String(error.name)} | ${String(error.message)} | ${String(error.code)} | ${String(error.errno)} | ${String(error.syscall)} | ${String(error.address)} | ${String(error.port)}`; +} + +try { + const response = await fetch(process.argv[2]); + console.log(response.status, await response.text()); +} catch (error) { + let level: unknown = error; + for (let depth = 0; level !== undefined && depth < 4; depth++) { + console.log(depth, describe(level)); + level = (level as { cause?: unknown }).cause; + } +} diff --git a/tests/harness/fetch.test.ts b/tests/harness/fetch.test.ts index 4cc3bf86f..6b04acf59 100644 --- a/tests/harness/fetch.test.ts +++ b/tests/harness/fetch.test.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { globSync, mkdirSync, readFileSync } from "node:fs"; import { createServer as createHttpServer } from "node:http"; import { createServer as createHttpsServer } from "node:https"; +import { createServer as createNetServer, type AddressInfo } from "node:net"; import { join } from "node:path"; import { promisify } from "node:util"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; @@ -461,6 +462,7 @@ describe(`proxy env opt-in (NODE_USE_ENV_PROXY${sanitize ? ", sanitized" : ""})` NO_PROXY: "", }; const before = servers.proxiedRequests(); + const tunneledBefore = servers.tunneledRequests(); const authBefore = servers.proxyAuthorizations().length; const [nodeRes, nativeRes] = await Promise.all([ runBinary("node", [entry, ...argv], env), @@ -470,6 +472,8 @@ describe(`proxy env opt-in (NODE_USE_ENV_PROXY${sanitize ? ", sanitized" : ""})` expect(nativeRes.exitCode, nativeRes.stderr.toString("utf8")).toBe(nodeRes.exitCode); expectCleanStderr(nativeRes, nodeRes); expect(servers.proxiedRequests() - before).toBe(2); + // Both lanes reach the http:// target through a CONNECT tunnel. + expect(servers.tunneledRequests() - tunneledBefore).toBe(2); expect(servers.proxyAuthorizations().slice(authBefore)).toEqual([ expectedProxyAuthorization, expectedProxyAuthorization, @@ -522,6 +526,7 @@ describe(`proxy env opt-in (NODE_USE_ENV_PROXY${sanitize ? ", sanitized" : ""})` NO_PROXY: "", }; const before = servers.proxiedRequests(); + const tunneledBefore = servers.tunneledRequests(); const authBefore = servers.proxyAuthorizations().length; const [nodeRes, nativeRes] = await Promise.all([ runBinary("node", [entry, baseUrl], env), @@ -531,6 +536,7 @@ describe(`proxy env opt-in (NODE_USE_ENV_PROXY${sanitize ? ", sanitized" : ""})` expect(nativeRes.exitCode, nativeRes.stderr.toString("utf8")).toBe(nodeRes.exitCode); expectCleanStderr(nativeRes, nodeRes); expect(servers.proxiedRequests() - before).toBe(2); + expect(servers.tunneledRequests() - tunneledBefore).toBe(2); expect(servers.proxyAuthorizations().slice(authBefore)).toEqual([ expectedProxyAuthorization, expectedProxyAuthorization, @@ -593,6 +599,244 @@ describe(`proxy env opt-in (NODE_USE_ENV_PROXY${sanitize ? ", sanitized" : ""})` } }, 120_000); + /** An HTTPS target on localhost that counts the requests it serves, + * with the localhost certificate or another fixture certificate. */ + async function secureTarget(cert = "localhost"): Promise<{ + url: string; + requests: () => number; + close: () => Promise; + }> { + const certs = join(fixturesRoot, "../server/certs"); + let requests = 0; + const server = createHttpsServer( + { + key: readFileSync(join(certs, `${cert}-key.pem`)), + cert: readFileSync(join(certs, `${cert}.pem`)), + }, + (request, response) => { + requests++; + response.end(`secure ${request.url} ${request.headers.host}`); + }, + ); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + return { + url: `https://localhost:${port}`, + requests: () => requests, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; + } + + test.for(["llvm"] as const)( + "%s fetch tunnels HTTPS through an http_proxy with CONNECT", + async () => { + const target = await secureTarget(); + try { + const entry = join(fixturesRoot, "static-proxy/main.mts"); + const binary = await build(entry, "proxy-connect"); + const env = { + ...process.env, + NODE_EXTRA_CA_CERTS: join(fixturesRoot, "../server/certs/ca.pem"), + NODE_USE_ENV_PROXY: "1", + https_proxy: authenticatedProxyUrl, + HTTPS_PROXY: authenticatedProxyUrl, + no_proxy: "", + NO_PROXY: "", + }; + const before = servers.proxiedRequests(); + const authBefore = servers.proxyAuthorizations().length; + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry, target.url], env), + runBinary(binary, [target.url], env), + ]); + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(nativeRes.stdout.toString("utf8")).toContain("200 secure /text localhost:"); + expect(nativeRes.exitCode, nativeRes.stderr.toString("utf8")).toBe(nodeRes.exitCode); + expectCleanStderr(nativeRes, nodeRes); + expect(servers.proxiedRequests() - before).toBe(2); + expect(target.requests()).toBe(2); + expect(servers.proxyAuthorizations().slice(authBefore)).toEqual([ + expectedProxyAuthorization, + expectedProxyAuthorization, + ]); + } finally { + await target.close(); + } + }, + 120_000, + ); + + test.for(["llvm"] as const)( + "%s fetch verifies the target host through the CONNECT tunnel", + async () => { + // The alt certificate names alt.localhost only. + const target = await secureTarget("alt"); + try { + const entry = join(fixturesRoot, "static-network-error/main.mts"); + const binary = await build(entry, "proxy-connect-verify"); + const url = target.url; + const env = { + ...process.env, + NODE_EXTRA_CA_CERTS: join(fixturesRoot, "../server/certs/ca.pem"), + NODE_USE_ENV_PROXY: "1", + https_proxy: proxyUrl, + HTTPS_PROXY: proxyUrl, + no_proxy: "", + NO_PROXY: "", + }; + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry, url], env), + runBinary(binary, [url], env), + ]); + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(nativeRes.stdout.toString("utf8")).toBe("TypeError fetch failed true\n"); + expect(target.requests()).toBe(0); + } finally { + await target.close(); + } + }, + 120_000, + ); + + test.for(["llvm"] as const)( + "%s fetch sends proxy credentials only with a username and a password", + async () => { + const entry = join(fixturesRoot, "static-proxy/main.mts"); + const binary = await build(entry, "proxy-user-only"); + const userOnly = proxyUrl.replace("http://", "http://proxy-user@"); + const env = { + ...process.env, + NODE_USE_ENV_PROXY: "1", + http_proxy: userOnly, + HTTP_PROXY: userOnly, + no_proxy: "", + NO_PROXY: "", + }; + const authBefore = servers.proxyAuthorizations().length; + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry, baseUrl], env), + runBinary(binary, [baseUrl], env), + ]); + expect(nativeRes.stdout.equals(nodeRes.stdout)).toBe(true); + expect(nativeRes.exitCode, nativeRes.stderr.toString("utf8")).toBe(nodeRes.exitCode); + expect(servers.proxyAuthorizations().slice(authBefore)).toEqual(["", ""]); + }, + 120_000, + ); + + test.for(["llvm"] as const)( + "%s fetch failures through a proxy carry Node's cause", + async () => { + // Answers every CONNECT with one fixed status line. + const statusProxy = createNetServer((socket) => { + socket.once("data", () => { + socket.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + }); + socket.on("error", () => {}); + }); + await new Promise((resolve) => statusProxy.listen(0, "127.0.0.1", resolve)); + const statusProxyUrl = `http://127.0.0.1:${(statusProxy.address() as AddressInfo).port}`; + try { + const entry = join(fixturesRoot, "static-proxy-error/main.mts"); + const binary = await build(entry, "proxy-error-cause"); + for (const proxy of [statusProxyUrl, refusedUrl]) { + const env = { + ...process.env, + NODE_USE_ENV_PROXY: "1", + https_proxy: proxy, + HTTPS_PROXY: proxy, + no_proxy: "", + NO_PROXY: "", + }; + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry, "https://localhost/"], env), + runBinary(binary, ["https://localhost/"], env), + ]); + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(nativeRes.exitCode, nativeRes.stderr.toString("utf8")).toBe(nodeRes.exitCode); + } + } finally { + await new Promise((resolve) => statusProxy.close(() => resolve())); + } + }, + 120_000, + ); + + test.for(["llvm"] as const)( + "%s fetch tunnels HTTPS through an HTTPS proxy with CONNECT", + async () => { + const target = await secureTarget(); + try { + const entry = join(fixturesRoot, "static-proxy/main.mts"); + const binary = await build(entry, "proxy-https-https"); + const env = { + ...process.env, + NODE_EXTRA_CA_CERTS: join(fixturesRoot, "../server/certs/ca.pem"), + NODE_USE_ENV_PROXY: "1", + https_proxy: secureAuthenticatedProxyUrl, + HTTPS_PROXY: secureAuthenticatedProxyUrl, + no_proxy: "", + NO_PROXY: "", + }; + const before = servers.proxiedRequests(); + const tunneledBefore = servers.tunneledRequests(); + const authBefore = servers.proxyAuthorizations().length; + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry, target.url], env), + runBinary(binary, [target.url], env), + ]); + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(nativeRes.stdout.toString("utf8")).toContain("200 secure /text localhost:"); + expect(nativeRes.exitCode, nativeRes.stderr.toString("utf8")).toBe(nodeRes.exitCode); + expectCleanStderr(nativeRes, nodeRes); + expect(servers.proxiedRequests() - before).toBe(2); + expect(servers.tunneledRequests() - tunneledBefore).toBe(2); + expect(target.requests()).toBe(2); + expect(servers.proxyAuthorizations().slice(authBefore)).toEqual([ + expectedProxyAuthorization, + expectedProxyAuthorization, + ]); + } finally { + await target.close(); + } + }, + 120_000, + ); + + test.for(["llvm"] as const)( + "%s fetch verifies the target host through an HTTPS proxy tunnel", + async () => { + // The alt certificate names alt.localhost only. + const target = await secureTarget("alt"); + try { + const entry = join(fixturesRoot, "static-network-error/main.mts"); + const binary = await build(entry, "proxy-https-verify"); + const env = { + ...process.env, + NODE_EXTRA_CA_CERTS: join(fixturesRoot, "../server/certs/ca.pem"), + NODE_USE_ENV_PROXY: "1", + https_proxy: secureProxyUrl, + HTTPS_PROXY: secureProxyUrl, + no_proxy: "", + NO_PROXY: "", + }; + const [nodeRes, nativeRes] = await Promise.all([ + runBinary("node", [entry, target.url], env), + runBinary(binary, [target.url], env), + ]); + expect(nativeRes.stdout.toString("utf8")).toBe(nodeRes.stdout.toString("utf8")); + expect(nativeRes.stdout.toString("utf8")).toBe("TypeError fetch failed true\n"); + expect(target.requests()).toBe(0); + } finally { + await target.close(); + } + }, + 120_000, + ); + test.for(["llvm"] as const)( "%s fetch rejects an invalid opted-in proxy instead of dialing directly", async () => {