fix(MQTT): don't let a disabled channel gate downlink, PKI, or uplink - #11473
fix(MQTT): don't let a disabled channel gate downlink, PKI, or uplink#11473h3lix1 wants to merge 2 commits into
Conversation
⚡ Try this PR in the Web FlasherNote Building this pull request… the flash button, badges and supported-board |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Three places in MQTT.cpp test settings.downlink_enabled on its own, without the role and has_settings guards that Channels::anyMqttEnabled() uses: the downlink gate in onReceiveProto(), the anyChannelHasDownlink scan that unlocks the PKI topic, and the subscribe loop. A disabled channel keeps its settings. ensureLicensedOperation() takes a copy of the channel, sets role to DISABLED and clears only the PSK, then writes it back, so a licensed operator's "admin" channel ends up disabled with its name and downlink_enabled intact. fixupChannel() only zeroes settings when has_settings is false, so nothing cleans it up afterwards. Plain admin traffic setting a role to DISABLED leaves the same residue. The node then subscribes to that channel's topic, naming it to the broker, accepts downlink on it, and stamps the packet with the disabled slot's index. That index has no key (Channels::getKey returns -1 for DISABLED), so a rebroadcast dies with NO_CHANNEL, and the phone is shown traffic on a channel the user turned off. The same gap lets a disabled slot alone satisfy the PKI unlock. Resolution stays first-match-wins over getGlobalId(), matching getByName(). It must: duplicate global ids are legal, because Channels::getName() maps every blank name to the modem-preset name, so any two blank-named slots collide. Rejecting that as ambiguous would drop all non-PKI downlink on stock configs, and the third test pins that down. Two of the three tests fail on develop. The third passes on develop and is there to stop a future rework reintroducing an ambiguity rejection.
7307706 to
fafe9ce
Compare
Same missing guard as the downlink side, on the uplink scan in onSend(). It matters more here than it looks, because for a PKI packet this scan is the only uplink gate there is: the per-channel check below it is short-circuited by isPKIEncrypted, and perhapsEncode's PKI branch never consults the channel at all, so nothing else can stop the publish. The result is that a node whose owner turned uplink off on every live channel still publishes every PKI DM it originates or relays, because one stale DISABLED slot with uplink_enabled left set satisfies the scan. ensureLicensedOperation() manufactures exactly that slot and it latches: once role is DISABLED and the PSK is empty its guard never fires again. The payload stays encrypted, but the envelope carries sender, recipient, packet id and timestamp, which is the DM social graph, and possibly to the public default broker. Deliberately not touching the per-channel gate below. A disabled chIndex does reach it, since the PKI branch of perhapsEncode does not validate the index, but isPKIEncrypted then selects the literal "PKI" as the channel id, so getGlobalId(chIndex) is never evaluated and no disabled channel's name is published. Adding a guard there would be dead code. Bounding the loop by getNumChannels() rather than a hardcoded 7 is cleanup, not a fix: channels_count is forced to MAX_NUM_CHANNELS on every device, so the out-of-range iterations only happen under the native test harness, where they emit an ERROR line each. It matches the idiom the rest of the file already uses. The root cause is arguably that ensureLicensedOperation() zeroes only the PSK when it forces a channel to DISABLED, leaving a half-scrubbed slot. Left alone.
Rewritten. The earlier version of this PR argued that
getByName()'s case-insensitive match andprimary-channel fallback let the wrong channel answer. That was wrong: the existing gate re-checks
with a case-sensitive
strcmpagainstgetGlobalId(ch.index), so near-misses and the primaryfallback both fail closed. Sorry for the noise. The real defect is a different one in the same lines.
Four places in
MQTT.cpptestdownlink_enabledoruplink_enabledon their own, without theroleandhas_settingsguards thatChannels::anyMqttEnabled()uses a few files over: thedownlink gate in
onReceiveProto(), theanyChannelHasDownlinkscan that unlocks the PKI topic, thesubscribe loop in
sendSubscriptions(), and the uplink scan inonSend().A disabled channel keeps its settings.
ensureLicensedOperation()takes a copy of the channel, setsroletoDISABLEDand clears only the PSK, then writes it back, so a licensed operator'sadminchannel ends up disabled with its name and
downlink_enabledintact.fixupChannel()only zeroessettings when
has_settingsis false, so nothing cleans it up. Plain admin traffic that sets a roleto
DISABLEDleaves the same residue.The node then subscribes to that channel's topic, which also names it to the broker in the SUBSCRIBE,
accepts downlink on it, and stamps the packet with the disabled slot's index. That index has no key,
since
Channels::getKey()returns -1 forDISABLED, so a rebroadcast attempt dies withNO_CHANNELand the phone is shown traffic on a channel the user turned off. The same gap lets adisabled slot on its own satisfy the PKI unlock.
The uplink side is the worse half
The same guard is missing on the "does any channel want uplink" scan in
onSend(), and there it isnot one gate among several. For a PKI packet it is the only one: the per-channel
uplink_enabledcheck below it is short-circuited by
isPKIEncrypted, andperhapsEncode()'s PKI branch neverconsults the channel at all, so nothing else can stop the publish.
So a node whose owner turned uplink off on every live channel still publishes every PKI DM it
originates or relays, because that one stale disabled slot satisfies the scan. The payload stays
encrypted, but the envelope carries sender, recipient, packet id and timestamp, and it may be going
to the public default broker. It also silently defeats the exact setting a privacy-conscious operator
would reach for.
I deliberately left the per-channel gate alone. A disabled
chIndexdoes reach it, because the PKIbranch of
perhapsEncode()does not validate the index, butisPKIEncryptedthen selects theliteral
"PKI"as the channel id, sogetGlobalId(chIndex)is never evaluated and no disabledchannel's name is published. A guard there would be dead code.
Bounding that loop by
getNumChannels()instead of a hardcoded7is cleanup rather than a fix.channels_countis forced toMAX_NUM_CHANNELSon every device, so the out-of-range iterations onlyhappen under the native test harness, where each one logs an ERROR. It matches what the rest of the
file already does.
Why resolution stays first-match-wins
Duplicate global ids are legal.
Channels::getName()maps every blank name to the modem-preset namewith no index guard, so any two blank-named active slots resolve to the same id, and
fixupChannel()rewrites a channel literally named
Defaultto blank, which is a legacy-compat path that existsbecause those configs are out there. Rejecting a duplicate as ambiguous would drop all non-PKI
downlink on stock configurations. The third test pins that down so a later rework does not
reintroduce it.
Comparing the raw
settings.nameinstead is also wrong: the channel's on-the-wire identity isgetGlobalId(), which is whatsendSubscriptions()subscribes with and whatonSend()publishesunder, so matching on the raw name would make every blank-named channel unmatchable, including the
stock default primary.
Behaviour change worth naming
Folding
downlink_enabledinto the scan slightly widens the gate in one case: where a lower-indexslot shares a name but has downlink disabled and a higher-index slot has it enabled, develop drops
and this accepts. That is the correct direction, since
sendSubscriptions()subscribed to that exacttopic on the higher-index slot's behalf, but it is a real difference rather than pure equivalence.
The root cause is arguably upstream of all of this:
ensureLicensedOperation()zeroes only the PSKwhen it forces a channel to
DISABLED, leaving a half-scrubbed slot behind, and it latches, since onthe next pass its own guard no longer fires. Scrubbing the settings block there would remove the
source rather than the symptom. Left alone here, happy to follow up.
Tests
test_receiveIgnoresDisabledChannelDownlink,test_receiveIgnoresPkiWhenOnlyDisabledChannelHasDownlinkand
test_pkiNotUplinkedWhenOnlyDisabledChannelHasUplinkall fail on develop withExpected TRUE Was FALSE.test_receiveAcceptsDownlinkWhenTwoChannelsShareAGlobalIdpasses ondevelop and is the regression guard described above.
Testing: builds clean on
heltec-v4, 61/61 intest_mqttonnative-macos. Not exercised against alive broker.
🤖 Generated with Claude Code
🤝 Attestations