MeshMonitor Security Advisory β May 2026 β
Status: Fixed on main (pending release). Reporter: External researcher (anonymous-by-request). Disclosure: Coordinated; researcher has been credited in PR descriptions.
This advisory documents four authorization issues in the MeshMonitor REST API: three high-severity findings reachable by anonymous callers under the standard public-viewer configuration (MM-SEC-1/2/3) and one medium-severity authenticated-user privilege escalation (MM-SEC-4).
MM-SEC-1 β Anonymous disclosure of VAPID private key (and other settings) via GET /api/settings β
Severity: High. Affected versions: All releases up to and including v4.2.0. Fixed in: PR #2904 (commit on main); shipping in next release.
Issue β
GET /api/settings returned every row from the settings table that wasn't source:-prefixed, with no permission gate. The push-notification service auto-generates a VAPID keypair on first start and persists both the public and private keys into that same table. Effect: any unauthenticated visitor could retrieve the deployment's VAPID private key.
Other secret-bearing keys exposed by the same path:
securityDigestAppriseUrlβ Apprise URLs commonly embed SMTP / Slack / Discord webhook / Telegram credentialsanalyticsConfigβ provider tokens
Impact β
With the VAPID private key plus a subscriber's endpoint/p256dh/auth, an attacker can deliver arbitrary push notifications to that subscriber's browser under the legitimate site's name and icon. This is a high-quality phishing vector because push notifications bypass spam filters and surface on the lock screen / notification tray.
The subscription material is not currently exposed via any HTTP route on main, and is excluded from system-backup tarballs (locked in by systemBackupService.tables.test.ts). Realistic exploitation therefore requires the attacker to obtain that material via some other path (a future bug, server-side compromise, or out-of-band leak).
Operator mitigation (pre-patch) β
- Set
VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY,VAPID_SUBJECTvia environment variables. - Rotate the auto-generated key by deleting the three
vapid_*rows from thesettingstable and restarting. Existing browser subscriptions are invalidated; clients re-subscribe transparently on next visit. - Block
GET /api/settingsat the reverse proxy for unauthenticated callers.
Fix β
SECRET_SETTINGS_KEYS (explicit) + SECRET_SETTINGS_KEY_PATTERN (tail regex *_private_key|*_secret|*_token) drive a stripSecretSettings(map, isAdmin) helper applied to both the global and source-merged response shapes. Public VAPID key is still returned (browsers need it to subscribe).
MM-SEC-2 β Anonymous disclosure of all channel PSKs via /api/channels and /api/poll β
Severity: High. Affected versions: All releases up to and including v4.2.0. Fixed in: PR #2905; shipping in next release.
Issue β
GET /api/channels, GET /api/channels/all, and the /api/poll channels section returned raw getAllChannels() rows verbatim β including the 32-byte psk symmetric key for every channel. The endpoints gated on channel_0:read, which is granted to anonymous callers in the standard public-viewer configuration.
The channels section of /api/poll correctly applied per-channel visibility filtering (so hidden channels were omitted from the channels array) but still pushed the full DB row of permitted channels including their PSKs.
Impact β
A Meshtastic channel PSK is the symmetric key that both authenticates and encrypts traffic on that channel. Disclosure lets an attacker:
- decrypt all currently observable on-air traffic on the channel
- decrypt all previously recorded traffic
- inject signed traffic indistinguishable from legitimate users
Operator mitigation (pre-patch) β
Revoke channel_0:read from the anonymous user. This breaks the public dashboard for logged-out visitors but is the only way to fully close the leak without code changes.
Fix β
transformChannel (with getRoleName) hoisted from routes/v1/channels.ts into a shared src/server/utils/channelView.ts module. The whitelist explicitly omits psk and exposes a derived pskSet: boolean so callers can answer "is a PSK configured?" without seeing the key. The legacy /api/channels, /api/channels/all, and the /api/poll channels section all route through this helper now. Static channel_0:read gate replaced with a per-row hasPermission(user, channel_${id}, read) check; admins always see all.
MM-SEC-3 β Anonymous disclosure of hidden-channel message content via /api/poll β
Severity: High. Affected versions: All releases up to and including v4.2.0. Fixed in: PR #2906; shipping in next release.
Issue β
The /api/poll messages section, GET /api/messages, and GET /api/messages/unread-counts all gated on channel_0:read || messages:read and then returned message content / unread counts spanning every channel. The only filter applied to the messages array was msg.channel !== -1 (DM exclusion).
Sibling sections of the same poll handler (channels, unread-counts) already correctly applied a per-channel read filter; the messages section did not.
Impact β
A user with channel_0:read (anonymous in default config) received the full text of messages on hidden channels. Reproduced by the researcher: 1 visible channel, 84 messages spanning 3 channels including the operator's hidden one.
Operator mitigation (pre-patch) β
Revoke channel_0:read from the anonymous user (same as MM-SEC-2). Operators who have not configured any hidden channels are not exposed to MM-SEC-3 specifically, but PSK disclosure under MM-SEC-2 still applies.
Fix β
Each of the three sites now pre-computes an authorizedChannelIds: Set<number> from per-channel read permissions, then filters: DMs require messages:read; channel messages require both the legacy gate AND authorizedChannelIds.has(msg.channel). Same approach applied to the unread-counts handler.
MM-SEC-4 β Channel-mutator privilege escalation between authenticated users β
Severity: Medium (authenticated-user only; not anonymous-exploitable). Affected versions: All releases up to and including v4.2.0. Fixed in: PR #2907; shipping in next release.
Issue β
Five channel-mutation endpoints all gated on a static channel_0:read|write permission while operating on :id from the URL:
GET /api/channels/:id/exportβ gatechannel_0:read, returns the actual PSKPUT /api/channels/:idβ gatechannel_0:writeDELETE /api/channels/:idβ gatechannel_0:writePOST /api/channels/:slotId/importβ gatechannel_0:writePOST /api/channels/reorderβ gatechannel_0:write, mutates every slot
A user granted only channel_0:write could rename, re-PSK, delete, import-into, or reorder any channel β including channels the operator deliberately walled off via the per-channel permission model.
Impact β
Authenticated-user-only privilege escalation. Anonymous defaults do not grant write permissions, so this is not anonymous-exploitable. Severity is medium because it requires a compromised or coerced authenticated account that already has at least channel_0:write.
Operator mitigation (pre-patch) β
Audit accounts that hold channel_0:write and revoke from any user that was not intended to have full per-channel write access.
Fix β
Each endpoint now uses requireAuth() plus a per-row hasPermission(req.user, channel_${id}, ...) check using the URL's actual id. For reorder, every slot whose contents change requires write permission; permutations are cycle-closed so checking destination slots covers source slots. Admins always pass.
MM-SEC-5 β Authenticated disclosure of local-node PKI private key via GET /api/device/security-keys β
Severity: High. Affected versions: All releases up to and including v4.2.1. Fixed in: PR (this commit on main); shipping in next release. Reporter: External researcher (follow-on audit).
Issue β
GET /api/device/security-keys returned the local node's actualDeviceConfig.security blob β both the public key and the base64-encoded private key β to any authenticated caller. The route's gate was requireAuth() only; no resource permission was checked. The route source comment named the intended property ("Private key is sensitive - requires authentication") but the gate did not enforce admin scope.
Impact β
The local node's PKI private key permits the holder to decrypt PKI-encrypted DMs received by the local node, forge signed packets from the local node (NodeInfo, position broadcasts, channel-signed payloads, admin-channel responses), and impersonate the local node to any party that holds its public key. The device private key is broader-scoped than a channel PSK β a PSK authenticates one channel, the device key authenticates the device across every PKI interaction.
Operator mitigation (pre-patch) β
Audit user accounts and disable any non-admin account whose is_active is true. Block GET /api/device/security-keys at the reverse proxy for non-admin sessions until the patch is deployed.
Fix β
Replace requireAuth() with requireAdmin() on apiRouter.get('/device/security-keys', β¦) in src/server/server.ts. The route now matches the rest of the admin-only device surface (/admin/*, /push/vapid-subject).
MM-SEC-6 β Cross-channel PSK disclosure via GET /api/channels/debug β
Severity: Medium. Affected versions: All releases up to and including v4.2.1. Fixed in: PR (this commit on main); shipping in next release. Reporter: External researcher (follow-on audit).
Issue β
GET /api/channels/debug was a SELECT * FROM channels pass-through (databaseService.channels.getAllChannels()) gated on the unrelated messages:read permission. Any caller holding messages:read received the raw 32-byte psk for every channel, bypassing both the per-channel channel_${id}:read gate and the transformChannel projection that MM-SEC-2 established as the canonical pattern for read-class channel endpoints. Deployments that grant messages:read to anonymous made it anonymous-exploitable.
Impact β
Same as MM-SEC-2: PSK disclosure permits decryption of on-air channel traffic and injection of signed traffic on every disclosed channel.
Operator mitigation (pre-patch) β
Block GET /api/channels/debug at the reverse proxy. The route had no UI consumers β /api/channels and /api/channels/all cover the legitimate use cases.
Fix β
Route deleted. Comment in src/server/server.ts records why; the api-exercise smoke test (tests/api-exercise-test.sh) drops its /channels/debug check.
MM-SEC-7 β Cross-channel PSK disclosure via GET /api/sources/:id/channels β
Severity: Medium. Affected versions: All releases up to and including v4.2.1. Fixed in: PR (this commit on main); shipping in next release. Reporter: External researcher (follow-on audit).
Issue β
Same root cause as MM-SEC-2/MM-SEC-6 β databaseService.channels.getAllChannels(sourceId) was passed straight to res.json() with psk intact. The route's gate (messages:read, scoped to the URL's source) is unrelated to channel cryptographic material. PR #2905 patched the three sibling endpoints in server.ts but missed this one, which lives in src/server/routes/sourceRoutes.ts.
Impact β
Identical to MM-SEC-6.
Operator mitigation (pre-patch) β
Block GET /api/sources/:id/channels at the reverse proxy until the patch is deployed; non-source-aware clients should keep using /api/channels.
Fix β
The route now uses optionalAuth() plus a per-row channel_${id}:read check scoped to the URL's source, then projects through transformChannel so the raw PSK is never serialized. Admins still see all channels (no PSK in any case).
MM-SEC-8 β Inconsistent credential strip on GET /api/sources/:id β
Severity: Low. Affected versions: All releases up to and including v4.2.1. Fixed in: PR (this commit on main); shipping in next release. Reporter: External researcher (follow-on audit).
Issue β
GET /api/sources (list) destructures password and apiKey out of each source's config blob before responding (sourceRoutes.ts:54). The adjacent GET /api/sources/:id (singular) returned the raw row, including credentials, to any caller with sources:read. The two routes treated the same data class differently.
Impact β
Low β sources:read is not granted to anonymous in the standard public-viewer config, and the resource description for sources (src/types/permission.ts) does not explicitly say credentials are out of scope. Filed because two adjacent routes in the same file disagreed on the strip; the list endpoint's void password; void apiKey; // intentionally stripped comment was the stronger signal, and the MM-SEC-1 pattern (secrets are admin-only regardless of resource grant) aligns with that.
Fix β
Both endpoints now route through a shared stripSourceSecrets(source, isAdmin) helper. Admins still receive the full record (the source-edit UI re-posts the same blob it loaded); everyone else gets password and apiKey removed from config. A single helper prevents the inconsistency from recurring.
Coverage that's locked in β
src/server/utils/channelView.test.tsβ assertstransformChannelnever includespsk, exposespskSet, and the whitelist is exact.src/server/routes/settingsRoutes.test.tsβ three cases covering anonymous, non-admin, and admin paths through the secret strip.src/server/services/systemBackupService.tables.test.tsβ assertspush_subscriptions,sessions, andbackup_historyare never in the system-backup allowlist (lock-in for MM-SEC-1 footnote 1).src/server/routes/sourceRoutes.security.test.tsβ MM-SEC-7 (PSK never serialized; per-channel filter enforced for non-admins; admin still sees all channels) and MM-SEC-8 (admins receivepassword/apiKey, non-admins do not, on both list and singular endpoints).src/server/routes/sourceRoutes.permissions.test.tsβ updated for MM-SEC-7's new gate (/sourceB/channelsreturns200 [], not403).- MM-SEC-5 (
/api/device/security-keys) and MM-SEC-6 (/api/channels/debugdeletion) live in theserver.tsmonolith and are exercised bytests/api-exercise-test.shplus the manual reproduction in this advisory; lifting them into Vitest is tracked under "Outstanding" below.
Outstanding β
- Move VAPID + apprise + analytics secrets from the
settingsk/v table into a dedicatedsecretstable (structural follow-up to MM-SEC-1's defense-in-depth strip). - Extract the legacy
/api/messages*and channel-mutator endpoints from theserver.tsmonolith into dedicated route files so MM-SEC-3 / MM-SEC-4 / MM-SEC-5 / MM-SEC-6 patches can grow integration-test coverage without dragging the whole server into a Vitest harness. - Document the VAPID key rotation procedure in operator-facing docs.
- Document
sources:readscope explicitly insrc/types/permission.ts's resource description (ties off the ambiguity called out in MM-SEC-8).
Credits β
Reported by an external security researcher who reviewed the MeshMonitor REST surface in May 2026 and provided actionable findings with references to the affected source lines. The follow-on audit (MM-SEC-5 through MM-SEC-8) was contributed by The Official Mesh Admin officialmeshadmin@proton.me. Validation, fixes, and this advisory were prepared in coordination with the researchers.