diff --git a/src/graphics/EmoteRenderer.cpp b/src/graphics/EmoteRenderer.cpp index 6fa0adb4c..c2bc0ec12 100644 --- a/src/graphics/EmoteRenderer.cpp +++ b/src/graphics/EmoteRenderer.cpp @@ -15,8 +15,19 @@ static inline int getStringWidth(OLEDDisplay *display, const char *text, size_t #if defined(OLED_UA) || defined(OLED_RU) return display->getStringWidth(text, len, true); #else - (void)len; - return display->getStringWidth(text); + // OLEDDisplay::getStringWidth (utf8=false) indexes the font jump table by (c - firstChar) with a + // signed char and no bounds check, so any byte outside printable ASCII (high bytes, but also + // control bytes like a stray 0x0A) reads outside the font array. Measure a sanitized copy in which + // unrepresentable bytes count as a '?' placeholder; printable ASCII is passed through unchanged. + char safe[64]; + if (len > sizeof(safe) - 1) + len = sizeof(safe) - 1; + for (size_t i = 0; i < len; i++) { + const uint8_t c = static_cast(text[i]); + safe[i] = (c >= 0x20 && c <= 0x7E) ? static_cast(c) : '?'; + } + safe[len] = '\0'; + return display->getStringWidth(safe, len, false); #endif } @@ -31,6 +42,14 @@ size_t utf8CharLen(uint8_t c) return 1; } +// Bytes the UTF-8 char at s[pos] occupies, clamped to what actually remains. A truncated multi-byte +// lead (e.g. a lone 0xF0) near the end otherwise makes a walker read past the buffer - the payload of +// a TEXT message is opaque bytes, so such truncated sequences are attacker-reachable. +static inline size_t utf8CharLenClamped(const char *s, size_t pos, size_t len) +{ + return std::min(utf8CharLen(static_cast(s[pos])), len - pos); +} + static inline bool isPossibleEmoteLead(uint8_t c) { // All supported emoji labels in emotes.cpp are currently in these UTF-8 lead ranges. @@ -209,7 +228,7 @@ static LineMetrics analyzeLineInternal(OLEDDisplay *display, const char *line, s continue; } - const size_t charLen = utf8CharLen(static_cast(line[i])); + const size_t charLen = utf8CharLenClamped(line, i, lineLen); if (display) metrics.width += getUtf8ChunkWidth(display, line + i, charLen); i += charLen; @@ -257,7 +276,7 @@ static int appendTextSpanAndMeasure(OLEDDisplay *display, int cursorX, int fontY while (pos < len) { size_t chunkLen = 0; while (pos + chunkLen < len) { - const size_t charLen = utf8CharLen(static_cast(text[pos + chunkLen])); + const size_t charLen = utf8CharLenClamped(text, pos + chunkLen, len); if (chunkLen + charLen >= sizeof(chunk)) break; chunkLen += charLen; @@ -328,7 +347,7 @@ size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t continue; } - const size_t charLen = utf8CharLen(static_cast(line[i])); + const size_t charLen = utf8CharLenClamped(line, i, lineLen); tokenWidth = getUtf8ChunkWidth(display, line + i, charLen); advance = charLen; } @@ -417,11 +436,11 @@ void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, if (findEmoteAt(line, lineLen, next, nextMatchLen, emoteSet, emoteCount) != nullptr) break; - next += utf8CharLen(static_cast(line[next])); + next += utf8CharLenClamped(line, next, lineLen); } if (next == i) - next += utf8CharLen(static_cast(line[i])); + next += utf8CharLenClamped(line, i, lineLen); cursorX = appendTextSpanAndMeasure(display, cursorX, fontY, line + i, next - i, true, fauxBold && inBold); i = next; diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index f0b339df2..1537dc43d 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -239,7 +239,9 @@ CryptoKey Channels::getKey(ChannelIndex chIndex) memcpy(k.bytes, channelSettings.psk.bytes, channelSettings.psk.size); k.length = channelSettings.psk.size; if (k.length == 0) { - if (ch.role == meshtastic_Channel_Role_SECONDARY) { + // A secondary with no PSK borrows the primary's key; the chIndex != primaryIndex check + // prevents infinite recursion if the primary slot itself is marked SECONDARY + if (ch.role == meshtastic_Channel_Role_SECONDARY && chIndex != primaryIndex) { LOG_DEBUG("Unset PSK for secondary channel %s. use primary key", ch.settings.name); k = getKey(primaryIndex); } else { @@ -309,11 +311,28 @@ void Channels::initDefaults() void Channels::onConfigChanged() { // Make sure the phone hasn't mucked anything up + bool hasPrimary = false; for (int i = 0; i < channelFile.channels_count; i++) { const meshtastic_Channel &ch = fixupChannel(i); - if (ch.role == meshtastic_Channel_Role_PRIMARY) + if (ch.role == meshtastic_Channel_Role_PRIMARY) { primaryIndex = i; + hasPrimary = true; + } + } + // Enforce the invariant that primaryIndex references a PRIMARY channel: a malformed config can + // demote every slot, which would leave all getPrimaryIndex() readers on a stale non-primary slot + if (!hasPrimary) { + meshtastic_Channel &stale = getByIndex(primaryIndex); + if (stale.role == meshtastic_Channel_Role_SECONDARY) { + stale.role = meshtastic_Channel_Role_PRIMARY; // keep the slot's key material + } else { + // Promoting a DISABLED (zeroed) slot would make a plaintext primary; restore the default + primaryIndex = 0; + initDefaultChannel(0); + } + LOG_WARN("Config has no PRIMARY channel, restored one at slot %u", primaryIndex); + fixupChannel(primaryIndex); } #if !MESHTASTIC_EXCLUDE_MQTT if (channels.anyMqttEnabled() && mqtt && !mqtt->isEnabled()) { diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index eb98b7cda..d59064ba9 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -1110,7 +1110,8 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo } } } else { - check_bw = bwCodeToKHz(loraConfig.bandwidth); + // Clamp at the source so numFreqSlots below can never be 0 (bandwidth 0 is reachable from a crafted set_config) + check_bw = clampBandwidthKHz(bwCodeToKHz(loraConfig.bandwidth)); } // Calculate width of slots (aka channels) based on bandwidth and any spacing or padding required by the region: @@ -1142,8 +1143,9 @@ bool RadioInterface::checkOrClampConfigLora(meshtastic_Config_LoRaConfig &loraCo const char *channelName = channels.getName(channels.getPrimaryIndex()); const char *presetNameDisplay = DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset); - uint32_t channelNameHashSlot = hash(channelName) % numFreqSlots; - uint32_t presetNameHashSlot = hash(presetNameDisplay) % numFreqSlots; + // numFreqSlots can still be 0 for an UNSET/degenerate region, and % 0 is a SIGFPE + uint32_t channelNameHashSlot = numFreqSlots ? (hash(channelName) % numFreqSlots) : 0; + uint32_t presetNameHashSlot = numFreqSlots ? (hash(presetNameDisplay) % numFreqSlots) : 0; if (loraConfig.override_frequency == 0) { @@ -1245,7 +1247,8 @@ void RadioInterface::applyModemConfig() newRegion->name); clampConfigLora(loraConfig); } - bw = bwCodeToKHz(loraConfig.bandwidth); + // Clamp at the source so numFreqSlots below can never be 0 (a bandwidth-0 config may already be persisted) + bw = clampBandwidthKHz(bwCodeToKHz(loraConfig.bandwidth)); sf = loraConfig.spread_factor; cr = loraConfig.coding_rate; } @@ -1276,9 +1279,13 @@ void RadioInterface::applyModemConfig() // Note that channel_num is actually (channel_num - 1), i.e. zero-based, since modulus (%) returns values from 0 to // (numFreqSlots - 1). const char *channelName = channels.getName(channels.getPrimaryIndex()); - uint32_t channelNameHashSlot = hash(channelName) % numFreqSlots; + // Guard the modulo: numFreqSlots can be 0 for an UNSET/degenerate region, and % 0 is a SIGFPE + uint32_t channelNameHashSlot = numFreqSlots ? (hash(channelName) % numFreqSlots) : 0; uint32_t presetNameHashSlot = - hash(DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset)) % numFreqSlots; + numFreqSlots + ? (hash(DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset)) % + numFreqSlots) + : 0; // override if we have a verbatim frequency if (loraConfig.override_frequency) { diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index f123a137b..468e020ea 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -22,7 +22,7 @@ struct AdminModule_ObserverData { */ class AdminModule : public ProtobufModule, public Observable { - friend class AdminModuleTestShim; // native unit tests reach handleSetConfig + hasOpenEditTransaction + friend class AdminModuleTestShim; // test/support/AdminModuleTestShim.h - native tests reach the private handlers/state public: /** Constructor diff --git a/test/support/AdminModuleTestShim.h b/test/support/AdminModuleTestShim.h new file mode 100644 index 000000000..7dba0677e --- /dev/null +++ b/test/support/AdminModuleTestShim.h @@ -0,0 +1,25 @@ +#pragma once +// The one class AdminModule.h befriends (test seam): exposes the protected/private handlers to the +// native suites and defers persistence so setters exercise in-RAM logic without disk/reboot effects. +#include "MeshTypes.h" // packetPool +#include "modules/AdminModule.h" + +class AdminModuleTestShim : public AdminModule +{ + public: + using AdminModule::handleReceivedProtobuf; + using AdminModule::handleSetConfig; + using AdminModule::handleSetModuleConfig; + + // With an "open edit transaction" saveChanges() is a pure no-op: no reloadConfig/saveToDisk/reboot. + void deferSaves() { hasOpenEditTransaction = true; } + + // Setters may allocate an error reply from packetPool; drain it each iteration or the pool leaks. + void drainReply() + { + if (myReply) { + packetPool.release(myReply); + myReply = nullptr; + } + } +}; diff --git a/test/support/DeterministicRng.h b/test/support/DeterministicRng.h new file mode 100644 index 000000000..5896e272e --- /dev/null +++ b/test/support/DeterministicRng.h @@ -0,0 +1,41 @@ +#pragma once +// Seeded 64-bit LCG (Knuth MMIX constants) shared by the fuzz suites - no rand()/time(), so a failing +// iteration reproduces exactly from the printed seed. `static inline` + per-TU state suits single-file +// Unity suites; each suite keeps its own BASE_SEED/FUZZ_SEED and seeds per group. +#include +#include + +static uint64_t g_fuzzRng = 0; + +static inline void rngSeed(uint64_t s) +{ + g_fuzzRng = s ? s : 0x9E3779B97F4A7C15ULL; +} +static inline uint32_t rngNext() +{ + g_fuzzRng = g_fuzzRng * 6364136223846793005ULL + 1442695040888963407ULL; + return (uint32_t)(g_fuzzRng >> 32); +} +static inline uint8_t rngByte() +{ + return (uint8_t)(rngNext() & 0xFF); +} +static inline uint32_t rngRange(uint32_t n) // uniform-ish in [0, n) +{ + return n ? (rngNext() % n) : 0; +} +static inline void rngFill(void *buf, size_t n) +{ + uint8_t *b = (uint8_t *)buf; + for (size_t i = 0; i < n; i++) + b[i] = rngByte(); +} +// One of the boundary NodeNums every fuzz suite should hit - 0, 1, broadcast (0xFFFFFFFF == +// NODENUM_BROADCAST) - plus any suite-specific well-known nodes (local/remote/target) passed in. +static inline uint32_t rngEdgeNodeNum(const uint32_t *wellKnown = nullptr, size_t wellKnownN = 0) +{ + static const uint32_t edges[] = {0u, 1u, 0xFFFFFFFFu}; + const size_t numEdges = sizeof(edges) / sizeof(edges[0]); + size_t i = rngRange((uint32_t)(numEdges + wellKnownN)); + return (i < numEdges) ? edges[i] : wellKnown[i - numEdges]; +} diff --git a/test/support/MockMeshService.h b/test/support/MockMeshService.h new file mode 100644 index 000000000..6bfeed077 --- /dev/null +++ b/test/support/MockMeshService.h @@ -0,0 +1,10 @@ +#pragma once +// Release-to-pool MeshService stub shared by the native suites: handlers that raise client +// notifications must not leak them across thousands of iterations (LSan turns the run RED). +#include "mesh/MeshService.h" + +class MockMeshService : public MeshService +{ + public: + void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } +}; diff --git a/test/test_admin_radio/test_main.cpp b/test/test_admin_radio/test_main.cpp index 886694caf..e8c0e9108 100644 --- a/test/test_admin_radio/test_main.cpp +++ b/test/test_admin_radio/test_main.cpp @@ -21,16 +21,12 @@ #include #include "meshtastic/config.pb.h" +#include "support/AdminModuleTestShim.h" +#include "support/MockMeshService.h" // hash() is a file-scope function in RadioInterface.cpp; link it in for slot-formula tests extern uint32_t hash(const char *str); -class MockMeshService : public MeshService -{ - public: - void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } -}; - static MockMeshService *mockMeshService; // ----------------------------------------------------------------------- @@ -929,15 +925,7 @@ static void test_channelSpacingCalculation_placeholder() // handleSetConfig fromOthers dispatch tests // ----------------------------------------------------------------------- -class AdminModuleTestShim : public AdminModule -{ - public: - using AdminModule::handleSetConfig; - // Defer persistence so handleSetConfig() exercises the in-RAM config logic without saveToDisk() touching - // the (uninitialized) node database in this lightweight harness. - void deferSaves() { hasOpenEditTransaction = true; } -}; - +// AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares. static AdminModuleTestShim *testAdmin; static meshtastic_Config makeLoraSetConfig(meshtastic_Config_LoRaConfig_RegionCode region, bool usePreset, diff --git a/test/test_fuzz_decode/test_main.cpp b/test/test_fuzz_decode/test_main.cpp index 84b1d0315..4685d1e10 100644 --- a/test/test_fuzz_decode/test_main.cpp +++ b/test/test_fuzz_decode/test_main.cpp @@ -22,7 +22,12 @@ #include "mesh-pb-constants.h" #include "mesh/generated/meshtastic/admin.pb.h" +#include "mesh/generated/meshtastic/channel.pb.h" +#include "mesh/generated/meshtastic/config.pb.h" #include "mesh/generated/meshtastic/mesh.pb.h" +#include "mesh/generated/meshtastic/mesh_beacon.pb.h" +#include "mesh/generated/meshtastic/module_config.pb.h" +#include "mesh/generated/meshtastic/mqtt.pb.h" #include "mesh/generated/meshtastic/storeforward.pb.h" #include "mesh/generated/meshtastic/telemetry.pb.h" #include "meshUtils.h" @@ -30,28 +35,8 @@ #include #include -// --------------------------------------------------------------------------- -// Deterministic RNG - seeded 64-bit LCG (Knuth MMIX constants). No rand()/time so a failing -// iteration reproduces exactly from the printed base seed. -// --------------------------------------------------------------------------- -static uint64_t g_rng = 0; -static void rngSeed(uint64_t s) -{ - g_rng = s ? s : 0x9E3779B97F4A7C15ULL; -} -static uint32_t rngNext() -{ - g_rng = g_rng * 6364136223846793005ULL + 1442695040888963407ULL; - return (uint32_t)(g_rng >> 32); -} -static uint8_t rngByte() -{ - return (uint8_t)(rngNext() & 0xFF); -} -static uint32_t rngRange(uint32_t n) // uniform-ish in [0, n) -{ - return n ? (rngNext() % n) : 0; -} +// Deterministic RNG (rngSeed/rngNext/rngByte/rngRange) - shared seeded LCG. +#include "support/DeterministicRng.h" static constexpr uint64_t BASE_SEED = 0x00C0FFEEULL; static constexpr unsigned DECODE_ITERS = 3000; // per type, per pass (ASan-instrumented, keep bounded) @@ -75,6 +60,13 @@ union AnyMsg { meshtastic_Routing routing; meshtastic_AdminMessage adminMessage; meshtastic_StoreAndForward storeAndForward; + meshtastic_MeshBeacon meshBeacon; + meshtastic_ServiceEnvelope serviceEnvelope; + meshtastic_ModuleConfig moduleConfig; + meshtastic_Config config; + meshtastic_Channel channel; + meshtastic_ChannelSettings channelSettings; + meshtastic_KeyVerification keyVerification; }; struct FuzzType { @@ -94,6 +86,13 @@ static const FuzzType FUZZ_TYPES[] = { {"Routing", &meshtastic_Routing_msg}, {"AdminMessage", &meshtastic_AdminMessage_msg}, {"StoreAndForward", &meshtastic_StoreAndForward_msg}, + {"MeshBeacon", &meshtastic_MeshBeacon_msg}, // beacon offer: char[101] message + PSK-bearing ChannelSettings + {"ServiceEnvelope", &meshtastic_ServiceEnvelope_msg}, // MQTT downlink wrapper - unusual (non-RF) ingress + {"ModuleConfig", &meshtastic_ModuleConfig_msg}, // admin set_module_config payload union + {"Config", &meshtastic_Config_msg}, // admin set_config payload union + {"Channel", &meshtastic_Channel_msg}, // admin set_channel payload + {"ChannelSettings", &meshtastic_ChannelSettings_msg}, + {"KeyVerification", &meshtastic_KeyVerification_msg}, // PKI key-verification handshake payload }; static const size_t NUM_FUZZ_TYPES = sizeof(FUZZ_TYPES) / sizeof(FUZZ_TYPES[0]); @@ -164,8 +163,7 @@ static void decodeFuzzPass(bool protoish, uint64_t seed) len = genProtoish(buf, sizeof(buf)); } else { len = rngRange(sizeof(buf) + 1); - for (size_t i = 0; i < len; i++) - buf[i] = rngByte(); + rngFill(buf, len); } memset(&out, 0, sizeof(out)); // Return value intentionally ignored: true or false are both acceptable. What must never @@ -328,8 +326,7 @@ void test_D2f_pb_string_length(void) 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(); + rngFill(buf, maxLen); 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) diff --git a/test/test_fuzz_emotes/test_main.cpp b/test/test_fuzz_emotes/test_main.cpp new file mode 100644 index 000000000..25749e6c5 --- /dev/null +++ b/test/test_fuzz_emotes/test_main.cpp @@ -0,0 +1,130 @@ +// Adversarial fuzzing of the emote/UTF-8 width+truncation walkers - the render path a TEXT_MESSAGE +// payload reaches AFTER decode. Text payloads are opaque protobuf `bytes`, so PB_VALIDATE_UTF8 never +// screens them: invalid UTF-8, control bytes and truncated multi-byte lead bytes reach these walkers +// verbatim. The walkers advance by utf8CharLen(lead), which for a 0xC0/0xE0/0xF0 lead near the end of +// the buffer claims more bytes than remain - so an unclamped copy reads past the string. +// +// Each input is placed in an EXACT-sized heap buffer (content + NUL, no slack) so any read past the +// content is a hard heap-buffer-overflow that AddressSanitizer flags. Runs under the coverage env. + +#include "TestUtil.h" +#include "configuration.h" +#include + +#if HAS_SCREEN + +#include "graphics/EmoteRenderer.h" +#include +#include +#include +#include + +#include "support/DeterministicRng.h" +static constexpr uint64_t BASE_SEED = 0x00E3070EULL; + +// Headless display with a REAL font, so the fuzz exercises the production width path end to end: +// EmoteRenderer's getUtf8ChunkWidth memcpy (guarded by the utf8CharLen clamp) AND the getStringWidth +// helper's byte sanitizer (which keeps the stock library's signed-char font indexing in bounds for +// bytes outside printable ASCII). getStringWidth only walks the font jump table, never a frame buffer. +class FakeDisplay : public OLEDDisplay +{ + public: + FakeDisplay() { setFont(ArialMT_Plain_10); } + void display() override {} + int getBufferOffset() override { return 0; } + size_t write(uint8_t) override { return 1; } +}; + +// Drive both walkers over a NUL-terminated exact-sized copy of [bytes, bytes+len). +static void exercise(FakeDisplay &d, const uint8_t *bytes, size_t len) +{ + char *buf = (char *)malloc(len + 1); // exactly content + NUL: reads past index len are OOB + memcpy(buf, bytes, len); + buf[len] = '\0'; + + (void)graphics::EmoteRenderer::measureStringWithEmotes(&d, buf); // analyzeLineInternal walk + char out[64]; + (void)graphics::EmoteRenderer::truncateToWidth(&d, buf, out, sizeof(out), 40); // cut-loop walk + + free(buf); +} + +// A byte that begins a multi-byte UTF-8 sequence (so utf8CharLen returns 2/3/4). +static uint8_t multibyteLead() +{ + static const uint8_t leads[] = {0xC0, 0xE0, 0xF0, 0xE2}; // 0xE2/0xF0 are also emote leads + return leads[rngRange(sizeof(leads))]; +} + +void test_emote_utf8_fuzz(void) +{ + printf(" seed=0x%llx\n", (unsigned long long)BASE_SEED); + rngSeed(BASE_SEED); + + FakeDisplay d; + + for (unsigned k = 0; k < 20000; k++) { + uint8_t bytes[300]; + size_t len = 1 + rngRange(sizeof(bytes)); // 1..300, includes >MAX_MESSAGE_SIZE + + // Non-zero fill so strlen() == len (an embedded NUL would just shorten the effective string). + for (size_t i = 0; i < len; i++) + bytes[i] = (uint8_t)(1 + rngRange(255)); + + // Sprinkle multi-byte leads (some truncated) through the body to reach findEmoteAt / the + // modifier skippers as well as the plain width path. + unsigned sprinkles = rngRange(6); + for (unsigned s = 0; s < sprinkles; s++) + bytes[rngRange(len)] = multibyteLead(); + + // Half the time, force the LAST byte to a lead: a truncated sequence with no continuation left, + // the exact shape that makes an unclamped walker step past the buffer end. + if (rngRange(2)) + bytes[len - 1] = multibyteLead(); + + exercise(d, bytes, len); + } + TEST_ASSERT_TRUE(true); // reaching here = no ASan fault across all iterations +} + +// Fixed regressions for the truncated-lead shape, independent of the RNG. +void test_emote_truncated_lead_edges(void) +{ + FakeDisplay d; + const uint8_t loneF0[] = {0xF0}; + const uint8_t tailE0[] = {'h', 'i', 0xE0}; + const uint8_t tailC0[] = {'a', 'b', 'c', 0xC0}; + uint8_t allF0[64]; + memset(allF0, 0xF0, sizeof(allF0)); + + exercise(d, loneF0, sizeof(loneF0)); + exercise(d, tailE0, sizeof(tailE0)); + exercise(d, tailC0, sizeof(tailC0)); + exercise(d, allF0, sizeof(allF0)); + TEST_ASSERT_TRUE(true); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_emote_truncated_lead_edges); + RUN_TEST(test_emote_utf8_fuzz); + exit(UNITY_END()); +} + +void loop() {} + +#else // !HAS_SCREEN + +void setUp(void) {} +void tearDown(void) {} +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} +void loop() {} + +#endif diff --git a/test/test_fuzz_packets/test_main.cpp b/test/test_fuzz_packets/test_main.cpp index c38f9ef58..196962aea 100644 --- a/test/test_fuzz_packets/test_main.cpp +++ b/test/test_fuzz_packets/test_main.cpp @@ -12,6 +12,15 @@ // 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) +// Group E5 AdminModule dispatch over adversarial AdminMessage (local from==0, setter/node-op tags) +// Group E6 MeshBeaconListenerModule handler over adversarial MeshBeacon (un-terminated message, offer PSK) +// Group E7 NodeDB::updateUser / updateFrom over adversarial nodeId + User / MeshPacket +// Group E8 PositionModule handler over adversarial Position (self/remote origin, fixed_position, RTC path) +// Group E9 DeviceTelemetryModule handler over adversarial Telemetry (all variant tags) +// Group E10 NeighborInfoModule handler over adversarial NeighborInfo (neighbor-count edges) +// +// KeyVerificationModule (handler inert until a prior handshake advances its private state) and +// StoreForwardModule (needs PSRAM + router wiring) are instead covered at the decode level in test_fuzz_decode. #include "MeshTypes.h" // include BEFORE TestUtil.h #include "TestUtil.h" @@ -25,37 +34,28 @@ #include "mesh/MeshService.h" #include "mesh/NodeDB.h" #include "mesh/Router.h" +#include "modules/AdminModule.h" +#include "modules/NeighborInfoModule.h" #include "modules/NodeInfoModule.h" +#include "modules/PositionModule.h" +#include "modules/Telemetry/DeviceTelemetry.h" #include "modules/TraceRouteModule.h" #include #include #include #include +#if !MESHTASTIC_EXCLUDE_BEACON +#include "modules/MeshBeaconModule.h" +#endif + 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; -} +// Deterministic RNG (rngSeed/rngNext/rngByte/rngRange/rngFill/rngEdgeNodeNum) - shared seeded LCG. +#include "support/AdminModuleTestShim.h" +#include "support/DeterministicRng.h" +#include "support/MockMeshService.h" static constexpr uint64_t BASE_SEED = 0x00BADF00DULL; // --------------------------------------------------------------------------- @@ -96,9 +96,12 @@ class MockNodeDB : public NodeDB static MockNodeDB *mockNodeDB = nullptr; +static MockMeshService *mockService = nullptr; + void setUp(void) { config = meshtastic_LocalConfig_init_zero; + moduleConfig = meshtastic_LocalModuleConfig_init_zero; owner = meshtastic_User_init_zero; mockNodeDB = new MockNodeDB(); @@ -106,6 +109,9 @@ void setUp(void) nodeDB = mockNodeDB; myNodeInfo.my_node_num = LOCAL_NODE; + mockService = new MockMeshService(); + service = mockService; + channels.initDefaults(); channels.onConfigChanged(); } @@ -115,6 +121,10 @@ void tearDown(void) delete mockNodeDB; mockNodeDB = nullptr; nodeDB = nullptr; + + service = nullptr; + delete mockService; + mockService = nullptr; } // =========================================================================== @@ -144,10 +154,8 @@ void test_E1_perhaps_decode_fuzz(void) 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; + p.encrypted.size = rngRange(sizeof(p.encrypted.bytes) + 1); // 0..256, always in bounds + rngFill(p.encrypted.bytes, p.encrypted.size); DecodeState st = perhapsDecode(&p); // Any verdict is fine; the contract is that arbitrary ciphertext never crashes the pipeline. @@ -182,8 +190,7 @@ static meshtastic_User fuzzUser() 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(); + rngFill(u.public_key.bytes, u.public_key.size); return u; } @@ -273,10 +280,8 @@ void test_E3_traceroute_route_processing_fuzz(void) 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; + mp.decoded.payload.size = rngRange(sizeof(mp.decoded.payload.bytes) + 1); + rngFill(mp.decoded.payload.bytes, mp.decoded.payload.size); } else { meshtastic_RouteDiscovery r = makeRoute(rngRange(ROUTE_MAX + 1), rngRange(ROUTE_MAX + 1), rngRange(ROUTE_MAX + 1), rngRange(ROUTE_MAX + 1)); @@ -350,8 +355,7 @@ void test_E4_phone_forward_gate(void) 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(); + rngFill(buf, n); meshtastic_PortNum port = (rngRange(2)) ? meshtastic_PortNum_WAYPOINT_APP : meshtastic_PortNum_NODEINFO_APP; d = makeData(port, buf, n); @@ -368,6 +372,412 @@ void test_E4_phone_forward_gate(void) } } +// =========================================================================== +// Group E5 - AdminModule dispatch over adversarial AdminMessage +// (AdminModuleTestShim comes from test/support - the friend seam AdminModule.h declares.) +// =========================================================================== +// A scoped subset of setter / node-list tags - the ones reachable without a fully-booted module graph. +// EXCLUDES: side-effecting tags even with saves deferred (reboot*, shutdown, *factory_reset*, +// nodedb_reset, ota, enter_dfu, delete_file, *_preferences, exit_simulator, begin/commit_edit_settings); +// get_* reply builders (un-mocked service paths); set_owner (needs global nodeInfoModule) and +// set_fixed_position (needs global positionModule) - their payloads are covered by E2/E7 and +// test_fuzz_decode. set_config and set_channel ARE fuzzed - they found two crashes (a SIGFPE in LoRa +// validation and unbounded getKey recursion), both fixed; E5 re-fuzzes the triggers as regression +// guards. The module-config variant is constrained below only to skip the beacon variant's global. +static const pb_size_t SAFE_ADMIN_TAGS[] = { + meshtastic_AdminMessage_set_config_tag, + meshtastic_AdminMessage_set_module_config_tag, + meshtastic_AdminMessage_set_channel_tag, + meshtastic_AdminMessage_set_favorite_node_tag, + meshtastic_AdminMessage_remove_favorite_node_tag, + meshtastic_AdminMessage_set_ignored_node_tag, + meshtastic_AdminMessage_remove_ignored_node_tag, + meshtastic_AdminMessage_toggle_muted_node_tag, + meshtastic_AdminMessage_remove_by_nodenum_tag, + meshtastic_AdminMessage_add_contact_tag, + meshtastic_AdminMessage_remove_fixed_position_tag, + meshtastic_AdminMessage_set_time_only_tag, +}; +static const size_t NUM_SAFE_ADMIN_TAGS = sizeof(SAFE_ADMIN_TAGS) / sizeof(SAFE_ADMIN_TAGS[0]); + +// ModuleConfig variants that handleSetModuleConfig applies with only in-RAM copy + (deferred) save. +// Excludes mesh_beacon, whose handler derefs the global meshBeaconBroadcastModule. +static const pb_size_t SAFE_MODULE_CONFIG_TAGS[] = { + meshtastic_ModuleConfig_mqtt_tag, + meshtastic_ModuleConfig_serial_tag, + meshtastic_ModuleConfig_external_notification_tag, + meshtastic_ModuleConfig_store_forward_tag, + meshtastic_ModuleConfig_range_test_tag, + meshtastic_ModuleConfig_telemetry_tag, + meshtastic_ModuleConfig_canned_message_tag, + meshtastic_ModuleConfig_audio_tag, + meshtastic_ModuleConfig_remote_hardware_tag, + meshtastic_ModuleConfig_neighbor_info_tag, + meshtastic_ModuleConfig_ambient_lighting_tag, + meshtastic_ModuleConfig_detection_sensor_tag, + meshtastic_ModuleConfig_paxcounter_tag, +}; +static const size_t NUM_SAFE_MODULE_CONFIG_TAGS = sizeof(SAFE_MODULE_CONFIG_TAGS) / sizeof(SAFE_MODULE_CONFIG_TAGS[0]); + +// A node number spanning the shared edge pool (0/1/broadcast + self) or broad random. +static NodeNum fuzzNodeNum() +{ + return (rngRange(4) == 0) ? rngNext() : rngEdgeNodeNum(&LOCAL_NODE, 1); +} + +// Randomize a ChannelSettings name + PSK (shared by the set_channel case and fuzzBeacon). Name is +// random-length but NUL-terminated (nanopb terminates decoded strings, so un-terminated isn't +// wire-reachable); PSK is 0..32 bytes including empty. +static void fuzzChannelSettings(meshtastic_ChannelSettings &s) +{ + size_t nameLen = rngRange(sizeof(s.name)); + for (size_t i = 0; i < sizeof(s.name); i++) + s.name[i] = (i < nameLen) ? (char)(1 + rngRange(255)) : '\0'; + s.psk.size = rngRange(sizeof(s.psk.bytes) + 1); + rngFill(s.psk.bytes, s.psk.size); +} + +// Build a wire-plausible-but-adversarial AdminMessage: size-bearing fields (pubkey, psk) stay within +// capacity (as nanopb would enforce on decode) so we find real logic bugs, not fabricated OOB that +// can't arrive over the air. +static meshtastic_AdminMessage fuzzAdminMessage() +{ + meshtastic_AdminMessage r = meshtastic_AdminMessage_init_zero; + r.which_payload_variant = SAFE_ADMIN_TAGS[rngRange(NUM_SAFE_ADMIN_TAGS)]; + switch (r.which_payload_variant) { + case meshtastic_AdminMessage_set_config_tag: + // LoRa only (position variant would deref global gps). use_preset fuzzed both ways, incl. the + // manual bandwidth==0 path that used to SIGFPE the validator. + r.set_config.which_payload_variant = meshtastic_Config_lora_tag; + r.set_config.payload_variant.lora.region = (meshtastic_Config_LoRaConfig_RegionCode)rngRange(32); + r.set_config.payload_variant.lora.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)rngRange(16); + r.set_config.payload_variant.lora.use_preset = (rngRange(2) == 0); + r.set_config.payload_variant.lora.bandwidth = rngRange(512); // includes 0 + r.set_config.payload_variant.lora.channel_num = rngNext(); + break; + case meshtastic_AdminMessage_set_module_config_tag: + // Non-beacon variant (beacon derefs global meshBeaconBroadcastModule). + r.set_module_config.which_payload_variant = SAFE_MODULE_CONFIG_TAGS[rngRange(NUM_SAFE_MODULE_CONFIG_TAGS)]; + break; + case meshtastic_AdminMessage_set_channel_tag: { + // Fuzz role + PSK; an empty-PSK SECONDARY at the primary slot used to recurse forever in + // Channels::getKey. + meshtastic_Channel &c = r.set_channel; + c.index = (int32_t)rngRange(16); // includes out-of-range (getByIndex bounds-checks) + c.has_settings = true; + c.role = (meshtastic_Channel_Role)rngRange(4); + fuzzChannelSettings(c.settings); + break; + } + case meshtastic_AdminMessage_add_contact_tag: + r.add_contact.node_num = fuzzNodeNum(); + r.add_contact.has_user = true; + r.add_contact.user = fuzzUser(); + r.add_contact.should_ignore = (rngRange(2) == 0); + r.add_contact.manually_verified = (rngRange(2) == 0); + break; + case meshtastic_AdminMessage_remove_fixed_position_tag: + r.remove_fixed_position = (rngRange(2) == 0); + break; + case meshtastic_AdminMessage_set_time_only_tag: + r.set_time_only = rngNext(); + break; + default: + // The remaining safe tags all share a uint32 nodenum union member (set/remove_favorite, + // set/remove_ignored, toggle_muted, remove_by_nodenum). + r.set_favorite_node = fuzzNodeNum(); + break; + } + return r; +} + +void test_E5_admin_dispatch_fuzz(void) +{ + printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE5)); + rngSeed(BASE_SEED ^ 0xE5); + + static AdminModuleTestShim admin; // static: MeshModule/OSThread lifetime, see the note in test_E2 + + // Seed the local node plus a couple of others for the node-list ops to target. + mockNodeDB->addNode(LOCAL_NODE); + mockNodeDB->addNode(REMOTE_NODE); + mockNodeDB->addNode(0x0C0C0C0C); + + for (unsigned k = 0; k < 6000; k++) { + admin.deferSaves(); // re-assert each iteration: no disk / reboot regardless of the tag + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = 0; // local BLE/USB/TCP client - bypasses the remote auth gates, reaches the switch + mp.to = LOCAL_NODE; + mp.id = rngNext(); + mp.channel = (uint8_t)rngRange(8); + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.decoded.portnum = meshtastic_PortNum_ADMIN_APP; + + meshtastic_AdminMessage r = fuzzAdminMessage(); + // Contract: never crashes / no OOB regardless of the AdminMessage contents. + (void)admin.handleReceivedProtobuf(mp, &r); + admin.drainReply(); + } + // Reaching here = no crash across all iterations. The node-list ops (set_ignored/add_contact) create + // nodes, so the DB may fill and evict our seed nodes - that's legitimate; the invariant is only that + // the count never overran the cap. + TEST_ASSERT_TRUE_MESSAGE(mockNodeDB->getNumMeshNodes() <= (size_t)MAX_NUM_NODES, "NodeDB overran MAX_NUM_NODES"); +} + +#if !MESHTASTIC_EXCLUDE_BEACON +// =========================================================================== +// Group E6 - MeshBeaconListenerModule handler over adversarial MeshBeacon +// =========================================================================== +class MeshBeaconListenerModuleTestShim : public MeshBeaconListenerModule +{ + public: + using MeshBeaconListenerModule::handleReceivedProtobuf; +}; + +static meshtastic_MeshBeacon fuzzBeacon() +{ + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + if (rngRange(2)) { + // Un-terminated: every byte non-NUL, so strnlen(message, sizeof-1) must run to its bound. + for (size_t i = 0; i < sizeof(b.message); i++) + b.message[i] = (char)(1 + rngRange(255)); + } else { + // NUL-terminated random-length prefix. + size_t n = rngRange(sizeof(b.message)); + for (size_t i = 0; i < sizeof(b.message); i++) + b.message[i] = (i < n) ? (char)(1 + rngRange(255)) : '\0'; + } + b.has_offer_channel = (rngRange(2) == 0); + if (b.has_offer_channel) + fuzzChannelSettings(b.offer_channel); + b.offer_region = (meshtastic_Config_LoRaConfig_RegionCode)rngRange(32); + b.has_offer_preset = (rngRange(2) == 0); + b.offer_preset = (meshtastic_Config_LoRaConfig_ModemPreset)rngRange(16); + return b; +} + +void test_E6_beacon_listener_fuzz(void) +{ + printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE6)); + rngSeed(BASE_SEED ^ 0xE6); + + static MeshBeaconListenerModuleTestShim beacon; // static: MeshModule lifetime, see the note in test_E2 + + for (unsigned k = 0; k < 6000; k++) { + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = fuzzNodeNum(); + mp.to = NODENUM_BROADCAST; + mp.id = rngNext(); + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; + + meshtastic_MeshBeacon b = fuzzBeacon(); + bool hasOffer = + b.has_offer_channel || b.offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET || b.has_offer_preset; + + // The listener must never consume the packet (it flows on to the phone)... + TEST_ASSERT_FALSE(beacon.handleReceivedProtobuf(mp, &b)); + // ...and any offer content must land in the client-visible cache, keyed to the sender. + if (hasOffer) { + TEST_ASSERT_TRUE(MeshBeaconListenerModule::lastReceivedOffer.valid); + TEST_ASSERT_EQUAL_UINT32(mp.from, MeshBeaconListenerModule::lastReceivedOffer.sender); + } + } +} +#endif // !MESHTASTIC_EXCLUDE_BEACON + +// =========================================================================== +// Group E7 - NodeDB::updateUser / updateFrom over adversarial nodeId + payloads +// =========================================================================== +// E2 fuzzes the NodeInfoModule handler; this drives the NodeDB mutators directly with adversarial +// node numbers (0 / self / broadcast / arbitrary) - the path that stores untrusted identity/telemetry. +void test_E7_nodedb_update_fuzz(void) +{ + printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE7)); + rngSeed(BASE_SEED ^ 0xE7); + + for (unsigned k = 0; k < 6000; k++) { + if (rngRange(2)) { + // updateUser: adversarial User (un-terminated strings, oversized-but-bounded pubkey) under + // an arbitrary node id and channel index. + meshtastic_User u = fuzzUser(); + NodeNum id = fuzzNodeNum(); + uint8_t ch = (uint8_t)rngRange(16); + (void)nodeDB->updateUser(id, u, ch); + } else { + // updateFrom: a decoded packet keyed on an arbitrary `from`, exercising the SNR/RSSI/hops + // ingestion with a random (often un-decodable) inner payload. + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.from = fuzzNodeNum(); + mp.to = (rngRange(2)) ? LOCAL_NODE : NODENUM_BROADCAST; + mp.id = rngNext(); + mp.rx_snr = (float)((int)rngRange(60) - 30); + mp.rx_rssi = (int32_t)rngRange(256) - 128; + 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)rngRange(80); + mp.decoded.payload.size = rngRange(sizeof(mp.decoded.payload.bytes) + 1); + rngFill(mp.decoded.payload.bytes, mp.decoded.payload.size); + nodeDB->updateFrom(mp); + } + } + // DB stayed internally consistent (count never ran past capacity). + TEST_ASSERT_TRUE_MESSAGE(mockNodeDB->getNumMeshNodes() <= (size_t)MAX_NUM_NODES, "NodeDB overran MAX_NUM_NODES"); +} + +// =========================================================================== +// Groups E8-E10 - remaining ProtobufModule handlers driven directly at +// handleReceivedProtobuf. We bypass the ProtobufModule base's allocReply/send +// machinery (calling the handler, not handleReceived), so no router is needed; +// the fixture's nodeDB/service/channels plus the auto-initialized nodeStatus/ +// powerStatus globals (defined in main.cpp) are enough. Each module derives from +// OSThread, so each shim is a function-local static (ThreadController lifetime - +// see the note in test_E2). Contract: no crash / no ASan finding on any input. +// =========================================================================== +class PositionModuleTestShim : public PositionModule +{ + public: + using PositionModule::handleReceivedProtobuf; +}; +class DeviceTelemetryModuleTestShim : public DeviceTelemetryModule +{ + public: + using DeviceTelemetryModule::handleReceivedProtobuf; +}; +class NeighborInfoModuleTestShim : public NeighborInfoModule +{ + public: + using NeighborInfoModule::handleReceivedProtobuf; +}; + +// Craft an RX MeshPacket header for a decoded-payload handler: adversarial from/to/id/hops, but a +// channel index kept in range (the router resolves and validates mp.channel before dispatch, so an +// out-of-range index can't reach a handler over the air - fuzzing it would test channels.getByIndex, +// not the handler). +static void fuzzRxHeader(meshtastic_MeshPacket &mp, meshtastic_PortNum portnum) +{ + mp.from = fuzzNodeNum(); + mp.to = (rngRange(2)) ? LOCAL_NODE : NODENUM_BROADCAST; + mp.id = rngNext(); + mp.channel = (uint8_t)rngRange(channels.getNumChannels()); + mp.rx_snr = (float)((int)rngRange(40) - 20); + mp.rx_rssi = -(int)rngRange(130); + mp.hop_start = (uint8_t)rngRange(8); // 0..7, wire-bounded + mp.hop_limit = (uint8_t)rngRange(8); + mp.want_ack = (rngRange(2) == 0); + mp.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + mp.decoded.portnum = portnum; +} + +void test_E8_position_handler_fuzz(void) +{ + printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE8)); + rngSeed(BASE_SEED ^ 0xE8); + + static PositionModuleTestShim shim; + + for (unsigned k = 0; k < 6000; k++) { + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + fuzzRxHeader(mp, meshtastic_PortNum_POSITION_APP); + // Occasionally claim to be from us (drives the fixed_position / setLocalPosition self-update path). + if (rngRange(4) == 0) + mp.from = LOCAL_NODE; + config.position.fixed_position = (rngRange(2) == 0); + + meshtastic_Position p = meshtastic_Position_init_zero; + p.latitude_i = (int32_t)rngNext(); + p.longitude_i = (int32_t)rngNext(); + p.altitude = (int32_t)rngNext(); + p.altitude_hae = (int32_t)rngNext(); + p.PDOP = rngNext(); + p.sats_in_view = rngNext(); + p.precision_bits = rngRange(40); // includes >32 (the default-precision fallback) + p.time = (rngRange(2) == 0) ? 0 : rngNext(); + p.timestamp = rngNext(); + (void)shim.handleReceivedProtobuf(mp, &p); + } + TEST_ASSERT_TRUE_MESSAGE(mockNodeDB->getNumMeshNodes() <= (size_t)MAX_NUM_NODES, "NodeDB overran MAX_NUM_NODES"); +} + +void test_E9_telemetry_handler_fuzz(void) +{ + printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xE9)); + rngSeed(BASE_SEED ^ 0xE9); + + static DeviceTelemetryModuleTestShim shim; + + for (unsigned k = 0; k < 6000; k++) { + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + fuzzRxHeader(mp, meshtastic_PortNum_TELEMETRY_APP); + + meshtastic_Telemetry t = meshtastic_Telemetry_init_zero; + t.time = rngNext(); + // Cycle across the telemetry variants; only device_metrics is consumed here, the rest must be + // ignored without touching uninitialized union members. + switch (rngRange(5)) { + case 0: + t.which_variant = meshtastic_Telemetry_device_metrics_tag; + t.variant.device_metrics.has_battery_level = (rngRange(2) == 0); + t.variant.device_metrics.battery_level = rngNext(); + t.variant.device_metrics.voltage = (float)((int)rngNext()); + t.variant.device_metrics.channel_utilization = (float)((int)rngNext()); + t.variant.device_metrics.air_util_tx = (float)((int)rngNext()); + t.variant.device_metrics.uptime_seconds = rngNext(); + break; + case 1: + t.which_variant = meshtastic_Telemetry_environment_metrics_tag; + t.variant.environment_metrics.temperature = (float)((int)rngNext()); + t.variant.environment_metrics.relative_humidity = (float)((int)rngNext()); + break; + case 2: + t.which_variant = meshtastic_Telemetry_air_quality_metrics_tag; + t.variant.air_quality_metrics.pm10_standard = rngNext(); + break; + case 3: + t.which_variant = meshtastic_Telemetry_power_metrics_tag; + t.variant.power_metrics.ch1_voltage = (float)((int)rngNext()); + break; + default: + t.which_variant = 0; // no variant set + break; + } + (void)shim.handleReceivedProtobuf(mp, &t); + } + TEST_ASSERT_TRUE_MESSAGE(mockNodeDB->getNumMeshNodes() <= (size_t)MAX_NUM_NODES, "NodeDB overran MAX_NUM_NODES"); +} + +void test_E10_neighborinfo_handler_fuzz(void) +{ + printf(" seed=0x%llx\n", (unsigned long long)(BASE_SEED ^ 0xEA)); + rngSeed(BASE_SEED ^ 0xEA); + + static const pb_size_t NB_MAX = + sizeof(((meshtastic_NeighborInfo *)0)->neighbors) / sizeof(((meshtastic_NeighborInfo *)0)->neighbors[0]); // 10 + + static NeighborInfoModuleTestShim shim; + + for (unsigned k = 0; k < 6000; k++) { + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + fuzzRxHeader(mp, meshtastic_PortNum_NEIGHBORINFO_APP); + + meshtastic_NeighborInfo nb = meshtastic_NeighborInfo_init_zero; + nb.node_id = fuzzNodeNum(); + nb.last_sent_by_id = fuzzNodeNum(); + nb.node_broadcast_interval_secs = rngNext(); + nb.neighbors_count = (pb_size_t)rngRange(NB_MAX + 1); // clamp as nanopb would on decode + for (pb_size_t i = 0; i < nb.neighbors_count; i++) { + nb.neighbors[i].node_id = fuzzNodeNum(); + nb.neighbors[i].snr = (float)((int)rngRange(40) - 20); + nb.neighbors[i].last_rx_time = rngNext(); + nb.neighbors[i].node_broadcast_interval_secs = rngNext(); + } + (void)shim.handleReceivedProtobuf(mp, &nb); + } + TEST_ASSERT_TRUE_MESSAGE(mockNodeDB->getNumMeshNodes() <= (size_t)MAX_NUM_NODES, "NodeDB overran MAX_NUM_NODES"); +} + void setup() { initializeTestEnvironment(); @@ -385,6 +795,26 @@ void setup() printf("\n=== Group E4: phone-forward gate ===\n"); RUN_TEST(test_E4_phone_forward_gate); + printf("\n=== Group E5: Admin dispatch fuzz ===\n"); + RUN_TEST(test_E5_admin_dispatch_fuzz); + +#if !MESHTASTIC_EXCLUDE_BEACON + printf("\n=== Group E6: Beacon listener fuzz ===\n"); + RUN_TEST(test_E6_beacon_listener_fuzz); +#endif + + printf("\n=== Group E7: NodeDB update fuzz ===\n"); + RUN_TEST(test_E7_nodedb_update_fuzz); + + printf("\n=== Group E8: Position handler fuzz ===\n"); + RUN_TEST(test_E8_position_handler_fuzz); + + printf("\n=== Group E9: Telemetry handler fuzz ===\n"); + RUN_TEST(test_E9_telemetry_handler_fuzz); + + printf("\n=== Group E10: NeighborInfo handler fuzz ===\n"); + RUN_TEST(test_E10_neighborinfo_handler_fuzz); + exit(UNITY_END()); } diff --git a/test/test_hop_scaling/test_main.cpp b/test/test_hop_scaling/test_main.cpp index 6fb71d511..aaa4e9ad2 100644 --- a/test/test_hop_scaling/test_main.cpp +++ b/test/test_hop_scaling/test_main.cpp @@ -106,6 +106,10 @@ static uint32_t makeDistributedNodeId(uint32_t baseId, uint32_t ordinal, uint32_ return baseId + salt + (ordinal * 33u); } +// Deterministic RNG (rngSeed/rngNext/rngRange) - shared seeded LCG. +#include "support/DeterministicRng.h" +static constexpr uint64_t FUZZ_SEED = 0x00F0B5CA1EULL; + // --------------------------------------------------------------------------- // Helpers - mesh topology builders // --------------------------------------------------------------------------- @@ -661,6 +665,57 @@ static void test_memory_layout() // Unity setup / teardown / main // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Fuzz - crafted-nodenum blitz of the hop histogram +// --------------------------------------------------------------------------- +// The scenario tests above check the scaling *math* with realistic topologies. This one is adversarial: +// it floods samplePacketForHistogram with node numbers chosen to maximize table churn (a tiny colliding +// pool, boundary values, broad random) plus denominator swings and hourly rolls, and asserts the fixed +// 128-entry table's invariants hold on every step. hopCount is kept in 0..MAX_HOP: the wire caps hops at +// 7 (3-bit protobuf field), so out-of-range hops are not a reachable input. Runs under ASan/LSan. +void test_fuzz_nodenum_blitz(void) +{ + TEST_MSG_FMT("=== Fuzz: crafted-nodenum histogram blitz (seed=0x%llx) ===", (unsigned long long)FUZZ_SEED); + rngSeed(FUZZ_SEED); + + static HopScalingTestShim shim; // static: OSThread-derived (Unity longjmp skips dtors on a failed assert) + shim.clear(); + shim.setHistogramDenominator(HopScalingModule::DENOM_MIN); // sample-all: maximum admission / churn + + for (unsigned k = 0; k < 40000; k++) { + NodeNum id; + switch (rngRange(4)) { + case 0: + id = rngEdgeNodeNum(&kLocalNode, 1); + break; // shared boundary pool (0/1/broadcast) + local node + case 1: + id = rngNext() & 0xFFu; + break; // tiny pool: forces hash reuse (update path) and collisions + default: + id = ((NodeNum)rngNext() << 1) ^ rngNext(); + break; // broad random + } + uint8_t hop = (uint8_t)rngRange(HopScalingModule::MAX_HOP + 1); // 0..7, wire-bounded + + shim.samplePacketForHistogram(id, hop); + + // Occasionally swing the sampling denominator and roll the hour to drive trim / eviction / + // the hourly rolling + scaling under sustained churn. Denominators stay powers of two (1..128) - + // the only states the module actually produces (its passesFilter uses hash & (denom-1)). + if (rngRange(256) == 0) + shim.setHistogramDenominator((uint8_t)(1u << rngRange(8))); // 1, 2, 4, ... 128 + if (rngRange(512) == 0) { + mockTime += ONE_HOUR_MS; + shim.rollHourTest(); + } + + // Invariants that must hold on every step regardless of input. + TEST_ASSERT_TRUE_MESSAGE(shim.getEntryCount() <= HopScalingModule::CAPACITY, "histogram overran CAPACITY"); + TEST_ASSERT_TRUE_MESSAGE(shim.getFillPercentage() <= 100, "fill percentage exceeded 100"); + TEST_ASSERT_TRUE_MESSAGE(shim.getLastRequiredHop() <= HOP_MAX, "required-hop recommendation exceeded HOP_MAX"); + } +} + void setUp(void) { if (!mockNodeDB) @@ -712,6 +767,9 @@ void setup() RUN_TEST(test_filtering_denom_steps_down_gradually); RUN_TEST(test_full_at_denom_max_drops_entry); + printf("\n=== Fuzz ===\n"); + RUN_TEST(test_fuzz_nodenum_blitz); + printf("\n=== Summary ===\n"); RUN_TEST(test_memory_layout); RUN_TEST(test_scenario_summary_output); diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index 509aa8402..6cca03fbd 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -24,6 +24,8 @@ #include "airtime.h" #include "modules/AdminModule.h" #include "modules/MeshBeaconModule.h" +#include "support/AdminModuleTestShim.h" +#include "support/MockMeshService.h" #include #include #include @@ -50,16 +52,8 @@ namespace constexpr NodeNum kLocalNode = 0xAAAA0001; constexpr NodeNum kRemoteNode = 0xBBBB0002; -// --------------------------------------------------------------------------- -// Minimal MockMeshService - stubs out side-effecting virtuals. -// handleToRadio is non-virtual so it runs the real implementation; we guard -// against the router->sendLocal path by setting a MockRouter below. -// --------------------------------------------------------------------------- -class MockMeshService : public MeshService -{ - public: - void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } -}; +// MockMeshService (test/support) stubs the side-effecting virtuals; handleToRadio is non-virtual so +// it runs the real implementation - the router->sendLocal path is guarded by MockRouter below. // --------------------------------------------------------------------------- // MockRouter: captures every packet handed to send() instead of transmitting. @@ -93,14 +87,7 @@ class MockRouter : public Router std::vector primaryAtSend; }; -// --------------------------------------------------------------------------- -// AdminModuleTestShim - exposes protected handleSetModuleConfig. -// --------------------------------------------------------------------------- -class AdminModuleTestShim : public AdminModule -{ - public: - using AdminModule::handleSetModuleConfig; -}; +// AdminModuleTestShim (test/support) exposes protected handleSetModuleConfig. // --------------------------------------------------------------------------- // MeshBeaconBroadcastModuleTestShim - exposes private internals for testing. diff --git a/test/test_mqtt/MQTT.cpp b/test/test_mqtt/MQTT.cpp index 7052b1bcd..3716e0a20 100644 --- a/test/test_mqtt/MQTT.cpp +++ b/test/test_mqtt/MQTT.cpp @@ -12,6 +12,8 @@ #include "mqtt/MQTT.h" #include "mqtt/ServiceEnvelope.h" +#include "support/DeterministicRng.h" // rngSeed/rngNext/rngByte/rngRange - shared seeded LCG (fuzz group) + #include #include @@ -275,6 +277,13 @@ class MQTTUnitTest : public MQTT size_t numBytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_ServiceEnvelope_msg, &env); mqttCallback(const_cast(topic.str().c_str()), bytes, numBytes); } + // Feed arbitrary bytes straight into the subscription callback - the non-RF ingress a malicious or + // broken broker could push. Mirrors publish()'s final mqttCallback() call but with an unconstrained + // payload, so it exercises DecodedServiceEnvelope decode + onReceiveProto with garbage. + void deliverRaw(const std::string &topic, const uint8_t *bytes, size_t n) + { + mqttCallback(const_cast(topic.c_str()), const_cast(bytes), (unsigned int)n); + } static void restart() { if (mqtt != NULL) { @@ -963,6 +972,65 @@ void test_configWithTLSEnabled(void) #endif } +// =========================================================================== +// Fuzz - adversarial MQTT downlink ingress (the non-RF path a broker can push) +// =========================================================================== +// Blitzes the onReceiveProto() chain with (a) raw garbage that must fail envelope decode cleanly and +// (b) well-formed envelopes wrapping crafted inner packets. Contract: no crash, at most one enqueue per envelope. +constexpr uint64_t MQTT_FUZZ_SEED = 0x00E3A71C0FULL; + +void test_receiveFuzzServiceEnvelope(void) +{ + printf(" seed=0x%llx\n", (unsigned long long)MQTT_FUZZ_SEED); + rngSeed(MQTT_FUZZ_SEED); + + const char *channelIds[] = {"test", "PKI", "nope", ""}; + const char *gatewayIds[] = {"!12345678", "!87654321", "!00000000"}; // [0] == our node id -> self path + + for (unsigned k = 0; k < 4000; k++) { + if (rngRange(3) == 0) { + // (a) Raw bytes: mostly random, must be rejected at DecodedServiceEnvelope without crashing. + uint8_t raw[128]; + size_t n = rngRange(sizeof(raw) + 1); + rngFill(raw, n); + unitTest->deliverRaw("msh/2/e/test/!87654321", raw, n); + } else { + // (b) Well-formed envelope around a crafted inner packet. + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = (rngRange(3) == 0) ? myNodeInfo.my_node_num : rngNext(); // self origin hits isFromUs + p.to = (rngRange(2)) ? myNodeInfo.my_node_num : rngNext(); + p.id = rngNext(); + p.channel = (uint8_t)rngByte(); + p.hop_limit = (uint8_t)rngRange(10); // includes > HOP_MAX (the invalid-hop reject path) + p.hop_start = (uint8_t)rngRange(10); + p.want_ack = (rngRange(2) == 0); + p.pki_encrypted = (rngRange(2) == 0); + if (rngRange(2) == 0) { + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = (rngRange(6) == 0) ? meshtastic_PortNum_ADMIN_APP : (meshtastic_PortNum)rngRange(80); + p.decoded.want_response = (rngRange(2) == 0); + p.decoded.has_bitfield = (rngRange(2) == 0); + p.decoded.bitfield = (uint32_t)rngNext(); + p.decoded.payload.size = rngRange(64); + rngFill(p.decoded.payload.bytes, p.decoded.payload.size); + } else { + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = rngRange(64); + rngFill(p.encrypted.bytes, p.encrypted.size); + } + unitTest->publish(&p, gatewayIds[rngRange(3)], channelIds[rngRange(4)]); + } + + // A single envelope reaches at most one enqueueReceivedMessage; more would be a routing bug. + TEST_ASSERT_TRUE_MESSAGE(mockRouter->packets_.size() <= 1, "MQTT downlink enqueued >1 packet per envelope"); + // Drain capture lists so 4000 iterations stay bounded (values already released to their pools). + mockRouter->packets_.clear(); + mockRoutingModule->ackNacks_.clear(); + mockMeshService->messages_.clear(); + mockMeshService->notifications_.clear(); + } +} + void setup() { initializeTestEnvironment(); @@ -998,6 +1066,7 @@ void setup() #endif RUN_TEST(test_receiveIgnoresUnexpectedFields); RUN_TEST(test_receiveIgnoresInvalidHopLimit); + RUN_TEST(test_receiveFuzzServiceEnvelope); RUN_TEST(test_publishTextMessageDirect); RUN_TEST(test_publishTextMessageWithProxy); RUN_TEST(test_reportToMapDefaultImprecise); diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index a0daabd6d..7d0d9565a 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -5,12 +5,7 @@ #include #include "meshtastic/config.pb.h" - -class MockMeshService : public MeshService -{ - public: - void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } -}; +#include "support/MockMeshService.h" static MockMeshService *mockMeshService; diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 5ab7bd3e3..c203438f4 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -18,6 +18,7 @@ #include "mesh/NodeDB.h" #include "mesh/Router.h" #include "modules/TrafficManagementModule.h" +#include "support/DeterministicRng.h" // rngSeed/rngNext/rngRange - shared seeded LCG #include #include #include @@ -1600,6 +1601,92 @@ static void test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp(void) // channels always have a public PSK so getPositionPrecisionForChannel caps at 15. TEST_ASSERT_EQUAL_UINT32(13, out.precision_bits); } + +// --------------------------------------------------------------------------- +// Fuzz - crafted-nodenum blitz of the unified cache +// --------------------------------------------------------------------------- +// Floods handleReceived/alterReceived with crafted packets over a tiny node pool so the fixed-size +// cache churns hard while the virtual clock sweeps the rate/unknown/position windows; no crash, counters bounded. +static constexpr uint64_t FUZZ_SEED = 0x00D07E5701ULL; + +static void test_tm_fuzz_nodenum_blitz(void) +{ + printf(" seed=0x%llx\n", (unsigned long long)FUZZ_SEED); + rngSeed(FUZZ_SEED); + + // Activate the cache-tracked windows (the crafted-nodenum target). The nodeinfo direct-response + // path (nodeinfo_direct_response_max_hops) is left OFF: it calls service->sendToMesh, and driving + // that at fuzz volume needs a fully-wired MeshService/phone queue this fixture doesn't provide - the + // deterministic test_tm_nodeinfo_directResponse_* tests cover it with single packets instead. + moduleConfig.traffic_management.rate_limit_window_secs = 60; + moduleConfig.traffic_management.rate_limit_max_packets = 3; + moduleConfig.traffic_management.unknown_packet_threshold = 4; + moduleConfig.traffic_management.position_min_interval_secs = 300; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + installWellKnownPrimaryChannel(); + + static TrafficManagementModuleTestShim module; // static: OSThread-derived (see note in test_fuzz_packets E2) + + // Shared boundary pool (0/1/broadcast) plus this suite's well-known nodes. + const NodeNum wellKnown[] = {kLocalNode, kRemoteNode, kTargetNode}; + const size_t wellKnownN = sizeof(wellKnown) / sizeof(wellKnown[0]); + const meshtastic_PortNum ports[] = { + meshtastic_PortNum_TEXT_MESSAGE_APP, meshtastic_PortNum_NODEINFO_APP, meshtastic_PortNum_POSITION_APP, + meshtastic_PortNum_ROUTING_APP, meshtastic_PortNum_ADMIN_APP, meshtastic_PortNum_TELEMETRY_APP, + }; + const size_t portsN = sizeof(ports) / sizeof(ports[0]); + + const unsigned ITERS = 30000; + for (unsigned k = 0; k < ITERS; k++) { + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = (rngRange(8) == 0) ? (NodeNum)rngNext() : rngEdgeNodeNum(wellKnown, wellKnownN); + p.to = rngEdgeNodeNum(wellKnown, wellKnownN); + p.id = rngNext(); + p.channel = 0; + p.hop_start = (uint8_t)rngRange(8); // 0..7, wire-bounded + p.hop_limit = (uint8_t)rngRange(8); + p.decoded.want_response = (rngRange(2) == 0); + + if (rngRange(5) == 0) { + // Undecoded / unknown packet - exercises the unknown-packet threshold path. + p.which_payload_variant = meshtastic_MeshPacket_encrypted_tag; + p.encrypted.size = rngRange(sizeof(p.encrypted.bytes) + 1); + rngFill(p.encrypted.bytes, p.encrypted.size); + } else { + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = (rngRange(8) == 0) ? (meshtastic_PortNum)rngRange(80) : ports[rngRange(portsN)]; + p.decoded.has_bitfield = true; + p.decoded.bitfield = (uint32_t)rngNext(); + if (p.decoded.portnum == meshtastic_PortNum_POSITION_APP && rngRange(2)) { + meshtastic_Position pos = meshtastic_Position_init_zero; + pos.has_latitude_i = true; + pos.has_longitude_i = true; + pos.latitude_i = (int32_t)rngNext(); + pos.longitude_i = (int32_t)rngNext(); + pos.precision_bits = rngRange(40); // includes >32 (the default-precision fallback) + p.decoded.payload.size = + pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_Position_msg, &pos); + } else { + // Random payload bytes: TMM's nested User/Position decode must fail cleanly. + p.decoded.payload.size = rngRange(sizeof(p.decoded.payload.bytes) + 1); + rngFill(p.decoded.payload.bytes, p.decoded.payload.size); + } + } + + (void)module.handleReceived(p); + if (rngRange(3) == 0) + module.alterReceived(p); + + // Advance the virtual clock so rate / unknown / position windows open and close under churn. + if (rngRange(16) == 0) + TrafficManagementModule::s_testNowMs += (rngRange(120) + 1) * 1000u; + if (rngRange(1024) == 0) + (void)module.runOnce(); // maintenance sweep: cache aging / eviction + } + + // The cache never inspected more packets than we fed, and the run reached here without an ASan fault. + TEST_ASSERT_TRUE_MESSAGE(module.getStats().packets_inspected <= ITERS, "packets_inspected overcounted"); +} } // namespace void setUp(void) @@ -1669,6 +1756,7 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp); RUN_TEST(test_tm_unknownRole_noDbEntry_appliesFullInterval); RUN_TEST(test_tm_unknownRole_noUserBit_appliesFullInterval); + RUN_TEST(test_tm_fuzz_nodenum_blitz); exit(UNITY_END()); }