diff --git a/CMakeLists.txt b/CMakeLists.txt index b34c370..785cef6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,7 +5,7 @@ if(POLICY CMP0156) endif() project( wolfram - VERSION 0.20.1 + VERSION 0.21.0 DESCRIPTION "A C/C++ SDK for the AT Protocol" LANGUAGES C CXX) diff --git a/include/wolfram/xrpc_server.h b/include/wolfram/xrpc_server.h index 9e0fae1..eb83518 100644 --- a/include/wolfram/xrpc_server.h +++ b/include/wolfram/xrpc_server.h @@ -471,6 +471,25 @@ void wf_xrpc_server_set_request_observer(wf_xrpc_server *server, void wf_xrpc_server_set_fallback(wf_xrpc_server *server, wf_xrpc_fallback_handler handler, void *ctx); +/** + * Trust `header_name` (e.g. "CF-Connecting-IP") for the client IP used in + * rate limiting and request logging, instead of the raw TCP peer address. + * + * Only enable this when the deployment topology guarantees the header + * cannot be forged by the client -- i.e. every request reaching this + * process has already passed through a proxy that overwrites or strips any + * client-supplied copy of the header and sets it itself (a Cloudflare + * Tunnel with no direct public port exposure is one such topology; a + * plain reverse proxy that blindly forwards an inbound header is NOT). + * Passing NULL (the default) disables this and always uses the socket + * peer address. + * + * Returns WF_ERR_ALLOC on allocation failure, WF_ERR_INVALID_ARG if + * `server` is NULL, WF_OK otherwise. + */ +wf_status wf_xrpc_server_set_trusted_client_ip_header(wf_xrpc_server *server, + const char *header_name); + /** * Like wf_xrpc_server_set_auth_callback, but also records an owned middleware * context (`mw_ctx`) that the server frees via `mw_free` in diff --git a/src/server/xrpc_server.c b/src/server/xrpc_server.c index 865d0eb..d2aa3b4 100644 --- a/src/server/xrpc_server.c +++ b/src/server/xrpc_server.c @@ -964,9 +964,40 @@ static void wf_server_observe(wf_xrpc_server *server, const char *nsid, /* Extract the client's address into `out` ("unknown" if it cannot be * determined). Shared by the built-in rate limiter and wf_xrpc_request's - * client_ip, which previously duplicated this dance independently. */ -static void wf_server_client_ip(struct MHD_Connection *conn, char *out, + * client_ip, which previously duplicated this dance independently. + * + * When `server->trusted_client_ip_header` is set, that header's value is + * used in preference to the raw socket peer -- see + * wf_xrpc_server_set_trusted_client_ip_header for the safety requirement + * this depends on. Only the text up to the first comma is used (some + * proxies reuse comma-separated-list headers even for single-hop values), + * trimmed of surrounding whitespace. Falls back to the socket peer if the + * header is absent, empty, or blank. */ +static void wf_server_client_ip(wf_xrpc_server *server, + struct MHD_Connection *conn, char *out, size_t out_len) { + if (server && server->trusted_client_ip_header) { + const char *hv = MHD_lookup_connection_value( + conn, MHD_HEADER_KIND, server->trusted_client_ip_header); + if (hv && hv[0]) { + const char *end = strchr(hv, ','); + size_t len = end ? (size_t)(end - hv) : strlen(hv); + while (len > 0 && (hv[0] == ' ' || hv[0] == '\t')) { + hv++; + len--; + } + while (len > 0 && (hv[len - 1] == ' ' || hv[len - 1] == '\t')) { + len--; + } + if (len > 0) { + size_t n = len < out_len - 1 ? len : out_len - 1; + memcpy(out, hv, n); + out[n] = '\0'; + return; + } + } + } + const union MHD_ConnectionInfo *ci = MHD_get_connection_info(conn, MHD_CONNECTION_INFO_CLIENT_ADDRESS); if (ci && ci->client_addr) { @@ -1118,7 +1149,7 @@ process: #else char client_ip_str[INET6_ADDRSTRLEN]; #endif - wf_server_client_ip(conn, client_ip_str, sizeof(client_ip_str)); + wf_server_client_ip(server, conn, client_ip_str, sizeof(client_ip_str)); /* Look up route. Distinguish a wrong HTTP method (the NSID is registered * but for the opposite kind) from an entirely unregistered NSID, so we emit @@ -1661,6 +1692,7 @@ void wf_xrpc_server_free(wf_xrpc_server *server) { pthread_mutex_destroy(&server->routes_mutex); pthread_mutex_destroy(&server->rate_limit_mutex); free(server->cors_origin); + free(server->trusted_client_ip_header); if (server->rate_limiter_owned) { wf_rate_limiter_free(server->rate_limiter_owned); } @@ -1936,6 +1968,21 @@ void wf_xrpc_server_set_fallback(wf_xrpc_server *server, pthread_mutex_unlock(&server->routes_mutex); } +wf_status wf_xrpc_server_set_trusted_client_ip_header(wf_xrpc_server *server, + const char *header_name) { + if (!server) return WF_ERR_INVALID_ARG; + char *dup = NULL; + if (header_name && header_name[0]) { + dup = strdup(header_name); + if (!dup) return WF_ERR_ALLOC; + } + pthread_mutex_lock(&server->routes_mutex); + free(server->trusted_client_ip_header); + server->trusted_client_ip_header = dup; + pthread_mutex_unlock(&server->routes_mutex); + return WF_OK; +} + void wf_xrpc_server_set_auth_callback_owned(wf_xrpc_server *server, wf_xrpc_auth_cb cb, void *ctx, void *mw_ctx, diff --git a/src/server/xrpc_server_internal.h b/src/server/xrpc_server_internal.h index 75392ac..0c354d4 100644 --- a/src/server/xrpc_server_internal.h +++ b/src/server/xrpc_server_internal.h @@ -96,6 +96,10 @@ struct wf_xrpc_server { struct wf_ws_pending *ws_pending; bool cors_enabled; /* emit CORS headers when true */ char *cors_origin; /* owned Allow-Origin value */ + /* Header to trust for the real client IP instead of the immediate TCP + * peer (see wf_xrpc_server_set_trusted_client_ip_header); NULL (the + * default) always uses the raw socket peer address. */ + char *trusted_client_ip_header; }; /** A live WebSocket connection, created after a successful upgrade. */ diff --git a/test/test_xrpc_server.c b/test/test_xrpc_server.c index b0dcff6..001d9e5 100644 --- a/test/test_xrpc_server.c +++ b/test/test_xrpc_server.c @@ -984,6 +984,127 @@ static int test_request_client_ip(void) { return 1; } +/* Raw GET so a caller-supplied header (e.g. a spoofed/trusted client-IP + * header) can be attached — wf_xrpc_client has no way to set arbitrary + * request headers. */ +static int raw_get_with_header(uint16_t port, const char *nsid, + const char *header_line) { + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return -1; + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + addr.sin_addr.s_addr = inet_addr("127.0.0.1"); + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + close(fd); + return -1; + } + char req[256]; + int n = snprintf(req, sizeof(req), + "GET /xrpc/%s HTTP/1.1\r\n" + "Host: 127.0.0.1:%u\r\n" + "%s" + "Connection: close\r\n" + "\r\n", + nsid, (unsigned)port, header_line ? header_line : ""); + if (write(fd, req, (size_t)n) != n) { + close(fd); + return -1; + } + char buf[512]; + size_t used = 0; + for (;;) { + struct pollfd pfd = {fd, POLLIN, 0}; + if (poll(&pfd, 1, 3000) <= 0) break; + if (used + 1 >= sizeof(buf)) break; + ssize_t r = read(fd, buf + used, sizeof(buf) - 1 - used); + if (r <= 0) break; + used += (size_t)r; + } + close(fd); + return 0; +} + +/* + * wf_xrpc_server_set_trusted_client_ip_header: when unset (the default), + * an attacker-supplied "CF-Connecting-IP" header must be ignored and the + * real socket peer used. Once a deployment opts in, that header must be + * trusted instead — this is only safe behind a proxy topology that + * guarantees the header can't be forged end-to-end, which is a deployment + * property this test can't verify; it only checks the mechanism. + */ +static int test_trusted_client_ip_header(void) { + wf_xrpc_server *server; + int failures = 0; + + g_seen_client_ip = NULL; + server = wf_xrpc_server_start("127.0.0.1", 0, 1); + if (!server) { + fprintf(stderr, "FAIL: trusted ip header start\n"); + return 1; + } + if (wf_xrpc_server_register_query(server, "io.example.whoami", + test_client_ip_handler, NULL) != WF_OK) { + fprintf(stderr, "FAIL: trusted ip header register\n"); + wf_xrpc_server_free(server); + return 1; + } + uint16_t port = wf_xrpc_server_port(server); + + /* Disabled by default: a spoofed header must not override the peer. */ + raw_get_with_header(port, "io.example.whoami", + "CF-Connecting-IP: 6.6.6.6\r\n"); + if (!g_seen_client_ip || strcmp(g_seen_client_ip, "127.0.0.1") != 0) { + fprintf(stderr, "FAIL: untrusted header overrode peer, got %s\n", + g_seen_client_ip ? g_seen_client_ip : "(null)"); + failures++; + } + + /* Enabled: the header value must now be used. */ + if (wf_xrpc_server_set_trusted_client_ip_header( + server, "CF-Connecting-IP") != WF_OK) { + fprintf(stderr, "FAIL: set_trusted_client_ip_header\n"); + wf_xrpc_server_free(server); + return 1; + } + g_seen_client_ip = NULL; + raw_get_with_header(port, "io.example.whoami", + "CF-Connecting-IP: 203.0.113.7\r\n"); + if (!g_seen_client_ip || strcmp(g_seen_client_ip, "203.0.113.7") != 0) { + fprintf(stderr, "FAIL: expected trusted header ip, got %s\n", + g_seen_client_ip ? g_seen_client_ip : "(null)"); + failures++; + } + + /* Enabled but absent from this request: falls back to the peer. */ + g_seen_client_ip = NULL; + raw_get_with_header(port, "io.example.whoami", NULL); + if (!g_seen_client_ip || strcmp(g_seen_client_ip, "127.0.0.1") != 0) { + fprintf(stderr, "FAIL: expected peer fallback, got %s\n", + g_seen_client_ip ? g_seen_client_ip : "(null)"); + failures++; + } + + /* Disabling again (NULL) restores the peer even with the header present. */ + wf_xrpc_server_set_trusted_client_ip_header(server, NULL); + g_seen_client_ip = NULL; + raw_get_with_header(port, "io.example.whoami", + "CF-Connecting-IP: 6.6.6.6\r\n"); + if (!g_seen_client_ip || strcmp(g_seen_client_ip, "127.0.0.1") != 0) { + fprintf(stderr, "FAIL: expected peer after disable, got %s\n", + g_seen_client_ip ? g_seen_client_ip : "(null)"); + failures++; + } + + wf_xrpc_server_free(server); + + if (failures == 0) { + printf("PASS: trusted client ip header\n"); + return 0; + } + return 1; +} + /* wf_xrpc_client's wf_response only surfaces a few named headers * (dpop_nonce, set_cookie, location) — none of the ones under test here — * so this drives a raw socket instead of the client library. */ @@ -1145,6 +1266,7 @@ int main(void) { failures += test_server_rate_limit(); failures += test_server_route_rate_limit(); failures += test_request_client_ip(); + failures += test_trusted_client_ip_header(); failures += test_rate_limit_headers(); return failures;