Harden against crafted-packet crashes + adversarial fuzzing (#10862)

Audit and fuzzing of the RF-packet decode -> dispatch -> display/phone paths for
the "crash a node or phone with a crafted packet" surface, beyond the XEdDSA
authenticity work.

Crash fixes (reproduced under AddressSanitizer / UBSan):
- GeoCoord::latLongToUTM/latLongToMGRS read fixed letter tables out of bounds on
  extreme latitude_i/longitude_i from a received Position, and narrowed
  out-of-range easting/northing doubles to unsigned (float-cast-overflow UB).
  Clamp the UTM zone, the easting/northing narrowing, and the band/col/row
  indices. Regression: test_geocoord_extreme_coords_no_oob.
- EnvironmentTelemetry/AirQualityTelemetry render attacker floats via
  String(float), which on nRF52/RP2040/STM32/portduino formats into a fixed
  char[33] (dtostrf) and overflows near FLT_MAX. Clamp the rendered metrics via
  UnitConversions::displaySafeFloat (finite + magnitude <= 1e9), unit-tested in
  test_type_conversions.

Defense-in-depth + robustness:
- TraceRouteModule::printRoute: fix an snr_back[-1] OOB read (wrong count in the
  guard) and stop formatting the INT8_MIN "unknown SNR" sentinel as a dB value.
- WaypointModule/NodeDB: sanitize untrusted strings before the OLED renderer and
  the phone-facing ClientNotification (belt-and-suspenders vs PB_VALIDATE_UTF8).
- MeshService::sendToPhone: withhold NODEINFO/WAYPOINT packets whose nested string
  won't cleanly decode, protecting strict phone protobuf decoders without
  affecting mesh relay.

Tests: new test_fuzz_decode (protobuf decode + UTF-8 sanitizer fuzz) and
test_fuzz_packets (perhapsDecode / module-handler / traceroute / phone-gate fuzz),
all under AddressSanitizer; native-suite-count 25 -> 27. Full suite 515/515 green.
This commit is contained in:
Ben Meadors
2026-07-02 16:50:49 -05:00
committed by GitHub
co-authored by GitHub
parent 510e9796f9
commit b4dd76a4db
14 changed files with 945 additions and 20 deletions
+39 -14
View File
@@ -1,4 +1,14 @@
#include "GeoCoord.h"
#include <cmath>
// 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) *
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)));
(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;
}
+29
View File
@@ -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() &&
+5
View File
@@ -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<uint32_t> fromNumChanged;
+8 -2
View File
@@ -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;
@@ -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
@@ -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
+11
View File
@@ -1,5 +1,7 @@
#pragma once
#include <cmath>
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);
}
};
+1 -1
View File
@@ -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 += "...";
+5
View File
@@ -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));
+1 -1
View File
@@ -1 +1 @@
25
27
+364
View File
@@ -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 <unity.h>
#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 <cstdio>
#include <cstring>
#include <pb_decode.h>
// ---------------------------------------------------------------------------
// 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() {}
+405
View File
@@ -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 <unity.h>
#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 <cstdio>
#include <cstring>
#include <pb_decode.h>
#include <vector>
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<meshtastic_NodeInfoLite> 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
@@ -1,7 +1,9 @@
#include "Channels.h"
#include "GeoCoord.h"
#include "PositionPrecision.h"
#include "TestUtil.h"
#include "mesh-pb-constants.h"
#include <cstdint>
#include <cstring>
#include <unity.h>
@@ -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());
}
+31
View File
@@ -14,6 +14,9 @@
#include "TypeConversions.h"
#include "mesh-pb-constants.h"
#include "meshUtils.h"
#include "modules/Telemetry/UnitConversions.h"
#include <cfloat>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <unity.h>
@@ -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());
}