mirror of
https://github.com/meshtastic/firmware.git
synced 2026-10-02 03:05:20 -04:00
PhoneAPI: gate local admin on the connection, not the wire from (#11033)
* PhoneAPI: gate local admin on the connection, not the wire from The lockdown admin check in handleToRadioPacket only ran when p.from == 0. from is a client-supplied wire field, and MeshService::handleToRadio rewrites it to 0 before AdminModule sees the packet. A client could therefore set from != 0 to skip the !getAdminAuthorized() drop, then have the packet normalized back to a local-admin identity and executed - unauthorized admin from an unauthorized connection. Every packet in handleToRadioPacket already comes from the local connection, so locality is a property of the connection, not of from. Move the decision into classifyLocalAdminPacket(), which ignores from and keys only on the admin variant and the connection's authorization: lockdown_auth is delivered inline, any other admin from an unauthorized connection is dropped, authorized admin passes through. The classifier is compiled unconditionally and unit-tested; the guarded caller (built only in the nRF52 lockdown config) calls it. Test: an unauthorized connection's ADMIN_APP packet with from != 0 is classified DropUnauthorized. * PhoneAPI: wipe the encoded lockdown passphrase, shorten comments --------- Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
This commit is contained in:
1 parent
ed3afdbc61
commit
a7fde715c6
3 files changed
+121
-18
No files matched your search
+38
-18
@@ -1697,6 +1697,19 @@ bool PhoneAPI::wasSeenRecently(uint32_t id)
|
||||
return false;
|
||||
}
|
||||
|
||||
PhoneAPI::LocalAdminGate PhoneAPI::classifyLocalAdminPacket(const meshtastic_MeshPacket &p, bool adminAuthorized,
|
||||
meshtastic_AdminMessage &outAdmin)
|
||||
{
|
||||
if (p.which_payload_variant != meshtastic_MeshPacket_decoded_tag || p.decoded.portnum != meshtastic_PortNum_ADMIN_APP)
|
||||
return LocalAdminGate::NotAdmin;
|
||||
outAdmin = meshtastic_AdminMessage_init_zero;
|
||||
if (!pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &outAdmin))
|
||||
return LocalAdminGate::NotAdmin; // undecodable: let the normal reject path respond
|
||||
if (outAdmin.which_payload_variant == meshtastic_AdminMessage_lockdown_auth_tag)
|
||||
return LocalAdminGate::LockdownAuth; // the passphrase itself; authenticate regardless of prior state
|
||||
return adminAuthorized ? LocalAdminGate::AuthorizedPassThrough : LocalAdminGate::DropUnauthorized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a packet that the phone wants us to send. It is our responsibility to free the packet to the pool
|
||||
*/
|
||||
@@ -1721,26 +1734,33 @@ bool PhoneAPI::handleToRadioPacket(meshtastic_MeshPacket &p)
|
||||
// the gate here closes that race and covers H6/H7 from the
|
||||
// audit: get_config_request and set_config from unauthed
|
||||
// clients no longer reach AdminModule at all.
|
||||
if (p.from == 0 && p.which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||
p.decoded.portnum == meshtastic_PortNum_ADMIN_APP) {
|
||||
// Gate on the connection, not the wire `from`, which a client can forge to a non-zero value to
|
||||
// bypass the check before MeshService normalizes it back to a local identity.
|
||||
// Scope the decoded admin message: it holds the plaintext passphrase, so bound its lifetime.
|
||||
{
|
||||
meshtastic_AdminMessage admin = meshtastic_AdminMessage_init_zero;
|
||||
if (pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &admin)) {
|
||||
if (admin.which_payload_variant == meshtastic_AdminMessage_lockdown_auth_tag) {
|
||||
handleLockdownAuthInline(admin.lockdown_auth);
|
||||
// Wipe the decoded passphrase scratch - the byte array in
|
||||
// p.decoded.payload.bytes is wiped by handleLockdownAuthInline.
|
||||
volatile uint8_t *adminVol = const_cast<volatile uint8_t *>(admin.lockdown_auth.passphrase.bytes);
|
||||
for (size_t i = 0; i < sizeof(admin.lockdown_auth.passphrase.bytes); i++)
|
||||
adminVol[i] = 0;
|
||||
return true;
|
||||
}
|
||||
if (!getAdminAuthorized()) {
|
||||
LOG_WARN("Lockdown: dropping admin payload variant=%d from unauthorized connection", admin.which_payload_variant);
|
||||
return false;
|
||||
}
|
||||
switch (classifyLocalAdminPacket(p, getAdminAuthorized(), admin)) {
|
||||
case LocalAdminGate::LockdownAuth: {
|
||||
handleLockdownAuthInline(admin.lockdown_auth);
|
||||
// The encoded wipe is the security-critical one: nothing else clears the passphrase from
|
||||
// the packet buffer. handleLockdownAuthInline already zeroes the decoded copy up to its
|
||||
// size; re-zero the full scratch capacity here as defense in depth.
|
||||
volatile uint8_t *adminVol = const_cast<volatile uint8_t *>(admin.lockdown_auth.passphrase.bytes);
|
||||
for (size_t i = 0; i < sizeof(admin.lockdown_auth.passphrase.bytes); i++)
|
||||
adminVol[i] = 0;
|
||||
volatile uint8_t *encodedVol = const_cast<volatile uint8_t *>(p.decoded.payload.bytes);
|
||||
for (size_t i = 0; i < sizeof(p.decoded.payload.bytes); i++)
|
||||
encodedVol[i] = 0;
|
||||
p.decoded.payload.size = 0; // keep the length consistent with the wiped buffer
|
||||
return true;
|
||||
}
|
||||
case LocalAdminGate::DropUnauthorized:
|
||||
LOG_WARN("Lockdown: dropping admin payload variant=%d from unauthorized connection", admin.which_payload_variant);
|
||||
return false;
|
||||
case LocalAdminGate::NotAdmin:
|
||||
case LocalAdminGate::AuthorizedPassThrough:
|
||||
break; // normal handling
|
||||
}
|
||||
// pb_decode failure: fall through to normal handling so the
|
||||
// regular Router/AdminModule reject path can respond.
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
Reference in new issue
Block a user