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:
+40
-15
@@ -1,4 +1,14 @@
|
|||||||
#include "GeoCoord.h"
|
#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()
|
GeoCoord::GeoCoord()
|
||||||
{
|
{
|
||||||
@@ -124,8 +134,13 @@ void GeoCoord::latLongToUTM(const double lat, const double lon, UTM &utm)
|
|||||||
{
|
{
|
||||||
|
|
||||||
const std::string latBands = "CDEFGHJKLMNPQRSTUVWXX";
|
const std::string latBands = "CDEFGHJKLMNPQRSTUVWXX";
|
||||||
utm.zone = int((lon + 180) / 6 + 1);
|
// A received Position carries raw int32 latitude_i/longitude_i with no range validation, so lat/lon
|
||||||
utm.band = latBands[int(lat / 8 + 10)];
|
// 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 a = 6378137; // WGS84 - equatorial radius
|
||||||
double k0 = 0.9996; // UTM point scale on the central meridian
|
double k0 = 0.9996; // UTM point scale on the central meridian
|
||||||
double eccSquared = 0.00669438; // eccentricity squared
|
double eccSquared = 0.00669438; // eccentricity squared
|
||||||
@@ -160,17 +175,22 @@ void GeoCoord::latLongToUTM(const double lat, const double lon, UTM &utm)
|
|||||||
sin(2 * latRad) +
|
sin(2 * latRad) +
|
||||||
(15 * eccSquared * eccSquared / 256 + 45 * eccSquared * eccSquared * eccSquared / 1024) * sin(4 * latRad) -
|
(15 * eccSquared * eccSquared / 256 + 45 * eccSquared * eccSquared * eccSquared / 1024) * sin(4 * latRad) -
|
||||||
(35 * eccSquared * eccSquared * eccSquared / 3072) * sin(6 * latRad));
|
(35 * eccSquared * eccSquared * eccSquared / 3072) * sin(6 * latRad));
|
||||||
utm.easting = (double)(k0 * N *
|
double eastingMeters =
|
||||||
(A + (1 - T + C) * pow(A, 3) / 6 +
|
k0 * N *
|
||||||
(5 - 18 * T + T * T + 72 * C - 58 * eccPrimeSquared) * A * A * A * A * A / 120) +
|
(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);
|
500000.0;
|
||||||
utm.northing =
|
double northingMeters =
|
||||||
(double)(k0 * (M + N * tan(latRad) *
|
k0 * (M + N * tan(latRad) *
|
||||||
(A * A / 2 + (5 - T + 9 * C + 4 * C * C) * A * A * A * A / 24 +
|
(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)
|
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.
|
// 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);
|
latLongToUTM(lat, lon, utm);
|
||||||
mgrs.zone = utm.zone;
|
mgrs.zone = utm.zone;
|
||||||
mgrs.band = utm.band;
|
mgrs.band = utm.band;
|
||||||
double col = floor(utm.easting / 100000);
|
// utm.zone is clamped to 1..60 above, but guard every index defensively: the column/row derived
|
||||||
mgrs.east100k = e100kLetters[(mgrs.zone - 1) % 3][col - 1];
|
// from easting/northing can fall outside the 100km-grid letter tables when lat/lon are extreme.
|
||||||
double row = (int32_t)floor(utm.northing / 100000.0) % 20;
|
int zoneIdx3 = ((mgrs.zone - 1) % 3 + 3) % 3;
|
||||||
mgrs.north100k = n100kLetters[(mgrs.zone - 1) % 2][row];
|
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.easting = (int32_t)utm.easting % 100000;
|
||||||
mgrs.northing = (int32_t)utm.northing % 100000;
|
mgrs.northing = (int32_t)utm.northing % 100000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,10 +302,39 @@ bool MeshService::trySendPosition(NodeNum dest, bool wantReplies)
|
|||||||
return false;
|
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)
|
void MeshService::sendToPhone(meshtastic_MeshPacket *p)
|
||||||
{
|
{
|
||||||
perhapsDecode(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
|
#ifdef ARCH_ESP32
|
||||||
#if !MESHTASTIC_EXCLUDE_STOREFORWARD
|
#if !MESHTASTIC_EXCLUDE_STOREFORWARD
|
||||||
if (moduleConfig.store_forward.enabled && storeForwardModule->isServer() &&
|
if (moduleConfig.store_forward.enabled && storeForwardModule->isServer() &&
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ class MeshService
|
|||||||
p->decoded.portnum == meshtastic_PortNum_DETECTION_SENSOR_APP ||
|
p->decoded.portnum == meshtastic_PortNum_DETECTION_SENSOR_APP ||
|
||||||
p->decoded.portnum == meshtastic_PortNum_ALERT_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
|
/// Called when some new packets have arrived from one of the radios
|
||||||
Observable<uint32_t> fromNumChanged;
|
Observable<uint32_t> fromNumChanged;
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -1024,7 +1024,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
|
|||||||
strncpy(config.network.ntp_server, "meshtastic.pool.ntp.org", 32);
|
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) || \
|
#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
|
HAS_TFT
|
||||||
// switch BT off by default; use TFT programming mode or hotkey to enable
|
// switch BT off by default; use TFT programming mode or hotkey to enable
|
||||||
config.bluetooth.enabled = false;
|
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 (owner.public_key.size == 32 && memcmp(p.public_key.bytes, owner.public_key.bytes, 32) == 0) {
|
||||||
if (!duplicateWarned) {
|
if (!duplicateWarned) {
|
||||||
duplicateWarned = true;
|
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[] =
|
char warning[] =
|
||||||
"Remote device %s has advertised your public key. This may indicate a compromised key. You may need "
|
"Remote device %s has advertised your public key. This may indicate a compromised key. You may need "
|
||||||
"to regenerate your public keys.";
|
"to regenerate your public keys.";
|
||||||
LOG_WARN(warning, p.long_name);
|
LOG_WARN(warning, safeName);
|
||||||
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
|
||||||
cn->level = meshtastic_LogRecord_Level_WARNING;
|
cn->level = meshtastic_LogRecord_Level_WARNING;
|
||||||
cn->time = getValidTime(RTCQualityFromNet);
|
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);
|
service->sendClientNotification(cn);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -210,6 +210,11 @@ void AirQualityTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSta
|
|||||||
return;
|
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;
|
const auto &m = telemetry.variant.air_quality_metrics;
|
||||||
|
|
||||||
// Check if any telemetry field has valid data
|
// Check if any telemetry field has valid data
|
||||||
|
|||||||
@@ -375,6 +375,22 @@ void EnvironmentTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSt
|
|||||||
return;
|
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;
|
const auto &m = telemetry.variant.environment_metrics;
|
||||||
|
|
||||||
// Check if any telemetry field has valid data
|
// Check if any telemetry field has valid data
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
class UnitConversions
|
class UnitConversions
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -7,4 +9,13 @@ class UnitConversions
|
|||||||
static float MetersPerSecondToKnots(float metersPerSecond);
|
static float MetersPerSecondToKnots(float metersPerSecond);
|
||||||
static float MetersPerSecondToMilesPerHour(float metersPerSecond);
|
static float MetersPerSecondToMilesPerHour(float metersPerSecond);
|
||||||
static float HectoPascalToInchesOfMercury(float hectoPascal);
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 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()) {
|
if (r->route_back_count > 0 || origin == nodeDB->getNodeNum()) {
|
||||||
route += "\n";
|
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);
|
route += vformat("(%.2fdB) 0x%x <-- ", (float)r->snr_back[r->snr_back_count - 1] / 4, origin);
|
||||||
else
|
else
|
||||||
route += "...";
|
route += "...";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include "configuration.h"
|
#include "configuration.h"
|
||||||
#include "graphics/SharedUIDisplay.h"
|
#include "graphics/SharedUIDisplay.h"
|
||||||
#include "graphics/draw/CompassRenderer.h"
|
#include "graphics/draw/CompassRenderer.h"
|
||||||
|
#include "meshUtils.h"
|
||||||
|
|
||||||
#if HAS_SCREEN
|
#if HAS_SCREEN
|
||||||
#include "gps/RTC.h"
|
#include "gps/RTC.h"
|
||||||
@@ -92,6 +93,10 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state,
|
|||||||
return;
|
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
|
// Get timestamp info. Will pass as a field to drawColumns
|
||||||
char lastStr[20];
|
char lastStr[20];
|
||||||
getTimeAgoStr(sinceReceived(&mp), lastStr, sizeof(lastStr));
|
getTimeAgoStr(sinceReceived(&mp), lastStr, sizeof(lastStr));
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
25
|
27
|
||||||
|
|||||||
@@ -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() {}
|
||||||
@@ -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 "Channels.h"
|
||||||
|
#include "GeoCoord.h"
|
||||||
#include "PositionPrecision.h"
|
#include "PositionPrecision.h"
|
||||||
#include "TestUtil.h"
|
#include "TestUtil.h"
|
||||||
#include "mesh-pb-constants.h"
|
#include "mesh-pb-constants.h"
|
||||||
|
#include <cstdint>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <unity.h>
|
#include <unity.h>
|
||||||
|
|
||||||
@@ -207,6 +209,26 @@ static void test_cryptoKeyIsPublic_invalidKeyIsNotPublic()
|
|||||||
TEST_ASSERT_FALSE(cryptoKeyIsPublic(makeCryptoKey(nullptr, -1)));
|
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 setUp(void) {}
|
||||||
|
|
||||||
void tearDown(void) {}
|
void tearDown(void) {}
|
||||||
@@ -232,6 +254,7 @@ void setup()
|
|||||||
RUN_TEST(test_cryptoKeyIsPublic_strongKeyIsPrivate);
|
RUN_TEST(test_cryptoKeyIsPublic_strongKeyIsPrivate);
|
||||||
RUN_TEST(test_cryptoKeyIsPublic_aes256KeyIsPrivate);
|
RUN_TEST(test_cryptoKeyIsPublic_aes256KeyIsPrivate);
|
||||||
RUN_TEST(test_cryptoKeyIsPublic_invalidKeyIsNotPublic);
|
RUN_TEST(test_cryptoKeyIsPublic_invalidKeyIsNotPublic);
|
||||||
|
RUN_TEST(test_geocoord_extreme_coords_no_oob);
|
||||||
exit(UNITY_END());
|
exit(UNITY_END());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
#include "TypeConversions.h"
|
#include "TypeConversions.h"
|
||||||
#include "mesh-pb-constants.h"
|
#include "mesh-pb-constants.h"
|
||||||
#include "meshUtils.h"
|
#include "meshUtils.h"
|
||||||
|
#include "modules/Telemetry/UnitConversions.h"
|
||||||
|
#include <cfloat>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <unity.h>
|
#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);
|
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 -------------------------------------------------------
|
// ---------- entry point -------------------------------------------------------
|
||||||
|
|
||||||
void setup()
|
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);
|
||||||
RUN_TEST(test_convert_to_node_info_extracts_bitfield_bools_none_set);
|
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_convert_to_node_info_user_only_when_has_user_bit_set);
|
||||||
|
RUN_TEST(test_displaySafeFloat_bounds_and_finiteness);
|
||||||
exit(UNITY_END());
|
exit(UNITY_END());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user