From 8905d1ca30f515ae2dadd76b1b76f5abb0e7105f Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 14 Sep 2026 00:35:30 +0000 Subject: [PATCH 1/2] fix(discord): grow gateway RX for trailing NUL to avoid heap OOB discord_rx_append grew only when cap < rx_len+len, then wrote a NUL at rx_len+len. Two 64KiB LWS fragments filled a doubled buffer exactly, so the terminator was one byte past the heap block (typical READY payloads). Co-authored-by: Adrianno E. S. --- CHANGELOG.md | 1 + src/channels/discord.c | 29 +++++--------- src/channels/discord_helpers.c | 36 ++++++++++++++++++ src/channels/discord_helpers.h | 12 +++++- tests/test_discord_helpers.c | 69 ++++++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a4fb8..55b58f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ## [Unreleased] ### Fixed +- Discord Gateway RX grows for the trailing NUL so two 64 KiB libwebsockets fragments cannot write one byte past the heap block (typical READY payloads). - WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. Dest buffers are `WS_TEXT_BUF_SIZE` so a max-length payload keeps its NUL; a too-large frame is logged instead of skipped with `<`. - Memory injection is skipped when the system prompt already fills its 64 KiB buffer, instead of writing the full `Relevant memories` prefix past the allocation after clamping recall to 0. - Session JSON that would exceed the 128 KiB cap is refused instead of truncated, so the next parse cannot wipe history. An oversized stored blob is left in place (distinct `SESSION_LOAD_TOO_LARGE`) rather than replaced by a later small turn. diff --git a/src/channels/discord.c b/src/channels/discord.c index 3dabbd4..0b52b97 100644 --- a/src/channels/discord.c +++ b/src/channels/discord.c @@ -143,6 +143,7 @@ struct discord_ctx { char *rx_buf; size_t rx_len; size_t rx_cap; + int rx_skip; int heartbeat_interval_ms; uint64_t last_seq; char *session_id; @@ -176,30 +177,14 @@ static uint64_t discord_now_ms(void) static void discord_rx_reset(struct discord_ctx *dc) { dc->rx_len = 0; + dc->rx_skip = 0; } /** Appends one RX fragment; returns 0 ok, -1 overflow. */ static int discord_rx_append(struct discord_ctx *dc, const void *in, size_t len) { - if (len > RX_MAX || dc->rx_len > RX_MAX - len) - return -1; - if (dc->rx_cap < dc->rx_len + len) { - size_t need = dc->rx_len + len + 1; - size_t ncap = dc->rx_cap ? dc->rx_cap * 2 : 4096; - while (ncap < need && ncap < RX_MAX) - ncap *= 2; - if (need > RX_MAX) - return -1; - char *p = realloc(dc->rx_buf, ncap); - if (!p) - return -1; - dc->rx_buf = p; - dc->rx_cap = ncap; - } - memcpy(dc->rx_buf + dc->rx_len, in, len); - dc->rx_len += len; - dc->rx_buf[dc->rx_len] = '\0'; - return 0; + return discord_helpers_rx_append(&dc->rx_buf, &dc->rx_len, &dc->rx_cap, in, len, + RX_MAX); } static int discord_send_json(struct lws *wsi, const char *json) @@ -538,9 +523,15 @@ static int callback_discord(struct lws *wsi, enum lws_callback_reasons reason, } break; case LWS_CALLBACK_CLIENT_RECEIVE: + if (dc->rx_skip) { + if (lws_is_final_fragment(wsi)) + dc->rx_skip = 0; + break; + } if (discord_rx_append(dc, in, len) != 0) { fprintf(stderr, "shellclaw: discord: gateway payload too large\n"); discord_rx_reset(dc); + dc->rx_skip = !lws_is_final_fragment(wsi); break; } if (lws_is_final_fragment(wsi)) { diff --git a/src/channels/discord_helpers.c b/src/channels/discord_helpers.c index 1e6e00b..9ee1389 100644 --- a/src/channels/discord_helpers.c +++ b/src/channels/discord_helpers.c @@ -6,6 +6,7 @@ #include "channels/discord_helpers.h" #include "channels/channel.h" #include +#include #include const char *discord_lifecycle_str(discord_lifecycle_t lc) @@ -139,3 +140,38 @@ int discord_helpers_send_backoff_ms(int attempt, double retry_after_sec, int jit sleep_ms = cap_ms; return sleep_ms; } + +int discord_helpers_rx_append(char **buf, size_t *len, size_t *cap, const void *in, + size_t chunk_len, size_t max_cap) +{ + size_t need; + + if (!buf || !len || !cap || (!in && chunk_len != 0) || max_cap < 1) + return -1; + if (chunk_len > max_cap || *len > max_cap - chunk_len) + return -1; + need = *len + chunk_len + 1; + if (need > max_cap) + return -1; + if (*cap < need) { + size_t ncap = *cap ? *cap * 2 : 4096; + char *p; + + while (ncap < need) + ncap *= 2; + if (ncap > max_cap) + ncap = max_cap; + if (ncap < need) + return -1; + p = realloc(*buf, ncap); + if (!p) + return -1; + *buf = p; + *cap = ncap; + } + if (chunk_len > 0) + memcpy(*buf + *len, in, chunk_len); + *len += chunk_len; + (*buf)[*len] = '\0'; + return 0; +} diff --git a/src/channels/discord_helpers.h b/src/channels/discord_helpers.h index 7632741..d89a58a 100644 --- a/src/channels/discord_helpers.h +++ b/src/channels/discord_helpers.h @@ -1,7 +1,7 @@ /** * @file discord_helpers.h * @brief Pure helpers shared by Discord channel and unit tests (allowlist, session id, mentions, - * REST 429 backoff calculation). + * REST 429 backoff calculation, Gateway RX append). */ #ifndef SHELLCLAW_DISCORD_HELPERS_H @@ -53,6 +53,16 @@ int discord_helpers_route_message_create(const cJSON *payload, const char *const int allowed_count, const char *bot_user_id, char *session_out, size_t session_outsz); +/** + * Append one Gateway RX fragment, growing so payload plus a trailing NUL always fit. + * + * @return 0 on success, -1 if the total would exceed @p max_cap or allocation failed. + * + * Example: two 64 KiB LWS fragments must grow past 131072 so the NUL fits. + */ +int discord_helpers_rx_append(char **buf, size_t *len, size_t *cap, const void *in, + size_t chunk_len, size_t max_cap); + #ifdef __cplusplus } #endif diff --git a/tests/test_discord_helpers.c b/tests/test_discord_helpers.c index c226c13..658bd73 100644 --- a/tests/test_discord_helpers.c +++ b/tests/test_discord_helpers.c @@ -7,6 +7,7 @@ #include "channels/channel.h" #include "cJSON.h" #include +#include #include #define ASSERT(c) do { if (!(c)) { fprintf(stderr, "FAIL: %s:%d %s\n", __FILE__, __LINE__, #c); return 1; } } while (0) @@ -140,6 +141,72 @@ static int test_route_message_create_rejects_edge_cases(void) return 0; } +/** Exact payload fill of a power-of-two cap must still reserve one byte for NUL. */ +static int test_rx_append_grows_for_nul(void) +{ + char *buf; + size_t len; + size_t cap; + char a[4095]; + char one; + char *big; + char *big2; + + buf = NULL; + len = 0; + cap = 0; + one = 'Z'; + memset(a, 'A', sizeof(a)); + ASSERT(discord_helpers_rx_append(&buf, &len, &cap, a, sizeof(a), 512 * 1024) == 0); + ASSERT(len == sizeof(a)); + ASSERT(cap >= len + 1); + ASSERT(buf[len] == '\0'); + ASSERT(discord_helpers_rx_append(&buf, &len, &cap, &one, 1, 512 * 1024) == 0); + ASSERT(len == 4096); + ASSERT(cap >= 4097); + ASSERT(buf[4096] == '\0'); + ASSERT(buf[4095] == 'Z'); + + free(buf); + buf = NULL; + len = 0; + cap = 0; + big = malloc(65536); + big2 = malloc(65536); + ASSERT(big && big2); + memset(big, 'B', 65536); + memset(big2, 'C', 65536); + ASSERT(discord_helpers_rx_append(&buf, &len, &cap, big, 65536, 512 * 1024) == 0); + ASSERT(len == 65536); + ASSERT(cap == 131072); + ASSERT(discord_helpers_rx_append(&buf, &len, &cap, big2, 65536, 512 * 1024) == 0); + ASSERT(len == 131072); + ASSERT(cap >= 131073); + ASSERT(buf[131072] == '\0'); + ASSERT(buf[0] == 'B' && buf[65535] == 'B'); + ASSERT(buf[65536] == 'C' && buf[131071] == 'C'); + free(big); + free(big2); + free(buf); + return 0; +} + +static int test_rx_append_rejects_over_max(void) +{ + char *buf; + size_t len; + size_t cap; + char chunk[16]; + + buf = NULL; + len = 0; + cap = 0; + memset(chunk, 'x', sizeof(chunk)); + ASSERT(discord_helpers_rx_append(&buf, &len, &cap, chunk, sizeof(chunk), 8) != 0); + ASSERT(buf == NULL); + return 0; +} + int main(void) { RUN(test_allow_entry_equals()); @@ -150,6 +217,8 @@ int main(void) RUN(test_lifecycle_str()); RUN(test_route_message_create_fixture()); RUN(test_route_message_create_rejects_edge_cases()); + RUN(test_rx_append_grows_for_nul()); + RUN(test_rx_append_rejects_over_max()); printf("test_discord_helpers: all tests passed\n"); return 0; } From d97452a7497398774179285b0b61941c26beb0e6 Mon Sep 17 00:00:00 2001 From: Adrianno Esnarriaga Sereno Date: Mon, 14 Sep 2026 00:36:06 +0000 Subject: [PATCH 2/2] fix(gateway): raise WebChat WS rx_buffer_size so inbound JSON is not split LWS delivers at most rx_buffer_size bytes per RECEIVE. The ws protocol used 256 with no fragment reassembly, so dashboard messages over ~220 characters were parsed as incomplete JSON and dropped. Size the buffer to fit a full WebChat frame (WS_TEXT_MAX plus JSON envelope). Distinct from outbound #62. Co-authored-by: Adrianno E. S. --- CHANGELOG.md | 1 + src/gateway/http.c | 5 ++++- src/gateway/http_lws.h | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b58f1..57ce925 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha ### Fixed - Discord Gateway RX grows for the trailing NUL so two 64 KiB libwebsockets fragments cannot write one byte past the heap block (typical READY payloads). +- WebChat inbound WS `rx_buffer_size` is `WS_RX_BUFFER_SIZE` (`WS_TEXT_MAX` plus JSON envelope) so dashboard messages are not split across 256-byte RECEIVE callbacks and dropped. - WebChat WebSocket sends now accept agent replies up to 32 KiB (`WS_TEXT_MAX`, matching `RESPONSE_BUF_SIZE`) instead of silently dropping payloads above 8 KiB. Dest buffers are `WS_TEXT_BUF_SIZE` so a max-length payload keeps its NUL; a too-large frame is logged instead of skipped with `<`. - Memory injection is skipped when the system prompt already fills its 64 KiB buffer, instead of writing the full `Relevant memories` prefix past the allocation after clamping recall to 0. - Session JSON that would exceed the 128 KiB cap is refused instead of truncated, so the next parse cannot wipe history. An oversized stored blob is left in place (distinct `SESSION_LOAD_TOO_LARGE`) rather than replaced by a later small turn. diff --git a/src/gateway/http.c b/src/gateway/http.c index 313a600..44f3333 100644 --- a/src/gateway/http.c +++ b/src/gateway/http.c @@ -37,11 +37,14 @@ static const struct lws_protocols protocols[] = { { .name = "ws", .callback = ws_callback, - .rx_buffer_size = 256, + .rx_buffer_size = WS_RX_BUFFER_SIZE, }, { .name = NULL }, }; +_Static_assert(WS_RX_BUFFER_SIZE >= (size_t)WS_TEXT_MAX + 32, + "WebChat rx_buffer_size must fit JSON envelope around WS_TEXT_MAX"); + static const struct lws_http_mount mount_http = { .mountpoint = "/", .origin = "http", diff --git a/src/gateway/http_lws.h b/src/gateway/http_lws.h index f14556e..45954b3 100644 --- a/src/gateway/http_lws.h +++ b/src/gateway/http_lws.h @@ -10,6 +10,7 @@ #include "core/version.h" #include "gateway/auth.h" #include "gateway/lws_compat.h" +#include "gateway/ws.h" #include #include #include @@ -24,6 +25,8 @@ extern "C" { #define CONFIG_BODY_MAX 65536 #define BODY_BUF_SIZE CONFIG_BODY_MAX #define ASAP_BODY_MAX (1024 * 1024) +/** Per-callback WebChat RX. Must fit `{"type":"message","text":...}` at WS_TEXT_MAX. */ +#define WS_RX_BUFFER_SIZE (WS_TEXT_MAX + 64) enum http_method { HTTP_GET = 1,