From 7bad8cacef9629731514acc048c012290da41227 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 08:50:31 +0000 Subject: [PATCH] Fix RFC 4180 quotes in scan CSV SSID and vendor fields The scan line reader ended a field at the first quotation mark, so an SSID or vendor containing a quote was dropped. Unescape doubled quotes into bounded buffers and show that text on the network info screen. Co-authored-by: El Padrino --- CMakeLists.txt | 1 + application.fam | 1 + src/app.h | 12 ++- src/scan_csv.c | 64 ++++++++++++++ src/scan_csv.h | 35 ++++++++ src/screen_wifi_scan.c | 36 ++++---- src/uart_comm.c | 80 ++++-------------- src/uart_comm.h | 3 - tests/scan_csv_test.c | 187 +++++++++++++++++++++++++++++++++++++++++ tests/test_scan_csv.py | 37 ++++++++ 10 files changed, 370 insertions(+), 86 deletions(-) create mode 100644 src/scan_csv.c create mode 100644 src/scan_csv.h create mode 100644 tests/scan_csv_test.c create mode 100644 tests/test_scan_csv.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 4e23658..9bb6a17 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,6 +3,7 @@ project(c5lab C) add_library(c5lab src/app.c + src/scan_csv.c src/uart_comm.c src/screen.c src/screen_main_menu.c diff --git a/application.fam b/application.fam index c511960..0898346 100644 --- a/application.fam +++ b/application.fam @@ -49,6 +49,7 @@ App( "src/screen_nmap.c", "src/screen_beacon_spam.c", "src/screen_wardrive_upload.c", + "src/scan_csv.c", "src/uart_comm.c", ], ) diff --git a/src/app.h b/src/app.h index 0ab727e..2a7b4c7 100644 --- a/src/app.h +++ b/src/app.h @@ -15,16 +15,20 @@ #include #include #include +#include "scan_csv.h" typedef struct WiFiApp WiFiApp; -// WiFi network info from scan +// WiFi network info from scan_networks / show_scan_results. +// ssid and vendor are unescaped (a CSV "" is one "). typedef struct { - char ssid[33]; - char bssid[18]; + char ssid[SCAN_CSV_SSID_MAX]; + char bssid[SCAN_CSV_BSSID_MAX]; int8_t rssi; int channel; - char auth[32]; + char auth[SCAN_CSV_AUTH_MAX]; + char vendor[SCAN_CSV_VENDOR_MAX]; + char band[SCAN_CSV_BAND_MAX]; } WiFiNetwork; #define MAX_SCAN_RESULTS 64 diff --git a/src/scan_csv.c b/src/scan_csv.c new file mode 100644 index 0000000..c3ad65a --- /dev/null +++ b/src/scan_csv.c @@ -0,0 +1,64 @@ +#include "scan_csv.h" + +#include +#include + +bool csv_next_quoted_field(const char** p, char* out, size_t out_size) { + if(!p || !*p || !out || out_size == 0) return false; + + out[0] = '\0'; + const char* s = *p; + while(*s == ' ' || *s == '\t' || *s == '\r' || *s == '\n') s++; + if(*s != '"') return false; + s++; // opening quote + + size_t i = 0; + while(*s) { + if(*s == '"') { + if(s[1] == '"') { + // RFC 4180: a doubled quote is one literal quote. + if(i + 1 < out_size) out[i++] = '"'; + s += 2; + continue; + } + break; // closing quote + } + if(i + 1 < out_size) out[i++] = *s; + s++; + } + + out[i] = '\0'; + if(*s != '"') { + out[0] = '\0'; + return false; + } + + s++; // closing quote + if(*s == ',') s++; + *p = s; + return true; +} + +bool parse_network_csv(const char* line, ScanCsvNetwork* net) { + if(!line || !net) return false; + + memset(net, 0, sizeof(*net)); + + char index_str[8] = {0}; + char channel_str[8] = {0}; + char rssi_str[8] = {0}; + const char* p = line; + + if(!csv_next_quoted_field(&p, index_str, sizeof(index_str))) return false; + if(!csv_next_quoted_field(&p, net->ssid, sizeof(net->ssid))) return false; + if(!csv_next_quoted_field(&p, net->vendor, sizeof(net->vendor))) return false; + if(!csv_next_quoted_field(&p, net->bssid, sizeof(net->bssid))) return false; + if(!csv_next_quoted_field(&p, channel_str, sizeof(channel_str))) return false; + if(!csv_next_quoted_field(&p, net->auth, sizeof(net->auth))) return false; + if(!csv_next_quoted_field(&p, rssi_str, sizeof(rssi_str))) return false; + if(!csv_next_quoted_field(&p, net->band, sizeof(net->band))) return false; + + if(channel_str[0] != '\0') net->channel = atoi(channel_str); + if(rssi_str[0] != '\0') net->rssi = atoi(rssi_str); + return true; +} diff --git a/src/scan_csv.h b/src/scan_csv.h new file mode 100644 index 0000000..c647bc8 --- /dev/null +++ b/src/scan_csv.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +/* Field sizes include the terminating NUL. + * SSID is the 802.11 maximum (32 octets). Vendor uses the same bound. + * BSSID is "AA:BB:CC:DD:EE:FF". Band holds values such as "2.4GHz". */ +#define SCAN_CSV_SSID_MAX 33 +#define SCAN_CSV_VENDOR_MAX 33 +#define SCAN_CSV_BSSID_MAX 18 +#define SCAN_CSV_AUTH_MAX 32 +#define SCAN_CSV_BAND_MAX 8 + +/* One scan_networks / show_scan_results row after RFC 4180 unescaping. + * A quote inside a field is stored as one ", not as the CSV "". */ +typedef struct { + char ssid[SCAN_CSV_SSID_MAX]; + char vendor[SCAN_CSV_VENDOR_MAX]; + char bssid[SCAN_CSV_BSSID_MAX]; + char auth[SCAN_CSV_AUTH_MAX]; + char band[SCAN_CSV_BAND_MAX]; + int channel; + int rssi; +} ScanCsvNetwork; + +/* Read the next quoted CSV field. "" inside the field becomes one ". + * Stops at the closing quote even when out is too small: extra source + * bytes are discarded, never written past out[out_size - 1]. + * On success, *p is advanced past the closing quote and a following comma. + * On failure, *p is left unchanged and out is an empty string. */ +bool csv_next_quoted_field(const char** p, char* out, size_t out_size); + +/* Parse "index","SSID","vendor","BSSID","channel","auth","RSSI","band". */ +bool parse_network_csv(const char* line, ScanCsvNetwork* net); diff --git a/src/screen_wifi_scan.c b/src/screen_wifi_scan.c index 52026c7..cc9e8b8 100644 --- a/src/screen_wifi_scan.c +++ b/src/screen_wifi_scan.c @@ -52,23 +52,27 @@ static void network_info_draw(Canvas* canvas, void* model) { screen_draw_title(canvas, "Network Info"); canvas_set_font(canvas, FontSecondary); - - // SSID - char line[48]; - snprintf(line, sizeof(line), "SSID: %s", net->ssid[0] ? net->ssid : "(hidden)"); + + // No "SSID:" prefix: a 21-character name fills one FontSecondary row. + char line[64]; + snprintf(line, sizeof(line), "%s", net->ssid[0] ? net->ssid : "(hidden)"); canvas_draw_str(canvas, 2, 20, line); - - // BSSID - snprintf(line, sizeof(line), "BSSID: %s", net->bssid); + + snprintf(line, sizeof(line), "Vendor: %s", net->vendor[0] ? net->vendor : "-"); canvas_draw_str(canvas, 2, 30, line); - - // Channel and RSSI - snprintf(line, sizeof(line), "Ch: %d Signal: %d dBm", net->channel, net->rssi); + + snprintf(line, sizeof(line), "BSSID: %s", net->bssid); canvas_draw_str(canvas, 2, 40, line); - - // Auth/Encryption - snprintf(line, sizeof(line), "Auth: %s", net->auth[0] ? net->auth : "Open"); + + if(net->band[0]) { + snprintf(line, sizeof(line), "Ch: %d %s %d dBm", net->channel, net->band, (int)net->rssi); + } else { + snprintf(line, sizeof(line), "Ch: %d Signal: %d dBm", net->channel, (int)net->rssi); + } canvas_draw_str(canvas, 2, 50, line); + + snprintf(line, sizeof(line), "Auth: %s", net->auth[0] ? net->auth : "Open"); + canvas_draw_str(canvas, 2, 60, line); } static bool network_info_input(InputEvent* event, void* context) { @@ -171,12 +175,12 @@ static void wifi_scan_draw(Canvas* canvas, void* model) { } bool selected = is_network_selected(app, idx + 1); - char line[42]; + char line[64]; if(net->ssid[0]) { - snprintf(line, sizeof(line), "%c %.18s [%d]", + snprintf(line, sizeof(line), "%c %s [%d]", selected ? '*' : ' ', net->ssid, - net->rssi); + (int)net->rssi); } else { snprintf(line, sizeof(line), "%c (%.17s)", selected ? '*' : ' ', diff --git a/src/uart_comm.c b/src/uart_comm.c index bf80fef..dceb8ca 100644 --- a/src/uart_comm.c +++ b/src/uart_comm.c @@ -24,73 +24,27 @@ static void uart_serial_irq(FuriHalSerialHandle* handle, FuriHalSerialRxEvent ev //============================================================================= // CSV Parsing for network scan results +// Format: "index","ssid","vendor","BSSID","channel","auth","rssi","band" +// Quotes inside a field are RFC 4180 doubled quotes. See scan_csv.c. //============================================================================= -bool csv_next_quoted_field(const char** p, char* out, size_t out_size) { - if(!p || !*p || !out || out_size == 0) return false; - - const char* s = *p; - while(*s == ' ' || *s == '\t' || *s == '\r' || *s == '\n') s++; - if(*s != '"') return false; - s++; // skip opening quote - - size_t i = 0; - while(*s && *s != '"') { - if(i + 1 < out_size) { - out[i++] = *s; - } - s++; - } - if(*s != '"') return false; - out[i] = '\0'; - s++; // skip closing quote - - if(*s == ',') s++; // skip comma if present - - *p = s; - return true; -} - -static bool parse_network_csv(const char* line, WiFiNetwork* net) { +static bool parse_network_line(const char* line, WiFiNetwork* net) { if(!line || !net) return false; - // Format: "index","ssid","vendor","AA:BB:CC:DD:EE:FF","channel","auth","rssi","band" - char index_str[8] = {0}; - char ssid[33] = {0}; - char vendor[33] = {0}; - char bssid[18] = {0}; - char channel_str[8] = {0}; - char auth[32] = {0}; - char rssi_str[8] = {0}; - char band[8] = {0}; - - const char* p = line; - bool ok = true; - ok &= csv_next_quoted_field(&p, index_str, sizeof(index_str)); - ok &= csv_next_quoted_field(&p, ssid, sizeof(ssid)); - ok &= csv_next_quoted_field(&p, vendor, sizeof(vendor)); - ok &= csv_next_quoted_field(&p, bssid, sizeof(bssid)); - ok &= csv_next_quoted_field(&p, channel_str, sizeof(channel_str)); - ok &= csv_next_quoted_field(&p, auth, sizeof(auth)); - ok &= csv_next_quoted_field(&p, rssi_str, sizeof(rssi_str)); - ok &= csv_next_quoted_field(&p, band, sizeof(band)); - - if(!ok) return false; - - int channel = 0; - int rssi = 0; - if(channel_str[0] != '\0') channel = atoi(channel_str); - if(rssi_str[0] != '\0') rssi = atoi(rssi_str); - - strncpy(net->ssid, ssid, sizeof(net->ssid) - 1); - net->ssid[sizeof(net->ssid) - 1] = '\0'; - strncpy(net->bssid, bssid, sizeof(net->bssid) - 1); - net->bssid[sizeof(net->bssid) - 1] = '\0'; - strncpy(net->auth, auth, sizeof(net->auth) - 1); - net->auth[sizeof(net->auth) - 1] = '\0'; - net->channel = channel; + ScanCsvNetwork parsed; + if(!parse_network_csv(line, &parsed)) return false; + + memset(net, 0, sizeof(*net)); + memcpy(net->ssid, parsed.ssid, sizeof(net->ssid)); + memcpy(net->vendor, parsed.vendor, sizeof(net->vendor)); + memcpy(net->bssid, parsed.bssid, sizeof(net->bssid)); + memcpy(net->auth, parsed.auth, sizeof(net->auth)); + memcpy(net->band, parsed.band, sizeof(net->band)); + net->channel = parsed.channel; + int rssi = parsed.rssi; + if(rssi > 127) rssi = 127; + if(rssi < -128) rssi = -128; net->rssi = (int8_t)rssi; - return true; } @@ -178,7 +132,7 @@ static int32_t uart_worker(void* context) { } else if(line_str[0] == '"') { // Parse network CSV line if(app->scan_results && app->scan_result_count < app->scan_result_capacity) { - if(parse_network_csv(line_str, &app->scan_results[app->scan_result_count])) { + if(parse_network_line(line_str, &app->scan_results[app->scan_result_count])) { app->scan_result_count++; } } diff --git a/src/uart_comm.h b/src/uart_comm.h index 8ceb758..1ee8d62 100644 --- a/src/uart_comm.h +++ b/src/uart_comm.h @@ -12,8 +12,5 @@ void uart_clear_buffer(WiFiApp* app); bool uart_check_board_connection(WiFiApp* app); bool uart_check_sd_card(WiFiApp* app); -// CSV parsing -bool csv_next_quoted_field(const char** p, char* out, size_t out_size); - // Scanning void uart_start_scan(WiFiApp* app); diff --git a/tests/scan_csv_test.c b/tests/scan_csv_test.c new file mode 100644 index 0000000..2fcc4f4 --- /dev/null +++ b/tests/scan_csv_test.c @@ -0,0 +1,187 @@ +#include "scan_csv.h" + +#include +#include +#include + +static void expect_example(void) { + const char* line = + "\"1\",\"moja \"\"specjalna\"\" siec\",\"Acme \"\"X\"\" Inc\"," + "\"AA:BB:CC:DD:EE:FF\",\"6\",\"WPA2\",\"-50\",\"2.4GHz\""; + ScanCsvNetwork net; + assert(parse_network_csv(line, &net)); + assert(strcmp(net.ssid, "moja \"specjalna\" siec") == 0); + assert(strcmp(net.vendor, "Acme \"X\" Inc") == 0); + assert(strcmp(net.bssid, "AA:BB:CC:DD:EE:FF") == 0); + assert(strcmp(net.auth, "WPA2") == 0); + assert(strcmp(net.band, "2.4GHz") == 0); + assert(net.channel == 6); + assert(net.rssi == -50); + + /* Strings the network-info screen draws for this row. */ + char shown[48]; + snprintf(shown, sizeof(shown), "%s", net.ssid); + assert(strcmp(shown, "moja \"specjalna\" siec") == 0); + snprintf(shown, sizeof(shown), "Vendor: %s", net.vendor); + assert(strcmp(shown, "Vendor: Acme \"X\" Inc") == 0); +} + +static void expect_plain_unchanged(void) { + const char* line = + "\"2\",\"HomeNetwork\",\"Cisco\",\"11:22:33:44:55:66\"," + "\"11\",\"WPA3\",\"-70\",\"5GHz\""; + ScanCsvNetwork net; + assert(parse_network_csv(line, &net)); + assert(strcmp(net.ssid, "HomeNetwork") == 0); + assert(strcmp(net.vendor, "Cisco") == 0); + assert(strcmp(net.bssid, "11:22:33:44:55:66") == 0); + assert(strcmp(net.auth, "WPA3") == 0); + assert(strcmp(net.band, "5GHz") == 0); + assert(net.channel == 11); + assert(net.rssi == -70); +} + +static void expect_commas_and_quotes(void) { + const char* line = + "\"3\",\"cafe, \"\"main\"\"\",\"Vendor, \"\"Y\"\"\"," + "\"10:00:00:00:00:01\",\"36\",\"OPEN\",\"-61\",\"5GHz\""; + ScanCsvNetwork net; + assert(parse_network_csv(line, &net)); + assert(strcmp(net.ssid, "cafe, \"main\"") == 0); + assert(strcmp(net.vendor, "Vendor, \"Y\"") == 0); + assert(net.channel == 36); + assert(net.rssi == -61); + assert(strcmp(net.bssid, "10:00:00:00:00:01") == 0); +} + +static void expect_truncation_keeps_following_fields(void) { + const char* line = + "\"9\",\"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXYYYY\",\"Acme\"," + "\"AA:BB:CC:DD:EE:FF\",\"6\",\"WPA2\",\"-50\",\"2.4GHz\""; + ScanCsvNetwork net; + assert(parse_network_csv(line, &net)); + assert(strlen(net.ssid) == 32); + assert(strncmp(net.ssid, "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", 32) == 0); + assert(strcmp(net.vendor, "Acme") == 0); + assert(strcmp(net.bssid, "AA:BB:CC:DD:EE:FF") == 0); + assert(net.channel == 6); + assert(net.rssi == -50); + + const char* quoted = + "\"9\",\"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\"\"Y\",\"Acme\"," + "\"AA:BB:CC:DD:EE:FF\",\"6\",\"WPA2\",\"-50\",\"2.4GHz\""; + assert(parse_network_csv(quoted, &net)); + assert(strlen(net.ssid) == 32); + assert(strcmp(net.vendor, "Acme") == 0); + assert(strcmp(net.auth, "WPA2") == 0); + assert(strcmp(net.band, "2.4GHz") == 0); + + const char* fit = "\"9\",\"XX\"\"YY\",\"Acme\",\"AA:BB:CC:DD:EE:FF\",\"6\",\"WPA2\",\"-50\",\"2.4GHz\""; + assert(parse_network_csv(fit, &net)); + assert(strcmp(net.ssid, "XX\"YY") == 0); +} + +static void expect_field_bounds(void) { + struct { + unsigned char before[4]; + char out[4]; + unsigned char after[4]; + } box; + memset(&box, 0xA5, sizeof(box)); + + const char* p = "\"abcdef\",\"z\""; + const char* start = p; + assert(csv_next_quoted_field(&p, box.out, sizeof(box.out))); + assert(strcmp(box.out, "abc") == 0); + assert(p > start); + char next[4]; + assert(csv_next_quoted_field(&p, next, sizeof(next))); + assert(strcmp(next, "z") == 0); + for(int i = 0; i < 4; i++) { + assert(box.before[i] == 0xA5); + assert(box.after[i] == 0xA5); + } + + char tiny[1]; + p = "\"ab\""; + assert(csv_next_quoted_field(&p, tiny, sizeof(tiny))); + assert(tiny[0] == '\0'); + + char buf[8]; + p = "\"abc"; + const char* stuck = p; + assert(!csv_next_quoted_field(&p, buf, sizeof(buf))); + assert(p == stuck); + assert(buf[0] == '\0'); + + p = "\"\"\"\""; + assert(csv_next_quoted_field(&p, buf, sizeof(buf))); + assert(strcmp(buf, "\"") == 0); + + p = "\"a,b\",\"c,d\""; + assert(csv_next_quoted_field(&p, buf, sizeof(buf))); + assert(strcmp(buf, "a,b") == 0); + assert(csv_next_quoted_field(&p, buf, sizeof(buf))); + assert(strcmp(buf, "c,d") == 0); + + /* show_pass evil / portal rows share this reader. Plain rows stay intact. */ + char wide[40]; + p = "\"Home\",\"secret\""; + assert(csv_next_quoted_field(&p, wide, sizeof(wide))); + assert(strcmp(wide, "Home") == 0); + assert(csv_next_quoted_field(&p, wide, sizeof(wide))); + assert(strcmp(wide, "secret") == 0); + + p = "\"moja \"\"specjalna\"\" siec\",\"p\"\"w\""; + assert(csv_next_quoted_field(&p, wide, sizeof(wide))); + assert(strcmp(wide, "moja \"specjalna\" siec") == 0); + assert(csv_next_quoted_field(&p, wide, sizeof(wide))); + assert(strcmp(wide, "p\"w") == 0); + + p = " \"1\", \"Home\""; + assert(csv_next_quoted_field(&p, wide, sizeof(wide))); + assert(strcmp(wide, "1") == 0); + assert(csv_next_quoted_field(&p, wide, sizeof(wide))); + assert(strcmp(wide, "Home") == 0); + + assert(!csv_next_quoted_field(NULL, buf, sizeof(buf))); + const char* none = NULL; + assert(!csv_next_quoted_field(&none, buf, sizeof(buf))); + p = "\"a\""; + assert(!csv_next_quoted_field(&p, NULL, sizeof(buf))); + assert(!csv_next_quoted_field(&p, buf, 0)); +} + +static void expect_rejects_broken_rows(void) { + ScanCsvNetwork net; + assert(!parse_network_csv(NULL, &net)); + assert(!parse_network_csv("\"1\"", NULL)); + assert(!parse_network_csv("not csv", &net)); + assert(!parse_network_csv( + "\"1\",\"only\",\"seven\",\"AA:BB:CC:DD:EE:FF\",\"6\",\"WPA2\",\"-50\"", + &net)); + assert(!parse_network_csv( + "\"1\",\"unterminated,\"Acme\",\"AA:BB:CC:DD:EE:FF\",\"6\",\"WPA2\",\"-50\",\"2.4GHz\"", + &net)); + assert(!parse_network_csv("\"1\",\"moja \"\"specjalna\"\" siec", &net)); + + assert(parse_network_csv( + "\"4\",\"\",\"\",\"AA:BB:CC:DD:EE:FF\",\"1\",\"OPEN\",\"0\",\"6GHz\"", + &net)); + assert(net.ssid[0] == '\0'); + assert(net.vendor[0] == '\0'); + assert(net.channel == 1); + assert(net.rssi == 0); + assert(strcmp(net.band, "6GHz") == 0); +} + +int main(void) { + expect_example(); + expect_plain_unchanged(); + expect_commas_and_quotes(); + expect_truncation_keeps_following_fields(); + expect_field_bounds(); + expect_rejects_broken_rows(); + puts("scan csv PASS"); + return 0; +} diff --git a/tests/test_scan_csv.py b/tests/test_scan_csv.py new file mode 100644 index 0000000..14aeee4 --- /dev/null +++ b/tests/test_scan_csv.py @@ -0,0 +1,37 @@ +"""Host tests for scan_networks / show_scan_results CSV parsing. + +No Flipper SDK required. Builds src/scan_csv.c with the system compiler. +""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] + + +class ScanCsvTests(unittest.TestCase): + def test_quoted_ssid_and_vendor(self): + with tempfile.TemporaryDirectory() as tmp: + executable = str(Path(tmp) / "scan_csv_test") + cmd = [ + "cc", + "-std=c11", + "-Wall", + "-Wextra", + "-Werror", + "-I", + str(ROOT / "src"), + str(ROOT / "src" / "scan_csv.c"), + str(ROOT / "tests" / "scan_csv_test.c"), + "-o", + executable, + ] + subprocess.run(cmd, check=True) + output = subprocess.check_output([executable], text=True) + self.assertIn("scan csv PASS", output) + + +if __name__ == "__main__": + unittest.main()