Describe the bug
The Map Analysis "Trails" view exposes full GPS location history — including node identifier (nodeNum), latitude, longitude, altitude, and timestamps — to users who should not have access to that data. This occurs because the /api/analysis/positions endpoint (which powers the Trails visualization) only enforces a coarse source-level nodes:read permission check and skips the two finer-grained permission gates that protect every other location-exposing surface in the application:
-
Per-channel viewOnMap permission is not checked. An admin can restrict a user role (including anonymous/guest) from seeing node positions on the map by setting channel_N.viewOnMap = false. The main map and the v1 position-history API both enforce this. The analysis Trails view does not — nodes on those channels still appear in their entirety.
-
Private position override (positionOverrideIsPrivate) is not checked. Nodes marked with a private position override require the nodes_private:read permission before any location data is disclosed. The analysis endpoint queries the raw telemetry table directly (bypassing the override logic entirely) and never inspects this flag, so unredacted historical GPS telemetry is returned regardless of that setting.
Because nodeNum is included in every returned record, an observer can convert it to the standard !xxxxxxxx Meshtastic hex identifier and cross-reference it against public mesh maps (e.g., meshmap.net) to correlate trails with real-world node owners — even without any node name or callsign being present in the response.
This also means nodes on encrypted channels (where the user has no viewOnMap access) are exposed. The problem affects anonymous users and any authenticated user whose permissions have been scoped to deny map visibility on specific channels.
To Reproduce
- As an admin, configure a source such that the anonymous (guest) user has
nodes:read access but channel_0.viewOnMap is not granted — or mark any node as having a private position override (positionOverrideIsPrivate = true).
- Open MeshMonitor in a private/incognito browser window (unauthenticated/anonymous session).
- Navigate to Map → Map Analysis.
- Select the Trails view.
- Observe that GPS trails are rendered for nodes and channels that should be hidden from anonymous users under the configured permissions.
The raw data can also be observed directly:
GET /api/analysis/positions
The response will include paginated records of nodeNum, latitude, longitude, altitude, sourceId, and timestamp regardless of channel or private-position settings.
Expected behavior
The Trails view and the /api/analysis/positions endpoint should enforce the same permission model as the main map and the v1 position-history API:
- Positions for nodes on channels where the requesting user lacks
viewOnMap permission should be excluded from the response.
- Positions for nodes with
positionOverrideIsPrivate = true should require nodes_private:read permission; users lacking that permission should receive no location data for those nodes.
The v1 per-node endpoint (GET /api/v1/nodes/:nodeId/position-history) already implements both of these checks correctly and can serve as the reference implementation.
Screenshots
N/A — the vulnerability is observable via the Trails UI rendering location data that should be hidden, or directly via the API response JSON.
Environment (please complete the following information):
- Host OS: Linux (Debian)
- Deployment type: Docker
- Networking customizations: Caddy reverse proxy, Authentik OIDC authentication provider
- Database backend: PostgreSQL
- Node type: N/A (server-side permission enforcement bug, not node-specific)
- Node Connection Type: N/A
- VirtualNode client: N/A
Additional context
Root cause is in server/routes/analysisRoutes.js. The resolvePermittedSourceIds() helper used by all analysis endpoints only calls checkPermissionAsync(userId, 'nodes', 'read', sourceId). It never calls checkNodeChannelAccess() (from server/utils/nodeEnhancer.js) or inspects positionOverrideIsPrivate on individual nodes.
Comparison of what each surface checks:
| Check |
Main map |
v1 /position-history |
Analysis /positions |
Source-level nodes:read |
✅ |
✅ |
✅ |
Per-channel channel_N.viewOnMap |
✅ |
✅ |
❌ |
positionOverrideIsPrivate → nodes_private:read |
✅ |
✅ |
❌ |
The /api/analysis/coverage-grid endpoint shares the same root cause (it calls getPositions() internally in db/repositories/analysis.js), though its output is binned grid cells rather than precise coordinates, so the direct location exposure risk is lower.
Suggested fix: in analysisRoutes.js, after fetching the raw page of positions from getPositions(), apply a per-record post-filter that (a) calls checkNodeChannelAccess() for each nodeNum to enforce viewOnMap, and (b) checks node.positionOverrideIsPrivate and gates on nodes_private:read — mirroring what v1/positionHistory.js already does. The same filter should propagate to the coverage-grid handler.
Describe the bug
The Map Analysis "Trails" view exposes full GPS location history — including node identifier (
nodeNum), latitude, longitude, altitude, and timestamps — to users who should not have access to that data. This occurs because the/api/analysis/positionsendpoint (which powers the Trails visualization) only enforces a coarse source-levelnodes:readpermission check and skips the two finer-grained permission gates that protect every other location-exposing surface in the application:Per-channel
viewOnMappermission is not checked. An admin can restrict a user role (including anonymous/guest) from seeing node positions on the map by settingchannel_N.viewOnMap = false. The main map and the v1 position-history API both enforce this. The analysis Trails view does not — nodes on those channels still appear in their entirety.Private position override (
positionOverrideIsPrivate) is not checked. Nodes marked with a private position override require thenodes_private:readpermission before any location data is disclosed. The analysis endpoint queries the raw telemetry table directly (bypassing the override logic entirely) and never inspects this flag, so unredacted historical GPS telemetry is returned regardless of that setting.Because
nodeNumis included in every returned record, an observer can convert it to the standard!xxxxxxxxMeshtastic hex identifier and cross-reference it against public mesh maps (e.g., meshmap.net) to correlate trails with real-world node owners — even without any node name or callsign being present in the response.This also means nodes on encrypted channels (where the user has no
viewOnMapaccess) are exposed. The problem affects anonymous users and any authenticated user whose permissions have been scoped to deny map visibility on specific channels.To Reproduce
nodes:readaccess butchannel_0.viewOnMapis not granted — or mark any node as having a private position override (positionOverrideIsPrivate = true).The raw data can also be observed directly:
The response will include paginated records of
nodeNum,latitude,longitude,altitude,sourceId, andtimestampregardless of channel or private-position settings.Expected behavior
The Trails view and the
/api/analysis/positionsendpoint should enforce the same permission model as the main map and the v1 position-history API:viewOnMappermission should be excluded from the response.positionOverrideIsPrivate = trueshould requirenodes_private:readpermission; users lacking that permission should receive no location data for those nodes.The v1 per-node endpoint (
GET /api/v1/nodes/:nodeId/position-history) already implements both of these checks correctly and can serve as the reference implementation.Screenshots
N/A — the vulnerability is observable via the Trails UI rendering location data that should be hidden, or directly via the API response JSON.
Environment (please complete the following information):
4.9.3Additional context
Root cause is in
server/routes/analysisRoutes.js. TheresolvePermittedSourceIds()helper used by all analysis endpoints only callscheckPermissionAsync(userId, 'nodes', 'read', sourceId). It never callscheckNodeChannelAccess()(fromserver/utils/nodeEnhancer.js) or inspectspositionOverrideIsPrivateon individual nodes.Comparison of what each surface checks:
/position-history/positionsnodes:readchannel_N.viewOnMappositionOverrideIsPrivate→nodes_private:readThe
/api/analysis/coverage-gridendpoint shares the same root cause (it callsgetPositions()internally indb/repositories/analysis.js), though its output is binned grid cells rather than precise coordinates, so the direct location exposure risk is lower.Suggested fix: in
analysisRoutes.js, after fetching the raw page of positions fromgetPositions(), apply a per-record post-filter that (a) callscheckNodeChannelAccess()for eachnodeNumto enforceviewOnMap, and (b) checksnode.positionOverrideIsPrivateand gates onnodes_private:read— mirroring whatv1/positionHistory.jsalready does. The same filter should propagate to the coverage-grid handler.