commit 9444740f327b0e5b8ce04134a5cef017648f2add
parent 0af605f6a0411007d6fbe3080c972d1f1a2e584a
Author: David Goulet <dgoulet@torproject.org>
Date: Wed, 5 Nov 2025 15:50:26 +0000
Merge branch 'prop365_interop' into 'main'
Implement the C-tor parts of prop365
Closes #41156
See merge request tpo/core/tor!949
Diffstat:
5 files changed, 229 insertions(+), 33 deletions(-)
diff --git a/changes/prop365_interop b/changes/prop365_interop
@@ -0,0 +1,8 @@
+ o Minor features (HTTPTunnelPort):
+ - Implement new HTTPTunnelPort features for interoperability with
+ Arti's HTTP CONNECT proxy. This work adds new headers to
+ requests to and replies from the HttpConnectPort,
+ support for OPTIONS requests, tightens the expected syntax
+ for Proxy-Authorization, and increases defense-in-depth
+ against some kinds of cross-site HTTP attacks.
+ Closes ticket 41156. Implements proposal 365.
diff --git a/src/core/or/connection_edge.c b/src/core/or/connection_edge.c
@@ -2984,9 +2984,28 @@ connection_ap_process_natd(entry_connection_t *conn)
return connection_ap_rewrite_and_attach_if_allowed(conn, NULL, NULL);
}
+#define TOR_CAPABILITIES_HEADER \
+ "Tor-Capabilities: \r\n"
+
+#define HTTP_CONNECT_FIXED_HEADERS \
+ TOR_CAPABILITIES_HEADER \
+ "Via: tor/1.0 tor-network (tor "VERSION")\r\n"
+
+#define HTTP_OTHER_FIXED_HEADERS \
+ TOR_CAPABILITIES_HEADER \
+ "Server: tor/1.0 (tor "VERSION")\r\n"
+
+static const char HTTP_OPTIONS_REPLY[] =
+ "HTTP/1.0 200 OK\r\n"
+ "Allow: OPTIONS, CONNECT\r\n"
+ HTTP_OTHER_FIXED_HEADERS
+ "\r\n";
+
static const char HTTP_CONNECT_IS_NOT_AN_HTTP_PROXY_MSG[] =
"HTTP/1.0 405 Method Not Allowed\r\n"
- "Content-Type: text/html; charset=iso-8859-1\r\n\r\n"
+ "Content-Type: text/html; charset=iso-8859-1\r\n"
+ HTTP_OTHER_FIXED_HEADERS
+ "\r\n"
"<html>\n"
"<head>\n"
"<title>This is an HTTP CONNECT tunnel, not a full HTTP Proxy</title>\n"
@@ -3009,6 +3028,64 @@ static const char HTTP_CONNECT_IS_NOT_AN_HTTP_PROXY_MSG[] =
"</body>\n"
"</html>\n";
+/** Return true iff `host` is a valid host header value indicating localhost.
+ */
+static bool
+host_header_is_localhost(const char *host_value)
+{
+ char *host = NULL;
+ uint16_t port = 0;
+ tor_addr_t addr;
+ bool result;
+
+ // Note that this does not _require_ that a port was set,
+ // which is what we want.
+ if (tor_addr_port_split(LOG_DEBUG, host_value, &host, &port) < 0) {
+ return false;
+ }
+ tor_assert(host);
+
+ if (tor_addr_parse(&addr, host) == 0) {
+ result = tor_addr_is_loopback(&addr);
+ } else {
+ result = ! strcasecmp(host, "localhost");
+ }
+
+ tor_free(host);
+ return result;
+}
+
+/** Return true if the Proxy-Authorization header present in 'auth'
+ * isn't using the "modern" format introduced by proposal 365,
+ * with "basic" auth and username "tor". */
+STATIC bool
+using_old_proxy_auth(const char *auth)
+{
+ auth = eat_whitespace(auth);
+ if (strcasecmpstart(auth, "Basic ")) {
+ // Not Basic.
+ return true;
+ }
+ auth += strlen("Basic ");
+ auth = eat_whitespace(auth);
+
+ ssize_t clen = base64_decode_maxsize(strlen(auth)) + 1;
+ char *credential = tor_malloc_zero(clen);
+ ssize_t n = base64_decode(credential, clen, auth, strlen(auth));
+ if (n < 0 || BUG(n >= clen)) {
+ // not base64, or somehow too long.
+ tor_free(credential);
+ return true;
+ }
+ // nul-terminate.
+ credential[n] = 0;
+
+ bool username_is_modern = ! strcmpstart(credential, "tor:");
+ tor_free(credential);
+
+ return ! username_is_modern;
+}
+
/** Called on an HTTP CONNECT entry connection when some bytes have arrived,
* but we have not yet received a full HTTP CONNECT request. Try to parse an
* HTTP CONNECT request from the connection's inbuf. On success, set up the
@@ -3025,16 +3102,26 @@ connection_ap_process_http_connect(entry_connection_t *conn)
char *command = NULL, *addrport = NULL;
char *addr = NULL;
size_t bodylen = 0;
+ const char *fixed_reply_headers = HTTP_OTHER_FIXED_HEADERS;
const char *errmsg = NULL;
+ bool close_without_message = false;
int rv = 0;
+ bool host_is_localhost = false;
+
+ // If true, we already have a full reply, so we shouldn't add
+ // fixed headers and CRLF.
+ bool errmsg_is_complete = false;
+ // If true, we're sending a fixed reply as an errmsg,
+ // but technically this isn't an error so we shouldn't log.
+ bool skip_error_log = false;
const int http_status =
fetch_from_buf_http(ENTRY_TO_CONN(conn)->inbuf, &headers, 8192,
&body, &bodylen, 1024, 0);
if (http_status < 0) {
- /* Bad http status */
- errmsg = "HTTP/1.0 400 Bad Request\r\n\r\n";
+ /* Unparseable http message. Don't send a reply. */
+ close_without_message = true;
goto err;
} else if (http_status == 0) {
/* no HTTP request yet. */
@@ -3043,25 +3130,68 @@ connection_ap_process_http_connect(entry_connection_t *conn)
const int cmd_status = parse_http_command(headers, &command, &addrport);
if (cmd_status < 0) {
- errmsg = "HTTP/1.0 400 Bad Request\r\n\r\n";
+ /* Unparseable command. Don't reply. */
+ close_without_message = true;
goto err;
}
tor_assert(command);
tor_assert(addrport);
+ {
+ // Find out whether the host is localhost. If it isn't,
+ // then either this is a connect request (which is okay)
+ // or a webpage is using DNS rebinding to try to bypass
+ // browser security (which isn't).
+ char *host = http_get_header(headers, "Host: ");
+ if (host) {
+ host_is_localhost = host_header_is_localhost(host);
+ }
+ tor_free(host);
+ }
+ if (!strcasecmp(command, "options") && host_is_localhost) {
+ errmsg = HTTP_OPTIONS_REPLY;
+ errmsg_is_complete = true;
+
+ // TODO: We could in theory make sure that the target
+ // is a host or is *.
+ // TODO: We could in theory make sure that the body is empty.
+ // (And we would have to, if we ever support HTTP/1.1.)
+
+ // This is not actually an error, but the error handling
+ // does the right operations here (send the reply,
+ // mark the connection).
+ skip_error_log = true;
+
+ goto err;
+ }
if (strcasecmp(command, "connect")) {
- errmsg = HTTP_CONNECT_IS_NOT_AN_HTTP_PROXY_MSG;
+ if (host_is_localhost) {
+ errmsg = HTTP_CONNECT_IS_NOT_AN_HTTP_PROXY_MSG;
+ errmsg_is_complete = true;
+ } else {
+ close_without_message = true;
+ }
goto err;
}
+ fixed_reply_headers = HTTP_CONNECT_FIXED_HEADERS;
+
tor_assert(conn->socks_request);
socks_request_t *socks = conn->socks_request;
uint16_t port;
if (tor_addr_port_split(LOG_WARN, addrport, &addr, &port) < 0) {
- errmsg = "HTTP/1.0 400 Bad Request\r\n\r\n";
+ errmsg = "HTTP/1.0 400 Bad Request\r\n";
goto err;
}
if (strlen(addr) >= MAX_SOCKS_ADDR_LEN) {
- errmsg = "HTTP/1.0 414 Request-URI Too Long\r\n\r\n";
+ errmsg = "HTTP/1.0 414 Request-URI Too Long\r\n";
+ goto err;
+ }
+
+ /* Reject the request if it's trying to interact with Arti RPC. */
+ char *rpc_hdr = http_get_header(headers, "Tor-RPC-Target: ");
+ if (rpc_hdr) {
+ tor_free(rpc_hdr);
+ errmsg = "HTTP/1.0 501 Not implemented (No RPC Support)\r\n";
goto err;
}
@@ -3070,13 +3200,30 @@ connection_ap_process_http_connect(entry_connection_t *conn)
{
char *authorization = http_get_header(headers, "Proxy-Authorization: ");
if (authorization) {
+ if (using_old_proxy_auth(authorization)) {
+ log_warn(LD_GENERAL, "Proxy-Authorization header in legacy format. "
+ "With modern Tor, use Basic auth with username=tor.");
+ }
socks->username = authorization; // steal reference
socks->usernamelen = strlen(authorization);
}
- char *isolation = http_get_header(headers, "X-Tor-Stream-Isolation: ");
- if (isolation) {
- socks->password = isolation; // steal reference
- socks->passwordlen = strlen(isolation);
+ char *isolation = http_get_header(headers, "Tor-Stream-Isolation: ");
+ char *x_isolation = http_get_header(headers, "X-Tor-Stream-Isolation: ");
+ if (isolation || x_isolation) {
+ // We need to cram both of these headers into a single
+ // password field. Using a delimiter like this is a bit ugly,
+ // but the only ones who can confuse it are the applications,
+ // whom we are trusting get their own isolation right.
+ const char DELIM[] = "\x01\xff\x01\xff";
+ tor_asprintf(&socks->password,
+ "%s%s%s",
+ isolation?isolation:"",
+ DELIM,
+ x_isolation?x_isolation:"");
+ tor_free(isolation);
+ tor_free(x_isolation);
+
+ socks->passwordlen = strlen(socks->password);
}
}
@@ -3094,10 +3241,21 @@ connection_ap_process_http_connect(entry_connection_t *conn)
goto done;
err:
- if (BUG(errmsg == NULL))
- errmsg = "HTTP/1.0 400 Bad Request\r\n\r\n";
- log_info(LD_EDGE, "HTTP tunnel error: saying %s", escaped(errmsg));
- connection_buf_add(errmsg, strlen(errmsg), ENTRY_TO_CONN(conn));
+ if (BUG(errmsg == NULL) && ! close_without_message)
+ errmsg = "HTTP/1.0 400 Bad Request\r\n";
+ if (errmsg) {
+ if (!skip_error_log)
+ log_info(LD_EDGE, "HTTP tunnel error: saying %s", escaped(errmsg));
+ connection_buf_add(errmsg, strlen(errmsg), ENTRY_TO_CONN(conn));
+ if (!errmsg_is_complete) {
+ connection_buf_add(fixed_reply_headers, strlen(fixed_reply_headers),
+ ENTRY_TO_CONN(conn));
+ connection_buf_add("\r\n", 2, ENTRY_TO_CONN(conn));
+ }
+ } else {
+ if (!skip_error_log)
+ log_info(LD_EDGE, "HTTP tunnel error: closing silently");
+ }
/* Mark it as "has_finished" so that we don't try to send an extra socks
* reply. */
conn->socks_request->has_finished = 1;
@@ -3776,9 +3934,13 @@ connection_ap_handshake_socks_reply(entry_connection_t *conn, char *reply,
CONN_TYPE_AP_HTTP_CONNECT_LISTENER) {
const char *response = end_reason_to_http_connect_response_line(endreason);
if (!response) {
- response = "HTTP/1.0 400 Bad Request\r\n\r\n";
+ response = "HTTP/1.0 400 Bad Request\r\n";
}
connection_buf_add(response, strlen(response), ENTRY_TO_CONN(conn));
+ connection_buf_add(HTTP_CONNECT_FIXED_HEADERS,
+ strlen(HTTP_CONNECT_FIXED_HEADERS),
+ ENTRY_TO_CONN(conn));
+ connection_buf_add("\r\n", 2, ENTRY_TO_CONN(conn));
} else if (conn->socks_request->socks_version == 4) {
memset(buf,0,SOCKS4_NETWORK_LEN);
buf[1] = (status==SOCKS5_SUCCEEDED ? SOCKS4_GRANTED : SOCKS4_REJECT);
diff --git a/src/core/or/connection_edge.h b/src/core/or/connection_edge.h
@@ -310,6 +310,7 @@ STATIC void connection_half_edge_add(const edge_connection_t *conn,
STATIC struct half_edge_t *connection_half_edge_find_stream_id(
const smartlist_t *half_conns,
streamid_t stream_id);
+STATIC bool using_old_proxy_auth(const char *auth);
#endif /* defined(CONNECTION_EDGE_PRIVATE) */
#endif /* !defined(TOR_CONNECTION_EDGE_H) */
diff --git a/src/core/or/reasons.c b/src/core/or/reasons.c
@@ -464,38 +464,38 @@ end_reason_to_http_connect_response_line(int endreason)
/* XXXX these are probably all wrong. Should they all be 502? */
switch (endreason) {
case 0:
- return "HTTP/1.0 200 OK\r\n\r\n";
+ return "HTTP/1.0 200 OK\r\n";
case END_STREAM_REASON_MISC:
- return "HTTP/1.0 500 Internal Server Error\r\n\r\n";
+ return "HTTP/1.0 500 Internal Server Error\r\n";
case END_STREAM_REASON_RESOLVEFAILED:
- return "HTTP/1.0 404 Not Found (resolve failed)\r\n\r\n";
+ return "HTTP/1.0 503 Service Unavailable (resolve failed)\r\n";
case END_STREAM_REASON_NOROUTE:
- return "HTTP/1.0 404 Not Found (no route)\r\n\r\n";
+ return "HTTP/1.0 503 Service Unavailable (no route)\r\n";
case END_STREAM_REASON_CONNECTREFUSED:
- return "HTTP/1.0 403 Forbidden (connection refused)\r\n\r\n";
+ return "HTTP/1.0 403 Forbidden (connection refused)\r\n";
case END_STREAM_REASON_EXITPOLICY:
- return "HTTP/1.0 403 Forbidden (exit policy)\r\n\r\n";
+ return "HTTP/1.0 403 Forbidden (exit policy)\r\n";
case END_STREAM_REASON_DESTROY:
- return "HTTP/1.0 502 Bad Gateway (destroy cell received)\r\n\r\n";
+ return "HTTP/1.0 502 Bad Gateway (destroy cell received)\r\n";
case END_STREAM_REASON_DONE:
- return "HTTP/1.0 502 Bad Gateway (unexpected close)\r\n\r\n";
+ return "HTTP/1.0 502 Bad Gateway (unexpected close)\r\n";
case END_STREAM_REASON_TIMEOUT:
- return "HTTP/1.0 504 Gateway Timeout\r\n\r\n";
+ return "HTTP/1.0 504 Gateway Timeout\r\n";
case END_STREAM_REASON_HIBERNATING:
- return "HTTP/1.0 502 Bad Gateway (hibernating server)\r\n\r\n";
+ return "HTTP/1.0 502 Bad Gateway (hibernating server)\r\n";
case END_STREAM_REASON_INTERNAL:
- return "HTTP/1.0 502 Bad Gateway (internal error)\r\n\r\n";
+ return "HTTP/1.0 502 Bad Gateway (internal error)\r\n";
case END_STREAM_REASON_RESOURCELIMIT:
- return "HTTP/1.0 502 Bad Gateway (resource limit)\r\n\r\n";
+ return "HTTP/1.0 502 Bad Gateway (resource limit)\r\n";
case END_STREAM_REASON_CONNRESET:
- return "HTTP/1.0 403 Forbidden (connection reset)\r\n\r\n";
+ return "HTTP/1.0 403 Forbidden (connection reset)\r\n";
case END_STREAM_REASON_TORPROTOCOL:
- return "HTTP/1.0 502 Bad Gateway (tor protocol violation)\r\n\r\n";
+ return "HTTP/1.0 502 Bad Gateway (tor protocol violation)\r\n";
case END_STREAM_REASON_ENTRYPOLICY:
- return "HTTP/1.0 403 Forbidden (entry policy violation)\r\n\r\n";
+ return "HTTP/1.0 403 Forbidden (entry policy violation)\r\n";
case END_STREAM_REASON_NOTDIRECTORY: FALLTHROUGH;
default:
tor_assert_nonfatal_unreached();
- return "HTTP/1.0 500 Internal Server Error (weird end reason)\r\n\r\n";
+ return "HTTP/1.0 500 Internal Server Error (weird end reason)\r\n";
}
}
diff --git a/src/test/test_proto_http.c b/src/test/test_proto_http.c
@@ -6,11 +6,14 @@
* \brief Tests for our HTTP protocol parser code
*/
+#define CONNECTION_EDGE_PRIVATE
+
#include "core/or/or.h"
#include "test/test.h"
#include "lib/buf/buffers.h"
#include "core/proto/proto_http.h"
#include "test/log_test_helpers.h"
+#include "core/or/connection_edge.h"
#define S(str) str, sizeof(str)-1
@@ -203,11 +206,33 @@ test_proto_http_invalid(void *arg)
teardown_capture_of_logs();
}
+static void
+test_proto_http_proxy_auth(void *arg)
+{
+ (void)arg;
+
+ tt_assert(using_old_proxy_auth(""));
+ tt_assert(using_old_proxy_auth("Foo Bar"));
+ tt_assert(using_old_proxy_auth("Basicish Bar"));
+ tt_assert(using_old_proxy_auth("Basic"));
+ tt_assert(using_old_proxy_auth("Basic x"));
+ // encodes foo:bar
+ tt_assert(using_old_proxy_auth("Basic Zm9vOmJhcg=="));
+ // encodes torx:bar
+ tt_assert(using_old_proxy_auth("Basic dG9yeDpiYXI="));
+
+ // encodes tor:random
+ tt_assert(! using_old_proxy_auth("Basic dG9yOnJhbmRvbQ=="));
+
+ done:
+ ;
+}
+
struct testcase_t proto_http_tests[] = {
{ "peek", test_proto_http_peek, 0, NULL, NULL },
{ "valid", test_proto_http_valid, 0, NULL, NULL },
{ "invalid", test_proto_http_invalid, 0, NULL, NULL },
+ { "proxyauth", test_proto_http_proxy_auth, 0, NULL, NULL },
END_OF_TESTCASES
};
-