diff --git a/src/gps/GeoCoord.cpp b/src/gps/GeoCoord.cpp index 04c25caa3..f225ab61c 100644 --- a/src/gps/GeoCoord.cpp +++ b/src/gps/GeoCoord.cpp @@ -1,4 +1,14 @@ #include "GeoCoord.h" +#include + +// Narrow a UTM meter value to its unsigned field, clamping non-finite/negative/oversized inputs: an +// extreme (crafted) lat/lon can drive these out of range, and an overflowing double->unsigned cast is UB. +static uint32_t clampMeters(double m) +{ + if (!std::isfinite(m) || m < 0.0) + return 0; + return m > 4.0e9 ? 4000000000u : (uint32_t)m; +} GeoCoord::GeoCoord() { @@ -124,8 +134,13 @@ void GeoCoord::latLongToUTM(const double lat, const double lon, UTM &utm) { const std::string latBands = "CDEFGHJKLMNPQRSTUVWXX"; - utm.zone = int((lon + 180) / 6 + 1); - utm.band = latBands[int(lat / 8 + 10)]; + // A received Position carries raw int32 latitude_i/longitude_i with no range validation, so lat/lon + // here can be far outside real geographic bounds. Clamp the derived UTM zone (valid 1..60) and the + // latitude-band index so the lookups below cannot read out of bounds (GeoCoord.cpp:128 stack over/ + // under-read on e.g. latitude_i = INT32_MAX/INT32_MIN). + utm.zone = std::min(std::max(int((lon + 180) / 6 + 1), 1), 60); + int bandIdx = std::min(std::max(int(lat / 8 + 10), 0), int(latBands.length()) - 1); + utm.band = latBands[bandIdx]; double a = 6378137; // WGS84 - equatorial radius double k0 = 0.9996; // UTM point scale on the central meridian double eccSquared = 0.00669438; // eccentricity squared @@ -160,17 +175,22 @@ void GeoCoord::latLongToUTM(const double lat, const double lon, UTM &utm) sin(2 * latRad) + (15 * eccSquared * eccSquared / 256 + 45 * eccSquared * eccSquared * eccSquared / 1024) * sin(4 * latRad) - (35 * eccSquared * eccSquared * eccSquared / 3072) * sin(6 * latRad)); - utm.easting = (double)(k0 * N * - (A + (1 - T + C) * pow(A, 3) / 6 + - (5 - 18 * T + T * T + 72 * C - 58 * eccPrimeSquared) * A * A * A * A * A / 120) + - 500000.0); - utm.northing = - (double)(k0 * (M + N * tan(latRad) * - (A * A / 2 + (5 - T + 9 * C + 4 * C * C) * A * A * A * A / 24 + - (61 - 58 * T + T * T + 600 * C - 330 * eccPrimeSquared) * A * A * A * A * A * A / 720))); + double eastingMeters = + k0 * N * + (A + (1 - T + C) * pow(A, 3) / 6 + (5 - 18 * T + T * T + 72 * C - 58 * eccPrimeSquared) * A * A * A * A * A / 120) + + 500000.0; + double northingMeters = + k0 * (M + N * tan(latRad) * + (A * A / 2 + (5 - T + 9 * C + 4 * C * C) * A * A * A * A / 24 + + (61 - 58 * T + T * T + 600 * C - 330 * eccPrimeSquared) * A * A * A * A * A * A / 720)); if (lat < 0) - utm.northing += 10000000.0; // 10000000 meter offset for southern hemisphere + northingMeters += 10000000.0; // 10000000 meter offset for southern hemisphere + + // Clamp before narrowing to the unsigned UTM fields (see clampMeters): extreme lat/lon can drive + // these negative or past UINT32, and the raw double->unsigned cast would be UB. + utm.easting = clampMeters(eastingMeters); + utm.northing = clampMeters(northingMeters); } // Converts lat long coordinates to an MGRS. @@ -182,10 +202,15 @@ void GeoCoord::latLongToMGRS(const double lat, const double lon, MGRS &mgrs) latLongToUTM(lat, lon, utm); mgrs.zone = utm.zone; mgrs.band = utm.band; - double col = floor(utm.easting / 100000); - mgrs.east100k = e100kLetters[(mgrs.zone - 1) % 3][col - 1]; - double row = (int32_t)floor(utm.northing / 100000.0) % 20; - mgrs.north100k = n100kLetters[(mgrs.zone - 1) % 2][row]; + // utm.zone is clamped to 1..60 above, but guard every index defensively: the column/row derived + // from easting/northing can fall outside the 100km-grid letter tables when lat/lon are extreme. + int zoneIdx3 = ((mgrs.zone - 1) % 3 + 3) % 3; + int zoneIdx2 = ((mgrs.zone - 1) % 2 + 2) % 2; + int colIdx = std::min(std::max(int(floor(utm.easting / 100000)) - 1, 0), int(e100kLetters[zoneIdx3].length()) - 1); + mgrs.east100k = e100kLetters[zoneIdx3][colIdx]; + int rowIdx = ((int32_t)floor(utm.northing / 100000.0) % 20 + 20) % 20; + rowIdx = std::min(std::max(rowIdx, 0), int(n100kLetters[zoneIdx2].length()) - 1); + mgrs.north100k = n100kLetters[zoneIdx2][rowIdx]; mgrs.easting = (int32_t)utm.easting % 100000; mgrs.northing = (int32_t)utm.northing % 100000; } diff --git a/src/mesh/MeshService.cpp b/src/mesh/MeshService.cpp index a7464c0cc..44e7e5cf4 100644 --- a/src/mesh/MeshService.cpp +++ b/src/mesh/MeshService.cpp @@ -302,10 +302,39 @@ bool MeshService::trySendPosition(NodeNum dest, bool wantReplies) return false; } +// Re-decode nested string-bearing payloads before local phone delivery so PB_VALIDATE_UTF8 rejects +// malformed NodeInfo/Waypoint data a strict phone decoder could crash on. Mesh relay is unaffected. +bool MeshService::phonePayloadIsDecodable(const meshtastic_Data &d) +{ + // User/Waypoint are all-static nanopb messages (no PB_ENABLE_MALLOC/callback fields), so the + // decoded scratch owns no heap and needs no pb_release. + switch (d.portnum) { + case meshtastic_PortNum_NODEINFO_APP: { + meshtastic_User u = meshtastic_User_init_zero; + return pb_decode_from_bytes(d.payload.bytes, d.payload.size, &meshtastic_User_msg, &u); + } + case meshtastic_PortNum_WAYPOINT_APP: { + meshtastic_Waypoint w = meshtastic_Waypoint_init_zero; + return pb_decode_from_bytes(d.payload.bytes, d.payload.size, &meshtastic_Waypoint_msg, &w); + } + default: + return true; + } +} + void MeshService::sendToPhone(meshtastic_MeshPacket *p) { perhapsDecode(p); + // Withhold decoded nested payloads a strict phone decoder would reject; still-encrypted packets + // pass through (the phone may hold the key). + if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && !phonePayloadIsDecodable(p->decoded)) { + LOG_WARN("Dropping undecodable portnum=%d payload from phone delivery (from=0x%08x)", p->decoded.portnum, p->from); + releaseToPool(p); + fromNum++; // notify observers so the phone can resync + return; + } + #ifdef ARCH_ESP32 #if !MESHTASTIC_EXCLUDE_STOREFORWARD if (moduleConfig.store_forward.enabled && storeForwardModule->isServer() && diff --git a/src/mesh/MeshService.h b/src/mesh/MeshService.h index eee39fba2..41dcf1c80 100644 --- a/src/mesh/MeshService.h +++ b/src/mesh/MeshService.h @@ -100,6 +100,11 @@ class MeshService p->decoded.portnum == meshtastic_PortNum_DETECTION_SENSOR_APP || p->decoded.portnum == meshtastic_PortNum_ALERT_APP; } + + /// Returns false when a decoded NodeInfo/Waypoint payload fails nested protobuf decode (invalid + /// UTF-8 under PB_VALIDATE_UTF8, etc.); other portnums pass through. Callers gate on the variant. + static bool phonePayloadIsDecodable(const meshtastic_Data &decoded); + /// Called when some new packets have arrived from one of the radios Observable fromNumChanged; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 37ee96134..3e877a57c 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1024,7 +1024,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) strncpy(config.network.ntp_server, "meshtastic.pool.ntp.org", 32); #if (defined(T_DECK) || defined(T_WATCH_S3) || defined(UNPHONE) || defined(PICOMPUTER_S3) || defined(SENSECAP_INDICATOR) || \ - defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) || defined(RAK_WISMESH_TAP_V2)) && \ + defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) || defined(RAK_WISMESH_TAP_V2)) && \ HAS_TFT // switch BT off by default; use TFT programming mode or hotkey to enable config.bluetooth.enabled = false; @@ -3264,14 +3264,20 @@ bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelInde if (owner.public_key.size == 32 && memcmp(p.public_key.bytes, owner.public_key.bytes, 32) == 0) { if (!duplicateWarned) { duplicateWarned = true; + // Sanitize before embedding long_name in the phone-facing ClientNotification string + // (defense-in-depth vs PB_VALIDATE_UTF8). + char safeName[sizeof(p.long_name)]; + strncpy(safeName, p.long_name, sizeof(safeName)); + safeName[sizeof(safeName) - 1] = '\0'; + sanitizeUtf8(safeName, sizeof(safeName)); char warning[] = "Remote device %s has advertised your public key. This may indicate a compromised key. You may need " "to regenerate your public keys."; - LOG_WARN(warning, p.long_name); + LOG_WARN(warning, safeName); meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed(); cn->level = meshtastic_LogRecord_Level_WARNING; cn->time = getValidTime(RTCQualityFromNet); - snprintf(cn->message, sizeof(cn->message), warning, p.long_name); + snprintf(cn->message, sizeof(cn->message), warning, safeName); service->sendClientNotification(cn); } return false; diff --git a/src/modules/Telemetry/AirQualityTelemetry.cpp b/src/modules/Telemetry/AirQualityTelemetry.cpp index a50ea8e57..962068523 100644 --- a/src/modules/Telemetry/AirQualityTelemetry.cpp +++ b/src/modules/Telemetry/AirQualityTelemetry.cpp @@ -210,6 +210,11 @@ void AirQualityTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSta return; } + // Same String(float) stack-overflow guard as EnvironmentTelemetry: form_formaldehyde is the only + // float rendered here, and its raw bytes are unvalidated on decode. + telemetry.variant.air_quality_metrics.form_formaldehyde = + UnitConversions::displaySafeFloat(telemetry.variant.air_quality_metrics.form_formaldehyde); + const auto &m = telemetry.variant.air_quality_metrics; // Check if any telemetry field has valid data diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index eafa056c6..53dc3e7be 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -375,6 +375,22 @@ void EnvironmentTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSt return; } + // Bound the float metrics before String(float) renders them (see UnitConversions::displaySafeFloat); + // the stored packet is always the environment variant. + { + auto &e = telemetry.variant.environment_metrics; + e.temperature = UnitConversions::displaySafeFloat(e.temperature); + e.relative_humidity = UnitConversions::displaySafeFloat(e.relative_humidity); + e.barometric_pressure = UnitConversions::displaySafeFloat(e.barometric_pressure); + e.voltage = UnitConversions::displaySafeFloat(e.voltage); + e.current = UnitConversions::displaySafeFloat(e.current); + e.lux = UnitConversions::displaySafeFloat(e.lux); + e.white_lux = UnitConversions::displaySafeFloat(e.white_lux); + e.weight = UnitConversions::displaySafeFloat(e.weight); + e.distance = UnitConversions::displaySafeFloat(e.distance); + e.radiation = UnitConversions::displaySafeFloat(e.radiation); + } + const auto &m = telemetry.variant.environment_metrics; // Check if any telemetry field has valid data diff --git a/src/modules/Telemetry/UnitConversions.h b/src/modules/Telemetry/UnitConversions.h index 638476315..00d0bd51b 100644 --- a/src/modules/Telemetry/UnitConversions.h +++ b/src/modules/Telemetry/UnitConversions.h @@ -1,5 +1,7 @@ #pragma once +#include + class UnitConversions { public: @@ -7,4 +9,13 @@ class UnitConversions static float MetersPerSecondToKnots(float metersPerSecond); static float MetersPerSecondToMilesPerHour(float metersPerSecond); static float HectoPascalToInchesOfMercury(float hectoPascal); + + // Bound a float before Arduino String(float) renders it: its fixed char[33] + dtostrf overflow + // near FLT_MAX (stack smash). Clamp to +/-1e9 (<=10 digits) and drop non-finite values. + static inline float displaySafeFloat(float v) + { + if (!std::isfinite(v)) + return 0.0f; + return v < -1e9f ? -1e9f : (v > 1e9f ? 1e9f : v); + } }; diff --git a/src/modules/TraceRouteModule.cpp b/src/modules/TraceRouteModule.cpp index f3ff9f8b5..11019dcf9 100644 --- a/src/modules/TraceRouteModule.cpp +++ b/src/modules/TraceRouteModule.cpp @@ -452,7 +452,7 @@ void TraceRouteModule::printRoute(meshtastic_RouteDiscovery *r, uint32_t origin, // If there's a route back (or we are the destination as then the route is complete), print it if (r->route_back_count > 0 || origin == nodeDB->getNodeNum()) { route += "\n"; - if (r->snr_towards_count > 0 && origin == nodeDB->getNodeNum()) + if (origin == nodeDB->getNodeNum() && r->snr_back_count > 0 && r->snr_back[r->snr_back_count - 1] != INT8_MIN) route += vformat("(%.2fdB) 0x%x <-- ", (float)r->snr_back[r->snr_back_count - 1] / 4, origin); else route += "..."; diff --git a/src/modules/WaypointModule.cpp b/src/modules/WaypointModule.cpp index 8eaf2c5c6..9b41a9f5a 100644 --- a/src/modules/WaypointModule.cpp +++ b/src/modules/WaypointModule.cpp @@ -4,6 +4,7 @@ #include "configuration.h" #include "graphics/SharedUIDisplay.h" #include "graphics/draw/CompassRenderer.h" +#include "meshUtils.h" #if HAS_SCREEN #include "gps/RTC.h" @@ -92,6 +93,10 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, return; } + // Sanitize before these reach the OLED renderer (defense-in-depth vs PB_VALIDATE_UTF8). + sanitizeUtf8(wp.name, sizeof(wp.name)); + sanitizeUtf8(wp.description, sizeof(wp.description)); + // Get timestamp info. Will pass as a field to drawColumns char lastStr[20]; getTimeAgoStr(sinceReceived(&mp), lastStr, sizeof(lastStr)); diff --git a/test/native-suite-count b/test/native-suite-count index 7273c0fa8..f64f5d8d8 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -25 +27 diff --git a/test/test_fuzz_decode/test_main.cpp b/test/test_fuzz_decode/test_main.cpp new file mode 100644 index 000000000..84b1d0315 --- /dev/null +++ b/test/test_fuzz_decode/test_main.cpp @@ -0,0 +1,364 @@ +// Adversarial fuzzing of the protobuf decoders and the UTF-8 sanitizer - the "crash a node with a +// crafted packet" and "character-encoding crash" attack surface. +// +// These are the FIXTURE-FREE fuzzers: they exercise pure functions (pb_decode_from_bytes and the +// meshUtils UTF-8 helpers) with no NodeDB / channel / crypto bring-up. The heavier packet-path +// fuzzers live in test/test_fuzz_packets. +// +// The suite runs under the default `coverage` env (AddressSanitizer + LeakSanitizer); any +// out-of-bounds read/write, use-after-free, or leak on adversarial input turns the run RED. Inputs +// come from a deterministic seeded LCG so a failure always reproduces from the printed seed. +// +// Group D1 protobuf decode fuzz - every mesh-facing message type, random + protobuf-shaped bytes +// Group D2 UTF-8 sanitizer fuzz - sanitizeUtf8 / clampLongName / pb_string_length +// +// Note on PB_VALIDATE_UTF8: the global build flag makes nanopb reject a malformed-UTF-8 `string` +// field at decode, so decode of e.g. a User/Waypoint with a bad name returns *false*. That is a +// PASS here - the contract under test is crash-freedom, not decode success. + +#include "MeshTypes.h" // include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#include "mesh-pb-constants.h" +#include "mesh/generated/meshtastic/admin.pb.h" +#include "mesh/generated/meshtastic/mesh.pb.h" +#include "mesh/generated/meshtastic/storeforward.pb.h" +#include "mesh/generated/meshtastic/telemetry.pb.h" +#include "meshUtils.h" +#include +#include +#include + +// --------------------------------------------------------------------------- +// Deterministic RNG - seeded 64-bit LCG (Knuth MMIX constants). No rand()/time so a failing +// iteration reproduces exactly from the printed base seed. +// --------------------------------------------------------------------------- +static uint64_t g_rng = 0; +static void rngSeed(uint64_t s) +{ + g_rng = s ? s : 0x9E3779B97F4A7C15ULL; +} +static uint32_t rngNext() +{ + g_rng = g_rng * 6364136223846793005ULL + 1442695040888963407ULL; + return (uint32_t)(g_rng >> 32); +} +static uint8_t rngByte() +{ + return (uint8_t)(rngNext() & 0xFF); +} +static uint32_t rngRange(uint32_t n) // uniform-ish in [0, n) +{ + return n ? (rngNext() % n) : 0; +} + +static constexpr uint64_t BASE_SEED = 0x00C0FFEEULL; +static constexpr unsigned DECODE_ITERS = 3000; // per type, per pass (ASan-instrumented, keep bounded) + +// --------------------------------------------------------------------------- +// Group D1 - protobuf decode fuzz +// --------------------------------------------------------------------------- + +// Union so one buffer holds any decoded type with correct size/alignment; pb_release walks it with +// the matching descriptor after each iteration (a no-op for STATIC-only messages, but correct if a +// build ever compiles a malloc-backed field via PB_ENABLE_MALLOC). +union AnyMsg { + meshtastic_Data data; + meshtastic_MeshPacket meshPacket; + meshtastic_User user; + meshtastic_Position position; + meshtastic_Telemetry telemetry; + meshtastic_RouteDiscovery routeDiscovery; + meshtastic_Waypoint waypoint; + meshtastic_NeighborInfo neighborInfo; + meshtastic_Routing routing; + meshtastic_AdminMessage adminMessage; + meshtastic_StoreAndForward storeAndForward; +}; + +struct FuzzType { + const char *name; + const pb_msgdesc_t *fields; +}; + +static const FuzzType FUZZ_TYPES[] = { + {"Data", &meshtastic_Data_msg}, + {"MeshPacket", &meshtastic_MeshPacket_msg}, + {"User", &meshtastic_User_msg}, + {"Position", &meshtastic_Position_msg}, + {"Telemetry", &meshtastic_Telemetry_msg}, + {"RouteDiscovery", &meshtastic_RouteDiscovery_msg}, + {"Waypoint", &meshtastic_Waypoint_msg}, + {"NeighborInfo", &meshtastic_NeighborInfo_msg}, + {"Routing", &meshtastic_Routing_msg}, + {"AdminMessage", &meshtastic_AdminMessage_msg}, + {"StoreAndForward", &meshtastic_StoreAndForward_msg}, +}; +static const size_t NUM_FUZZ_TYPES = sizeof(FUZZ_TYPES) / sizeof(FUZZ_TYPES[0]); + +static size_t writeVarint(uint8_t *buf, size_t cap, size_t n, uint64_t v) +{ + do { + if (n >= cap) + break; + uint8_t byte = v & 0x7F; + v >>= 7; + if (v) + byte |= 0x80; + buf[n++] = byte; + } while (v); + return n; +} + +// Generate protobuf-*shaped* noise: a run of (tag, payload) pairs with valid and invalid wire types. +// Reaches decoder states (submessage length prefixes, packed fields, bad wire types) that pure random +// bytes rarely hit, so it stresses the parser far deeper than raw noise alone. +static size_t genProtoish(uint8_t *buf, size_t cap) +{ + size_t n = 0; + int fields = (int)rngRange(14); + for (int f = 0; f < fields && n + 32 < cap; f++) { + uint32_t fieldnum = 1 + rngRange(48); + uint32_t wire = rngRange(8); // 0,1,2,5 are valid; 3,4,6,7 exercise wire-type rejection + uint32_t tag = (fieldnum << 3) | (wire & 7); + n = writeVarint(buf, cap, n, tag); + switch (wire & 7) { + case 0: // varint + n = writeVarint(buf, cap, n, ((uint64_t)rngNext() << 32) | rngNext()); + break; + case 1: // 64-bit + for (int i = 0; i < 8 && n < cap; i++) + buf[n++] = rngByte(); + break; + case 2: { // length-delimited (string/bytes/submessage) - random and sometimes lying length + uint32_t L = rngRange(24); + n = writeVarint(buf, cap, n, L); + for (uint32_t i = 0; i < L && n < cap; i++) + buf[n++] = rngByte(); + break; + } + case 5: // 32-bit + for (int i = 0; i < 4 && n < cap; i++) + buf[n++] = rngByte(); + break; + default: // invalid wire type - decoder must reject cleanly + break; + } + } + return n; +} + +// Feed every type `iters` random buffers; the only contract is crash-freedom / ASan-clean. +static void decodeFuzzPass(bool protoish, uint64_t seed) +{ + rngSeed(seed); + AnyMsg out; + uint8_t buf[512]; + unsigned long total = 0; + + for (size_t ti = 0; ti < NUM_FUZZ_TYPES; ti++) { + for (unsigned k = 0; k < DECODE_ITERS; k++) { + size_t len; + if (protoish) { + len = genProtoish(buf, sizeof(buf)); + } else { + len = rngRange(sizeof(buf) + 1); + for (size_t i = 0; i < len; i++) + buf[i] = rngByte(); + } + memset(&out, 0, sizeof(out)); + // Return value intentionally ignored: true or false are both acceptable. What must never + // happen is an out-of-bounds access, and ASan is watching for exactly that. + (void)pb_decode_from_bytes(buf, len, FUZZ_TYPES[ti].fields, &out); + pb_release(FUZZ_TYPES[ti].fields, &out); + total++; + } + } + // Reaching here means no ASan fault fired across every iteration. + TEST_ASSERT_EQUAL_UINT32((uint32_t)(NUM_FUZZ_TYPES * DECODE_ITERS), total); +} + +void test_D1a_decode_fuzz_random(void) +{ + printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0x1111)); + decodeFuzzPass(/*protoish=*/false, BASE_SEED ^ 0x1111); +} + +void test_D1b_decode_fuzz_protobuf_shaped(void) +{ + printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0x2222)); + decodeFuzzPass(/*protoish=*/true, BASE_SEED ^ 0x2222); +} + +// --------------------------------------------------------------------------- +// Group D2 - UTF-8 sanitizer fuzz +// --------------------------------------------------------------------------- + +// Independent strict UTF-8 validator - deliberately NOT sanitizeUtf8, so a bug in sanitizeUtf8 can't +// mask itself. Validates [s, first NUL) exactly as a strict decoder would (rejects overlong, +// surrogates, > U+10FFFF, truncated, stray continuation/lead bytes). +static bool isValidUtf8(const char *s) +{ + const uint8_t *p = (const uint8_t *)s; + while (*p) { + uint8_t c = *p; + int seqLen; + uint32_t cp, minCp; + if (c < 0x80) { + p++; + continue; + } else if ((c & 0xE0) == 0xC0) { + seqLen = 2; + cp = c & 0x1F; + minCp = 0x80; + } else if ((c & 0xF0) == 0xE0) { + seqLen = 3; + cp = c & 0x0F; + minCp = 0x800; + } else if ((c & 0xF8) == 0xF0) { + seqLen = 4; + cp = c & 0x07; + minCp = 0x10000; + } else { + return false; // invalid lead (continuation byte as lead, or 0xF8+) + } + for (int i = 1; i < seqLen; i++) { + if ((p[i] & 0xC0) != 0x80) // truncated / bad continuation (embedded NUL ends the loop above) + return false; + cp = (cp << 6) | (p[i] & 0x3F); + } + if (cp < minCp || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) + return false; + p += seqLen; + } + return true; +} + +// Run the full sanitizeUtf8 contract against a raw buffer of size `cap`. +static void assertSanitizeContract(char *buf, size_t cap) +{ + char before[512]; + TEST_ASSERT_TRUE(cap <= sizeof(before)); + + sanitizeUtf8(buf, cap); + + // 1. Always NUL-terminated within the buffer. + TEST_ASSERT_EQUAL_UINT8_MESSAGE(0, (uint8_t)buf[cap - 1], "sanitizeUtf8 must force a trailing NUL"); + // 2. Length never exceeds bufSize-1. + TEST_ASSERT_TRUE_MESSAGE(strlen(buf) <= cap - 1, "sanitized string overran the buffer"); + // 3. Output re-validates as UTF-8 by an independent validator. + TEST_ASSERT_TRUE_MESSAGE(isValidUtf8(buf), "sanitizeUtf8 left invalid UTF-8 behind"); + + // 4. Idempotent: a second pass changes nothing and reports no replacement. + memcpy(before, buf, cap); + bool replacedAgain = sanitizeUtf8(buf, cap); + TEST_ASSERT_FALSE_MESSAGE(replacedAgain, "sanitizeUtf8 is not idempotent"); + TEST_ASSERT_EQUAL_MEMORY_MESSAGE(before, buf, cap, "second sanitize mutated an already-clean buffer"); +} + +// Every single byte value as a 1-char "string" in a 2-byte buffer. +void test_D2a_utf8_exhaustive_single_byte(void) +{ + for (int b = 0; b < 256; b++) { + char buf[2] = {(char)b, (char)b}; // deliberately not NUL-terminated + assertSanitizeContract(buf, sizeof(buf)); + } +} + +// Every 2-byte lead+continuation pair (covers overlong C0/C1, valid, and bad continuations). +void test_D2b_utf8_exhaustive_two_byte(void) +{ + for (int lead = 0xC0; lead <= 0xFF; lead++) { + for (int cont = 0x00; cont <= 0xFF; cont++) { + char buf[4] = {(char)lead, (char)cont, (char)lead, (char)cont}; + assertSanitizeContract(buf, sizeof(buf)); + } + } +} + +// Tiny buffers (cap 1..4) exercise the truncated-sequence-at-end paths. +void test_D2c_utf8_tiny_buffers(void) +{ + rngSeed(BASE_SEED ^ 0x3333); + for (size_t cap = 1; cap <= 4; cap++) { + for (unsigned k = 0; k < 4000; k++) { + char buf[8]; + for (size_t i = 0; i < cap; i++) + buf[i] = (char)rngByte(); + assertSanitizeContract(buf, cap); + } + } +} + +// Randomized buffers of random size, biased toward high bytes to stress multibyte paths. +void test_D2d_utf8_random(void) +{ + printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0x4444)); + rngSeed(BASE_SEED ^ 0x4444); + for (unsigned k = 0; k < 40000; k++) { + char buf[128]; + size_t cap = 1 + rngRange(sizeof(buf)); + for (size_t i = 0; i < cap; i++) { + // ~60% high bytes so multibyte lead/continuation logic gets hammered. + buf[i] = (rngRange(100) < 60) ? (char)(0x80 + rngRange(0x80)) : (char)rngByte(); + } + assertSanitizeContract(buf, cap); + } +} + +// clampLongName: a 25-byte buffer with random content and emoji straddling the 24-byte cut. +void test_D2e_clamp_long_name(void) +{ + rngSeed(BASE_SEED ^ 0x5555); + for (unsigned k = 0; k < 20000; k++) { + char buf[MAX_LONG_NAME_BYTES + 1 + 8]; // extra slack; clampLongName only touches [0, 24] + for (size_t i = 0; i < sizeof(buf); i++) + buf[i] = (char)rngByte(); + clampLongName(buf); + TEST_ASSERT_TRUE_MESSAGE(strlen(buf) <= MAX_LONG_NAME_BYTES, "clampLongName exceeded the byte cap"); + TEST_ASSERT_TRUE_MESSAGE(isValidUtf8(buf), "clampLongName left invalid UTF-8"); + } +} + +// pb_string_length: never over-reads, result is a valid content length within max_len. +void test_D2f_pb_string_length(void) +{ + rngSeed(BASE_SEED ^ 0x6666); + for (unsigned k = 0; k < 20000; k++) { + uint8_t buf[64]; + size_t maxLen = 1 + rngRange(sizeof(buf)); + for (size_t i = 0; i < maxLen; i++) + buf[i] = rngByte(); + size_t len = pb_string_length((const char *)buf, maxLen); + TEST_ASSERT_TRUE_MESSAGE(len <= maxLen, "pb_string_length returned > max_len"); + if (len > 0) + TEST_ASSERT_NOT_EQUAL_MESSAGE(0, buf[len - 1], "pb_string_length pointed past the last content byte"); + } +} + +// --------------------------------------------------------------------------- +void setUp(void) {} +void tearDown(void) {} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== Group D1: protobuf decode fuzz ===\n"); + RUN_TEST(test_D1a_decode_fuzz_random); + RUN_TEST(test_D1b_decode_fuzz_protobuf_shaped); + + printf("\n=== Group D2: UTF-8 sanitizer fuzz ===\n"); + RUN_TEST(test_D2a_utf8_exhaustive_single_byte); + RUN_TEST(test_D2b_utf8_exhaustive_two_byte); + RUN_TEST(test_D2c_utf8_tiny_buffers); + RUN_TEST(test_D2d_utf8_random); + RUN_TEST(test_D2e_clamp_long_name); + RUN_TEST(test_D2f_pb_string_length); + + exit(UNITY_END()); +} + +void loop() {} diff --git a/test/test_fuzz_packets/test_main.cpp b/test/test_fuzz_packets/test_main.cpp new file mode 100644 index 000000000..c38f9ef58 --- /dev/null +++ b/test/test_fuzz_packets/test_main.cpp @@ -0,0 +1,405 @@ +// Adversarial fuzzing of the packet path - the decrypt/decode/dispatch pipeline that a crafted RF +// packet flows through, plus the phone-forward validation gate. +// +// Unlike test/test_fuzz_decode (pure functions, no fixture) these drive real firmware machinery: +// Router::perhapsDecode over a configured channel, module handlers, the traceroute route-processing +// functions, and MeshService's phone-delivery gate. It reuses the MockNodeDB + channel bring-up from +// test/test_packet_signing. Runs under the `coverage` env (AddressSanitizer + LeakSanitizer); any +// out-of-bounds access or leak on adversarial input turns the run RED. Inputs are driven by a seeded +// LCG so failures reproduce from the printed seed. +// +// Group E1 encrypted RX -> perhapsDecode over arbitrary ciphertext +// Group E2 NodeInfoModule handler over arbitrary User structs +// Group E3 TraceRoute route-processing over adversarial RouteDiscovery (route/snr count edges) +// Group E4 MeshService phone-forward gate (nested-string validation) + +#include "MeshTypes.h" // include BEFORE TestUtil.h +#include "TestUtil.h" +#include + +#if !(MESHTASTIC_EXCLUDE_PKI) + +#include "mesh-pb-constants.h" +#include "mesh/Channels.h" +#include "mesh/CryptoEngine.h" +#include "mesh/MeshService.h" +#include "mesh/NodeDB.h" +#include "mesh/Router.h" +#include "modules/NodeInfoModule.h" +#include "modules/TraceRouteModule.h" +#include +#include +#include +#include + +static constexpr NodeNum LOCAL_NODE = 0x0A0A0A0A; +static constexpr NodeNum REMOTE_NODE = 0x0B0B0B0B; + +// --------------------------------------------------------------------------- +// Deterministic RNG (seeded LCG) +// --------------------------------------------------------------------------- +static uint64_t g_rng = 0; +static void rngSeed(uint64_t s) +{ + g_rng = s ? s : 0x9E3779B97F4A7C15ULL; +} +static uint32_t rngNext() +{ + g_rng = g_rng * 6364136223846793005ULL + 1442695040888963407ULL; + return (uint32_t)(g_rng >> 32); +} +static uint8_t rngByte() +{ + return (uint8_t)(rngNext() & 0xFF); +} +static uint32_t rngRange(uint32_t n) +{ + return n ? (rngNext() % n) : 0; +} +static constexpr uint64_t BASE_SEED = 0x00BADF00DULL; + +// --------------------------------------------------------------------------- +// MockNodeDB - mirror of test/test_packet_signing so we can inject nodes. +// --------------------------------------------------------------------------- +class MockNodeDB : public NodeDB +{ + public: + void clearTestNodes() + { + testNodes.clear(); + meshNodes = &testNodes; + numMeshNodes = 0; + } + void addNode(NodeNum num) + { + meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; + node.num = num; + testNodes.push_back(node); + meshNodes = &testNodes; + numMeshNodes = testNodes.size(); + } + void setPublicKey(NodeNum num, const uint8_t *pubKey) + { + meshtastic_NodeInfoLite *n = getMeshNode(num); + TEST_ASSERT_NOT_NULL(n); + n->public_key.size = 32; + memcpy(n->public_key.bytes, pubKey, 32); + } + void setSignerBit(NodeNum num, bool value) + { + meshtastic_NodeInfoLite *n = getMeshNode(num); + TEST_ASSERT_NOT_NULL(n); + nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, value); + } + std::vector testNodes; +}; + +static MockNodeDB *mockNodeDB = nullptr; + +void setUp(void) +{ + config = meshtastic_LocalConfig_init_zero; + owner = meshtastic_User_init_zero; + + mockNodeDB = new MockNodeDB(); + mockNodeDB->clearTestNodes(); + nodeDB = mockNodeDB; + myNodeInfo.my_node_num = LOCAL_NODE; + + channels.initDefaults(); + channels.onConfigChanged(); +} + +void tearDown(void) +{ + delete mockNodeDB; + mockNodeDB = nullptr; + nodeDB = nullptr; +} + +// =========================================================================== +// Group E1 - encrypted RX -> perhapsDecode over arbitrary ciphertext +// =========================================================================== +void test_E1_perhaps_decode_fuzz(void) +{ + printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE1)); + rngSeed(BASE_SEED ^ 0xE1); + + // A known signer with a stored key so the verify/downgrade policy branches get exercised whenever + // random ciphertext happens to decrypt into a plausible signed Data. + uint8_t pub[32], priv[32]; + crypto->generateKeyPair(pub, priv); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->setPublicKey(REMOTE_NODE, pub); + mockNodeDB->setSignerBit(REMOTE_NODE, true); + + const NodeNum targets[] = {NODENUM_BROADCAST, LOCAL_NODE, REMOTE_NODE, 0x12345678}; + + for (unsigned k = 0; k < 8000; k++) { + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = REMOTE_NODE; + p.to = targets[rngRange(4)]; + p.id = rngNext(); + p.channel = (uint8_t)rngByte(); // channel-hash hint; often no match -> decrypt tries each channel + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.pki_encrypted = (rngRange(2) == 0); + + size_t n = rngRange(sizeof(p.encrypted.bytes) + 1); // 0..256, always in bounds + for (size_t i = 0; i < n; i++) + p.encrypted.bytes[i] = rngByte(); + p.encrypted.size = n; + + DecodeState st = perhapsDecode(&p); + // Any verdict is fine; the contract is that arbitrary ciphertext never crashes the pipeline. + TEST_ASSERT_TRUE(st == DECODE_SUCCESS || st == DECODE_FAILURE || st == DECODE_FATAL); + } +} + +// =========================================================================== +// Group E2 - NodeInfoModule handler over arbitrary User structs +// =========================================================================== +class NodeInfoTestShim : public NodeInfoModule +{ + public: + using NodeInfoModule::handleReceivedProtobuf; +}; + +// Fill a User with adversarial content: char fields random, sometimes left un-terminated to test that +// downstream copies stay bounded; byte fields random-sized. +static meshtastic_User fuzzUser() +{ + meshtastic_User u = meshtastic_User_init_zero; + auto fillStr = [](char *s, size_t cap) { + size_t n = rngRange(cap + 4); // may exceed cap: exercises the un-terminated path + for (size_t i = 0; i < cap; i++) + s[i] = (i < n) ? (char)rngByte() : '\0'; + if (rngRange(2)) // half the time force a stray non-NUL in the last slot + s[cap - 1] = (char)(0x80 + rngRange(0x80)); + }; + fillStr(u.id, sizeof(u.id)); + fillStr(u.long_name, sizeof(u.long_name)); + fillStr(u.short_name, sizeof(u.short_name)); + u.hw_model = (meshtastic_HardwareModel)rngRange(256); + u.role = (meshtastic_Config_DeviceConfig_Role)rngRange(16); + u.public_key.size = rngRange(33); // 0..32 + for (size_t i = 0; i < u.public_key.size && i < sizeof(u.public_key.bytes); i++) + u.public_key.bytes[i] = rngByte(); + return u; +} + +void test_E2_nodeinfo_handler_fuzz(void) +{ + printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE2)); + rngSeed(BASE_SEED ^ 0xE2); + + // Function-local static: NodeInfoModule derives from OSThread, whose ctor registers `this` in a + // global ThreadController and whose dtor deregisters it. A stack local would be freed on return - + // and Unity's TEST_* macros exit via longjmp, skipping C++ dtors entirely - leaving a dangling + // pointer that a later OSThread ctor trips over (ASan stack-use-after-return). A static lives for + // the whole process, so its registration stays valid regardless of longjmp. + static NodeInfoTestShim shim; + mockNodeDB->addNode(REMOTE_NODE); // non-signer: handler falls through to updateUser() + + for (unsigned k = 0; k < 6000; k++) { + // Clear any key stored last iteration so updateUser() keeps reaching the string-copy path + // (CopyUserToNodeInfoLite) instead of early-returning on a key mismatch. + meshtastic_NodeInfoLite *rn = mockNodeDB->getMeshNode(REMOTE_NODE); + if (rn) + rn->public_key.size = 0; + + // Broadcast so the want_response / service-dependent reply path is skipped. + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = REMOTE_NODE; + mp.to = NODENUM_BROADCAST; + mp.id = rngNext(); + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.decoded.portnum = meshtastic_PortNum_NODEINFO_APP; + mp.xeddsa_signed = (rngRange(2) == 0); + + meshtastic_User u = fuzzUser(); + // Contract: never crashes and never corrupts NodeDB regardless of the User contents. + (void)shim.handleReceivedProtobuf(mp, &u); + } + // NodeDB survived the fuzzing intact (the node is still present, not corrupted away). + TEST_ASSERT_NOT_NULL(mockNodeDB->getMeshNode(REMOTE_NODE)); +} + +// =========================================================================== +// Group E3 - TraceRoute route-processing over adversarial RouteDiscovery +// =========================================================================== +static const pb_size_t ROUTE_MAX = ROUTE_SIZE; // 8 + +// Build a RouteDiscovery with caller-chosen (bounded) counts, filled with arbitrary node numbers. +static meshtastic_RouteDiscovery makeRoute(pb_size_t rc, pb_size_t st, pb_size_t rbc, pb_size_t sb) +{ + meshtastic_RouteDiscovery r = meshtastic_RouteDiscovery_init_zero; + r.route_count = rc; + for (pb_size_t i = 0; i < rc; i++) + r.route[i] = rngNext(); + r.snr_towards_count = st; + for (pb_size_t i = 0; i < st; i++) + r.snr_towards[i] = (int8_t)rngByte(); + r.route_back_count = rbc; + for (pb_size_t i = 0; i < rbc; i++) + r.route_back[i] = rngNext(); + r.snr_back_count = sb; + for (pb_size_t i = 0; i < sb; i++) + r.snr_back[i] = (int8_t)rngByte(); + return r; +} + +void test_E3_traceroute_route_processing_fuzz(void) +{ + printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE3)); + rngSeed(BASE_SEED ^ 0xE3); + + static TraceRouteModule tr; // static: OSThread-derived, see the note in test_E2 (ThreadController lifetime) + + for (unsigned k = 0; k < 6000; k++) { + // Mostly valid RouteDiscovery encodings, with adversarial count combinations (incl. the + // snr_back_count==0 / snr_towards_count>0 shape behind the printRoute guard fix); plus a + // fraction of pure-random payloads that must fail decode cleanly. + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = (rngRange(2)) ? LOCAL_NODE : REMOTE_NODE; // origin==us drives the printRoute back-path + mp.to = (rngRange(2)) ? LOCAL_NODE : REMOTE_NODE; + mp.id = rngNext(); + mp.rx_snr = (float)((int)rngRange(40) - 20); + mp.hop_start = (uint8_t)rngRange(8); + mp.hop_limit = (uint8_t)rngRange(8); + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.decoded.portnum = meshtastic_PortNum_TRACEROUTE_APP; + mp.decoded.want_response = (rngRange(2) == 0); + mp.decoded.request_id = (rngRange(2) == 0) ? 0 : rngNext(); + + if (rngRange(5) == 0) { + // Pure-random payload: processUpgradedPacket must reject it at decode and not crash. + size_t n = rngRange(sizeof(mp.decoded.payload.bytes) + 1); + for (size_t i = 0; i < n; i++) + mp.decoded.payload.bytes[i] = rngByte(); + mp.decoded.payload.size = n; + } else { + meshtastic_RouteDiscovery r = + makeRoute(rngRange(ROUTE_MAX + 1), rngRange(ROUTE_MAX + 1), rngRange(ROUTE_MAX + 1), rngRange(ROUTE_MAX + 1)); + mp.decoded.payload.size = pb_encode_to_bytes(mp.decoded.payload.bytes, sizeof(mp.decoded.payload.bytes), + &meshtastic_RouteDiscovery_msg, &r); + } + + tr.processUpgradedPacket(mp); // decode -> alterReceivedProtobuf (insert/append/printRoute) + + // After processing, the re-encoded route arrays must still be within bounds. + meshtastic_RouteDiscovery out = meshtastic_RouteDiscovery_init_zero; + if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_RouteDiscovery_msg, &out)) { + TEST_ASSERT_TRUE_MESSAGE(out.route_count <= ROUTE_MAX, "route_count overran ROUTE_SIZE"); + TEST_ASSERT_TRUE_MESSAGE(out.route_back_count <= ROUTE_MAX, "route_back_count overran ROUTE_SIZE"); + TEST_ASSERT_TRUE_MESSAGE(out.snr_towards_count <= ROUTE_MAX, "snr_towards_count overran ROUTE_SIZE"); + TEST_ASSERT_TRUE_MESSAGE(out.snr_back_count <= ROUTE_MAX, "snr_back_count overran ROUTE_SIZE"); + } + } +} + +// =========================================================================== +// Group E4 - MeshService phone-forward gate +// =========================================================================== +static meshtastic_Data makeData(meshtastic_PortNum port, const uint8_t *bytes, size_t n) +{ + meshtastic_Data d = meshtastic_Data_init_zero; + TEST_ASSERT_TRUE_MESSAGE(n <= sizeof(d.payload.bytes), "payload exceeds meshtastic_Data capacity"); + if (n > 0) + TEST_ASSERT_NOT_NULL(bytes); + d.portnum = port; + d.payload.size = n; + for (size_t i = 0; i < n; i++) + d.payload.bytes[i] = bytes[i]; + return d; +} + +void test_E4_phone_forward_gate(void) +{ + // Waypoint.name is field 6 (tag 0x32); User.long_name is field 2 (tag 0x12); both STRING, so + // PB_VALIDATE_UTF8 rejects invalid UTF-8 content on decode. + const uint8_t badWaypoint[] = {0x32, 0x01, 0xFF}; // name = single invalid lead byte + const uint8_t goodWaypoint[] = {0x32, 0x02, 'O', 'K'}; // name = "OK" + const uint8_t badUser[] = {0x12, 0x01, 0xFF}; // long_name = invalid + const uint8_t goodUser[] = {0x12, 0x02, 'O', 'K'}; // long_name = "OK" + const uint8_t arbitrary[] = {0xFF, 0xFE, 0x00, 0x80}; + + meshtastic_Data d; + + d = makeData(meshtastic_PortNum_WAYPOINT_APP, badWaypoint, sizeof(badWaypoint)); + TEST_ASSERT_FALSE_MESSAGE(MeshService::phonePayloadIsDecodable(d), "invalid-UTF8 waypoint must be withheld"); + + d = makeData(meshtastic_PortNum_WAYPOINT_APP, goodWaypoint, sizeof(goodWaypoint)); + TEST_ASSERT_TRUE_MESSAGE(MeshService::phonePayloadIsDecodable(d), "valid waypoint must pass"); + + d = makeData(meshtastic_PortNum_NODEINFO_APP, badUser, sizeof(badUser)); + TEST_ASSERT_FALSE_MESSAGE(MeshService::phonePayloadIsDecodable(d), "invalid-UTF8 nodeinfo must be withheld"); + + d = makeData(meshtastic_PortNum_NODEINFO_APP, goodUser, sizeof(goodUser)); + TEST_ASSERT_TRUE_MESSAGE(MeshService::phonePayloadIsDecodable(d), "valid nodeinfo must pass"); + + // Empty payloads decode to all-defaults -> pass. + d = makeData(meshtastic_PortNum_WAYPOINT_APP, nullptr, 0); + TEST_ASSERT_TRUE(MeshService::phonePayloadIsDecodable(d)); + + // Non-gated portnums always pass through, even with arbitrary bytes (text is opaque `bytes`). + d = makeData(meshtastic_PortNum_TEXT_MESSAGE_APP, arbitrary, sizeof(arbitrary)); + TEST_ASSERT_TRUE_MESSAGE(MeshService::phonePayloadIsDecodable(d), "text payload must not be gated"); + + // Fuzz: for gated portnums the gate verdict must exactly match a raw decode, and never crash. + rngSeed(BASE_SEED ^ 0xE4); + for (unsigned k = 0; k < 8000; k++) { + uint8_t buf[64]; + size_t n = rngRange(sizeof(buf) + 1); + for (size_t i = 0; i < n; i++) + buf[i] = rngByte(); + meshtastic_PortNum port = (rngRange(2)) ? meshtastic_PortNum_WAYPOINT_APP : meshtastic_PortNum_NODEINFO_APP; + d = makeData(port, buf, n); + + bool gate = MeshService::phonePayloadIsDecodable(d); + bool raw; + if (port == meshtastic_PortNum_WAYPOINT_APP) { + meshtastic_Waypoint w = meshtastic_Waypoint_init_zero; + raw = pb_decode_from_bytes(d.payload.bytes, d.payload.size, &meshtastic_Waypoint_msg, &w); + } else { + meshtastic_User u = meshtastic_User_init_zero; + raw = pb_decode_from_bytes(d.payload.bytes, d.payload.size, &meshtastic_User_msg, &u); + } + TEST_ASSERT_EQUAL_MESSAGE(raw, gate, "gate verdict must match nested decode"); + } +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== Group E1: perhapsDecode ciphertext fuzz ===\n"); + RUN_TEST(test_E1_perhaps_decode_fuzz); + + printf("\n=== Group E2: NodeInfo handler fuzz ===\n"); + RUN_TEST(test_E2_nodeinfo_handler_fuzz); + + printf("\n=== Group E3: TraceRoute route-processing fuzz ===\n"); + RUN_TEST(test_E3_traceroute_route_processing_fuzz); + + printf("\n=== Group E4: phone-forward gate ===\n"); + RUN_TEST(test_E4_phone_forward_gate); + + exit(UNITY_END()); +} + +void loop() {} + +#else // MESHTASTIC_EXCLUDE_PKI + +void setUp(void) {} +void tearDown(void) {} +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} +void loop() {} + +#endif diff --git a/test/test_position_precision/test_main.cpp b/test/test_position_precision/test_main.cpp index f7727e0d0..fbb937952 100644 --- a/test/test_position_precision/test_main.cpp +++ b/test/test_position_precision/test_main.cpp @@ -1,7 +1,9 @@ #include "Channels.h" +#include "GeoCoord.h" #include "PositionPrecision.h" #include "TestUtil.h" #include "mesh-pb-constants.h" +#include #include #include @@ -207,6 +209,26 @@ static void test_cryptoKeyIsPublic_invalidKeyIsNotPublic() TEST_ASSERT_FALSE(cryptoKeyIsPublic(makeCryptoKey(nullptr, -1))); } +// Regression for out-of-bounds indexing in GeoCoord's UTM/MGRS conversion on extreme +// latitude_i/longitude_i that arrive in a received Position (raw int32, unvalidated on decode). +// Pre-fix, latitude_i = INT32_MAX made latLongToUTM read latBands[36] on a 21-char string +// (stack-buffer-overflow at GeoCoord.cpp:128, an AddressSanitizer abort); extreme longitude produced +// a negative UTM zone feeding the MGRS letter tables. The fix clamps the zone/band/col/row indices. +// This exercises the fix under the coverage env's ASan. +static void test_geocoord_extreme_coords_no_oob() +{ + const int32_t vals[] = {INT32_MIN, INT32_MAX, INT32_MIN + 1, INT32_MAX - 1, 0, 1, -1, 900000000, -900000000, // +/-90 deg + 1800000000, -1800000000, // +/-180 deg + 2000000000, -2000000000, 123456789, -123456789}; + const size_t n = sizeof(vals) / sizeof(vals[0]); + for (size_t i = 0; i < n; i++) + for (size_t j = 0; j < n; j++) { + GeoCoord g(vals[i], vals[j], 0); // ctor -> setCoords() -> UTM/MGRS/OSGR/OLC + // Surviving every extreme pair (no ASan fault) means the index clamps hold. + TEST_ASSERT_EQUAL_INT32(vals[i], g.getLatitude()); + } +} + void setUp(void) {} void tearDown(void) {} @@ -232,6 +254,7 @@ void setup() RUN_TEST(test_cryptoKeyIsPublic_strongKeyIsPrivate); RUN_TEST(test_cryptoKeyIsPublic_aes256KeyIsPrivate); RUN_TEST(test_cryptoKeyIsPublic_invalidKeyIsNotPublic); + RUN_TEST(test_geocoord_extreme_coords_no_oob); exit(UNITY_END()); } diff --git a/test/test_type_conversions/test_main.cpp b/test/test_type_conversions/test_main.cpp index 18063d5ea..6e63e4d54 100644 --- a/test/test_type_conversions/test_main.cpp +++ b/test/test_type_conversions/test_main.cpp @@ -14,6 +14,9 @@ #include "TypeConversions.h" #include "mesh-pb-constants.h" #include "meshUtils.h" +#include "modules/Telemetry/UnitConversions.h" +#include +#include #include #include #include @@ -411,6 +414,33 @@ void test_convert_to_node_info_user_only_when_has_user_bit_set(void) TEST_ASSERT_EQUAL_STRING("!00000001", info2.user.id); } +// Regression for UnitConversions::displaySafeFloat: drop non-finite values and clamp magnitude so a +// crafted telemetry float can't overflow Arduino String(float)'s fixed char[33]. +static void test_displaySafeFloat_bounds_and_finiteness() +{ + // Non-finite -> 0 + TEST_ASSERT_EQUAL_FLOAT(0.0f, UnitConversions::displaySafeFloat(NAN)); + TEST_ASSERT_EQUAL_FLOAT(0.0f, UnitConversions::displaySafeFloat(INFINITY)); + TEST_ASSERT_EQUAL_FLOAT(0.0f, UnitConversions::displaySafeFloat(-INFINITY)); + // Huge magnitudes -> clamped to +/-1e9 + TEST_ASSERT_EQUAL_FLOAT(1e9f, UnitConversions::displaySafeFloat(FLT_MAX)); + TEST_ASSERT_EQUAL_FLOAT(-1e9f, UnitConversions::displaySafeFloat(-FLT_MAX)); + TEST_ASSERT_EQUAL_FLOAT(1e9f, UnitConversions::displaySafeFloat(3.0e30f)); + // In-range values pass through unchanged + TEST_ASSERT_EQUAL_FLOAT(0.0f, UnitConversions::displaySafeFloat(0.0f)); + TEST_ASSERT_EQUAL_FLOAT(23.5f, UnitConversions::displaySafeFloat(23.5f)); + TEST_ASSERT_EQUAL_FLOAT(-40.0f, UnitConversions::displaySafeFloat(-40.0f)); + TEST_ASSERT_EQUAL_FLOAT(120000.0f, UnitConversions::displaySafeFloat(120000.0f)); + + // The clamped output, formatted the way telemetry does, must fit Arduino String(float)'s char[33]. + const float attackers[] = {FLT_MAX, -FLT_MAX, 3.0e30f, 1.0e9f, -1.0e9f}; + for (float v : attackers) { + char buf[33]; + int n = snprintf(buf, sizeof(buf), "%.2f", (double)UnitConversions::displaySafeFloat(v)); + TEST_ASSERT_TRUE_MESSAGE(n > 0 && n < (int)sizeof(buf), "clamped float would overflow String(float) char[33]"); + } +} + // ---------- entry point ------------------------------------------------------- void setup() @@ -446,6 +476,7 @@ void setup() RUN_TEST(test_convert_to_node_info_extracts_bitfield_bools); RUN_TEST(test_convert_to_node_info_extracts_bitfield_bools_none_set); RUN_TEST(test_convert_to_node_info_user_only_when_has_user_bit_set); + RUN_TEST(test_displaySafeFloat_bounds_and_finiteness); exit(UNITY_END()); }