From 2499d116a3c82db5a24b8d95b726e7080f354082 Mon Sep 17 00:00:00 2001 From: bloqaudio Date: Mon, 31 Aug 2026 20:46:13 -0500 Subject: [PATCH 1/5] httpd: match header field names case-insensitively scan_header() found Content-Type and Cookie with is_word(), which compares bytes exactly and requires a separator after the pattern. Field names are case-insensitive (RFC 7230 section 3.2), and the whitespace after the colon is optional, so "content-type: multipart/..." and "Content-Type:multipart/..." were both treated as absent. The value pointers were then fixed offsets that assumed exactly one space. Add header_value(), which matches a lower-case name anchored at the line start, folds the request bytes to lower case as it compares, and returns the start of the value past any blanks, so a call site no longer adds the name length by hand. Cookie scanning reuses the returned pointer, and the end-of-header test becomes the strstart() the file already has. --- httpd/httpd.c | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/httpd/httpd.c b/httpd/httpd.c index 3fc5134..6227bb4 100644 --- a/httpd/httpd.c +++ b/httpd/httpd.c @@ -138,6 +138,24 @@ bool is_word(__xdata uint8_t *xdata_str_p, __code uint8_t * __xdata code_str_p) } +/* name must be lower-case, starting with the '\n' of the previous line's end */ +__xdata uint8_t *header_value(__xdata uint8_t *p, __code uint8_t *name) +{ + uint8_t u, c; + + while ((c = *name++)) { + u = *p++; + if (u >= 'A' && u <= 'Z') + u += 'a' - 'A'; + if (u != c) + return 0; + } + while (*p == ' ' || *p == '\t') + p++; + return p; +} + + bool is_url_word_x(__xdata uint8_t *uri_str_p, __xdata uint8_t *src_str_p) { uint8_t u, s; @@ -248,28 +266,29 @@ void send_unauthorized(void) __xdata uint8_t *scan_header(__xdata uint8_t * __xdata p) { + __xdata uint8_t *v; + content_type = 0; session = 0; authenticated = 0; - while (*p != '\r' || *(p + 1) != '\n' || *(p + 2) != '\r' || *(p + 3) != '\n') { + while (!strstart(p, "\r\n\r\n")) { dbg_char(*p); if (!*p++) break; - if (is_word(p, "\nContent-Type:")) - content_type = p + 15; - else if (is_word(p, "\nCookie:")) { + if ((v = header_value(p, "\ncontent-type:"))) + content_type = v; + else if ((v = header_value(p, "\ncookie:"))) { /* Scan for the "session" key: the header may hold several * cookies in any order. Match "session" not "session=" - * is_word() requires a separator after the match and '=' is * one, so this also rejects a longer key like "sessionx". */ - __xdata uint8_t *c = p + 8; /* past "\nCookie:" */ - while (*c && *c != '\r' && *c != '\n') { - if (is_word(c, "session")) { - session = c + 8; /* past "session=" */ + while (*v && *v != '\r' && *v != '\n') { + if (is_word(v, "session")) { + session = v + 8; /* past "session=" */ break; } - c++; + v++; } } } From d6bf46595a514b6610736106709da89b2c18f44c Mon Sep 17 00:00:00 2001 From: bloqaudio Date: Mon, 31 Aug 2026 20:46:13 -0500 Subject: [PATCH 2/5] httpd: saturate parse_short() instead of wrapping The digit loop accumulated into a uint16_t without a bound, so a query such as /vlan.json?vid=65540 read as VLAN 4 and every consumer saw a small, valid-looking number for an out-of-range one. Clamp the result at 0xffff once another digit would overflow (6552 * 10 + 9 is the last value that fits). The consumers already reject or mask 0xffff: vlan_get() refuses anything from 4095 up, send_l2() masks the index to the table size, and l2_delete() masks the high byte. --- httpd/httpd.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/httpd/httpd.c b/httpd/httpd.c index 6227bb4..2701e64 100644 --- a/httpd/httpd.c +++ b/httpd/httpd.c @@ -231,7 +231,10 @@ uint8_t parse_short(__xdata uint8_t *p) c = *p++ - '0'; if (c > 9) { break; } err = 0; - short_parsed = (short_parsed * 10) + c; + if (short_parsed > 6552) + short_parsed = 0xffff; + else + short_parsed = (short_parsed * 10) + c; } return err; } From cbcf67a6939439ec0f6dd31327a71fb8e0944701 Mon Sep 17 00:00:00 2001 From: bloqaudio Date: Mon, 31 Aug 2026 20:46:13 -0500 Subject: [PATCH 3/5] httpd: keep scan_header() on the terminator of a truncated header The scan stepped over the NUL that ends the received data before breaking, so on a request whose headers were cut short it returned a pointer one past the data. handle_post() then tested that byte for the end of the header, reading whatever an earlier packet had left there instead of the NUL the appcall wrote, and could go on to run an endpoint against the stale bytes. Break before advancing so the returned pointer sits on the NUL and the caller's check sees it. --- httpd/httpd.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/httpd/httpd.c b/httpd/httpd.c index 2701e64..e063a47 100644 --- a/httpd/httpd.c +++ b/httpd/httpd.c @@ -277,8 +277,9 @@ __xdata uint8_t *scan_header(__xdata uint8_t * __xdata p) while (!strstart(p, "\r\n\r\n")) { dbg_char(*p); - if (!*p++) + if (!*p) break; + p++; if ((v = header_value(p, "\ncontent-type:"))) content_type = v; else if ((v = header_value(p, "\ncookie:"))) { From 28020e818665d3fbf7f7924ab63f806e60123b43 Mon Sep 17 00:00:00 2001 From: bloqaudio Date: Mon, 31 Aug 2026 20:46:13 -0500 Subject: [PATCH 4/5] httpd: parse Content-Length in scan_header() Record the announced body length alongside Content-Type and the session cookie. The value goes through parse_short(), so an announcement that does not fit sixteen bits saturates at 0xffff rather than wrapping to something the size checks would accept, and a missing or unparseable header reads as 0. Nothing consumes it yet; the plain POST endpoints take it up next. --- httpd/httpd.c | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/httpd/httpd.c b/httpd/httpd.c index e063a47..d9192fb 100644 --- a/httpd/httpd.c +++ b/httpd/httpd.c @@ -54,6 +54,7 @@ __xdata uint8_t config_buf[CONFIG_UPLOAD_BUF]; __xdata uint16_t pre_acc; __xdata uint8_t * __xdata content_type = 0; __xdata uint8_t * __xdata session = 0; +__xdata uint16_t content_length; // Global variables holding POST state __xdata uint16_t bindex; // Current index into the boundary @@ -272,6 +273,7 @@ __xdata uint8_t *scan_header(__xdata uint8_t * __xdata p) __xdata uint8_t *v; content_type = 0; + content_length = 0; session = 0; authenticated = 0; @@ -282,7 +284,10 @@ __xdata uint8_t *scan_header(__xdata uint8_t * __xdata p) p++; if ((v = header_value(p, "\ncontent-type:"))) content_type = v; - else if ((v = header_value(p, "\ncookie:"))) { + else if ((v = header_value(p, "\ncontent-length:"))) { + parse_short(v); + content_length = short_parsed; + } else if ((v = header_value(p, "\ncookie:"))) { /* Scan for the "session" key: the header may hold several * cookies in any order. Match "session" not "session=" - * is_word() requires a separator after the match and '=' is From f5e4fa5134a59a0a5887069f652fea0ab282bee4 Mon Sep 17 00:00:00 2001 From: bloqaudio Date: Mon, 31 Aug 2026 20:46:13 -0500 Subject: [PATCH 5/5] httpd: buffer a plain POST body that arrives after the headers A POST /cmd whose body arrives in a separate TCP segment from the headers executed nothing: handle_post() read the body from the segment that carried the request line, found it empty, and answered 200 OK. Python's urllib and requests both write headers and body separately, so every scripted command from those clients became a silent no-op. POST /login had the same hole. The multipart endpoints were fixed earlier; this covers the plain ones. When the first segment holds fewer body bytes than Content-Length announces, the bytes accumulate in config_buf (idle outside multipart uploads) and the connection waits in TSTATE_POSTBODY; once the announced length is in, the endpoint runs against the buffer. The /cmd and /login executions move into run_cmd_body() and run_login_body() so both paths share them; the 200 OK builder they duplicated becomes send_ok(), and run_login_body() checks the pwd= prefix instead of skipping four bytes blindly. The announced length is the contract on both paths. A body that arrives with the headers is terminated at Content-Length before it runs, so pipelined bytes after it are not executed as commands the way the old code did. A request without a usable Content-Length answers 411: without one there is no way to know when the body has arrived, and the old 200 OK for an empty body is the bug this fixes. Bodies announced at or above the buffer size answer 400 before anything is buffered. The wait state has a deadline. uIP is built with a single connection, and an ESTABLISHED connection with nothing in flight never times out on its own, so headers followed by silence (a script interrupted, a link dropped mid-request, or someone holding the socket on purpose) would otherwise keep the slot until reboot; POST /login reaches the wait before authentication. The poll handler aborts the connection when the body has made no progress for five seconds, measured on the 200 Hz system tick rather than on poll count, which runs faster under interrupt load. --- httpd/httpd.c | 145 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 121 insertions(+), 24 deletions(-) diff --git a/httpd/httpd.c b/httpd/httpd.c index d9192fb..5e92d05 100644 --- a/httpd/httpd.c +++ b/httpd/httpd.c @@ -62,6 +62,12 @@ __xdata uint8_t verify_crc; __xdata uint32_t max_upload; __xdata uint16_t short_parsed; +#define POSTBODY_CMD 1 +#define POSTBODY_LOGIN 2 +#define POSTBODY_TIMEOUT (5 * SYS_TICK_HZ) +__xdata uint8_t postbody_endpoint; +__xdata uint16_t postbody_start; + __xdata char passwd[21]; // Set when a verified firmware upload awaits its response ACK, after // which the chip resets to apply the staged image @@ -78,6 +84,7 @@ __xdata uint32_t last_session_use; #define TSTATE_CLOSED 3 #define TSTATE_POST 4 #define TSTATE_MULTIPART 5 +#define TSTATE_POSTBODY 6 extern __xdata uint16_t crc_value; __xdata uint16_t crc_final; @@ -268,6 +275,18 @@ void send_unauthorized(void) } +void send_length_required(void) +{ + slen = strtox(outbuf, "HTTP/1.1 411 Length Required\r\nConnection: close\r\n\r\n"); +} + + +void send_ok(void) +{ + slen = strtox(outbuf, "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"); +} + + __xdata uint8_t *scan_header(__xdata uint8_t * __xdata p) { __xdata uint8_t *v; @@ -559,7 +578,7 @@ static void handle_config_fragment(__xdata uint8_t *p) send_bad_request(); return; } - slen = strtox(outbuf, "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"); + send_ok(); } @@ -604,12 +623,95 @@ static void handle_firmware_fragment(__xdata uint8_t *p) } +static void run_cmd_body(__xdata uint8_t *body) +{ + execute_commands(body); + if (err_status != ERR_OK) { + send_bad_request(); + return; + } + send_ok(); +} + + +static void run_login_body(__xdata uint8_t *body) +{ + if (strstart(body, "pwd=") && is_url_word_x(body + 4, passwd)) { + dbg_string("Password accepted!\n"); + read_reg_timer(&last_session_use); + gen_random_hex_chars(session_id, SESSION_ID_LENGTH); + session_id[SESSION_ID_LENGTH] = NUL; + slen = strtox(outbuf, "HTTP/1.1 302 Found\r\nConnection: close\r\nLocation: index.html\r\n" \ + "Set-Cookie: session="); + for (uint8_t i = 0; i < SESSION_ID_LENGTH; i++) + outbuf[slen++] = session_id[i]; + slen += strtox(outbuf + slen, "; SameSite=Strict\r\n\r\n"); + } else { + dbg_string("Password invalid!\n"); + slen = strtox(outbuf, "HTTP/1.1 302 Found\r\nConnection: close\r\nLocation: login.html\r\n\r\n"); + } +} + + +static uint8_t post_body_take(__xdata uint8_t *p) +{ + uint16_t have; + + if (!content_length) { + send_length_required(); + return 0; + } + if (content_length >= CONFIG_UPLOAD_BUF) { + send_bad_request(); + return 0; + } + have = uip_len - (p - uip_appdata); + if (have >= content_length) { + p[content_length] = NUL; + return 1; + } + memcpy(config_buf, p, have); + pre_acc = have; + postbody_start = ticks; + uip_conn->appstate.tstate = TSTATE_POSTBODY; + return 0; +} + + +static void post_body_continue(void) +{ + uint16_t take; + + // no header scan runs while the body is pending: content_length is this request's + take = content_length - pre_acc; + if (take > uip_len) + take = uip_len; + memcpy(config_buf + pre_acc, uip_appdata, take); + pre_acc += take; + if (pre_acc < content_length) { + postbody_start = ticks; + return; + } + config_buf[pre_acc] = NUL; + uip_conn->appstate.tstate = TSTATE_NONE; + if (postbody_endpoint == POSTBODY_CMD) + run_cmd_body(config_buf); + else + run_login_body(config_buf); +} + + void handle_post(void) { __xdata struct httpd_state * __xdata s = &(uip_conn->appstate); __xdata uint8_t *p = uip_appdata; __xdata uint8_t *request_path = p + 6; + if (s->tstate == TSTATE_POSTBODY) { + post_body_continue(); + return; + } + // Was the multipart header sent in multiple packets? if (s->tstate != TSTATE_MULTIPART) { dbg_string("Is POST\n"); @@ -662,11 +764,11 @@ void handle_post(void) send_unauthorized(); return; } - execute_commands(p); - if (err_status != ERR_OK) { - send_bad_request(); + postbody_endpoint = POSTBODY_CMD; + if (!post_body_take(p)) return; - } + run_cmd_body(p); + return; } else if (is_word(request_path, "login")) { dbg_string("POST login\n"); @@ -676,21 +778,11 @@ void handle_post(void) return; } - p += 8; // Read also over "pwd=" - if (is_url_word_x(p, passwd)) { - dbg_string("Password accepted!\n"); - read_reg_timer(&last_session_use); - gen_random_hex_chars(session_id, SESSION_ID_LENGTH); - session_id[SESSION_ID_LENGTH] = NUL; - slen = strtox(outbuf, "HTTP/1.1 302 Found\r\nConnection: close\r\nLocation: index.html\r\n" \ - "Set-Cookie: session="); - for (uint8_t i = 0; i < SESSION_ID_LENGTH; i++) - outbuf[slen++] = session_id[i]; - slen += strtox(outbuf + slen, "; SameSite=Strict\r\n\r\n"); - } else { - dbg_string("Password invalid!\n"); - slen = strtox(outbuf, "HTTP/1.1 302 Found\r\nConnection: close\r\nLocation: login.html\r\n\r\n"); - } + p += 4; + postbody_endpoint = POSTBODY_LOGIN; + if (!post_body_take(p)) + return; + run_login_body(p); return; } else if (s->tstate == TSTATE_MULTIPART || is_word(request_path, "upload") || is_word(request_path, "config")) { dbg_string("POST upload/config request\n"); @@ -712,8 +804,6 @@ void handle_post(void) send_not_found(); return; } - slen = strtox(outbuf, "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"); - return; } @@ -744,6 +834,11 @@ void httpd_appcall(void) dbg_string("Closing because everything has been transmitted\n"); uip_close(); s->tstate = TSTATE_CLOSED; + } else if (s->tstate == TSTATE_POSTBODY + && (uint16_t)ticks - postbody_start > POSTBODY_TIMEOUT) { + dbg_string("Body never arrived\n"); + uip_abort(); + s->tstate = TSTATE_CLOSED; } } else if (uip_acked() && s->tstate == TSTATE_TX) { dbg_string("ACK\n"); @@ -811,10 +906,12 @@ void httpd_appcall(void) dbg_char('\n'); #endif p = uip_appdata; - if (is_word(p, "POST") || s->tstate == TSTATE_MULTIPART) { + if (is_word(p, "POST") || s->tstate == TSTATE_MULTIPART + || s->tstate == TSTATE_POSTBODY) { handle_post(); // If this is an ongoing post stream, then wait for the next packet - if (s->tstate == TSTATE_POST || s->tstate == TSTATE_MULTIPART) { + if (s->tstate == TSTATE_POST || s->tstate == TSTATE_MULTIPART + || s->tstate == TSTATE_POSTBODY) { uip_len = 0; return; }