fix(security): enforce channel viewOnMap and private-position gates on analysis positions endpoint#3366
Conversation
…n analysis positions endpoint
The /api/analysis/positions endpoint was returning raw GPS position
history without checking whether the requesting user had viewOnMap
permission on the node's channel, or nodes_private:read permission
for nodes with positionOverrideIsPrivate=true. This allowed any user
with source-level nodes:read access — including anonymous/guest — to
enumerate precise historical coordinates for nodes they should not be
able to see on the map.
The /api/analysis/coverage-grid endpoint shared the same root cause
since it calls getPositions() internally.
Fix: introduce buildPositionFilter() which pre-fetches the user's
channel permission set (once per source), virtual-channel DB
permissions, and node metadata (channel + positionOverrideIsPrivate)
in bulk. The resulting synchronous predicate is applied:
- after getPositions() returns in the /positions handler
- inside getCoverageGrid()'s pagination loop via a new optional
postFilter arg on GetCoverageGridArgs
Admin requests bypass filtering (no change in behaviour).
Non-admin requests to /coverage-grid skip the shared in-memory cache
because results vary per user.
Mirrors the checks already enforced by the main map and the v1
/nodes/:id/position-history endpoint.
Fixes #3365
https://claude.ai/code/session_011GB1VoJXTVbFh2wdaUoArj
|
Claude finished @Yeraze's task —— View job Pull Request ReviewI'll provide a comprehensive review of this security fix PR. Here's my analysis plan: Review Tasks
🔍 Overall AssessmentThis is an excellent security fix that addresses a significant privilege escalation vulnerability. The implementation is well-designed, thoroughly tested, and follows security best practices. ✅ StrengthsSecurity Implementation
Performance Optimization
Code Quality
Test Coverage
🔧 Technical DetailsFilter Implementation (
|
| Check | Main map | v1 /position-history |
Analysis /positions |
Analysis /coverage-grid |
|---|---|---|---|---|
Source-level nodes:read |
✅ | ✅ | ✅ | ✅ |
Per-channel channel_N.viewOnMap |
✅ | ✅ | ✅ ✅ | ✅ ✅ |
positionOverrideIsPrivate → nodes_private:read |
✅ | ✅ | ✅ ✅ | ✅ ✅ |
🚀 Performance Analysis
Positive Impacts
- Bulk permission loading: Single DB query per source vs N queries per position
- Cached admin responses: No performance regression for admin users
- Efficient filtering: O(1) lookup per position after initial setup
Minor Considerations
- Memory usage: Loads all node metadata upfront (acceptable for typical deployments)
- Non-admin cache miss: Non-admin requests can't leverage shared cache (necessary for security)
🧪 Test Quality Assessment
The test suite covers all critical scenarios:
- Channel permission filtering (
analysisRoutes.test.ts:119-144) - Private position override blocking (
analysisRoutes.test.ts:146-166) - Admin bypass verification (
analysisRoutes.test.ts:168-186) - Cache behavior differences (
analysisRoutes.test.ts:287-298)
Tests use proper mocking and verify both positive and negative cases.
🔐 Security Analysis
Vulnerability Severity: HIGH
The original issue allowed unauthorized access to:
- GPS coordinates of nodes on restricted channels
- Position data marked as private
Fix Quality: EXCELLENT
- Applies consistent permission model across all position endpoints
- No bypass vectors identified
- Proper error handling (fails closed)
- Admin privileges preserved appropriately
💡 Minor Suggestions
Code Documentation
Consider adding a brief comment in getCoverageGrid explaining why postFilter is needed:
// Optional security filter for non-admin users to enforce channel permissions
postFilter?: (pos: PositionRow) => boolean;Error Handling
Current implementation gracefully handles missing node data by defaulting to { channel: 0, positionOverrideIsPrivate: false }. This is secure (fails closed) but consider logging when node metadata is missing for debugging.
🎯 Recommendation
APPROVE - This is a high-quality security fix that should be merged promptly. The implementation is thorough, well-tested, and addresses the vulnerability completely without introducing performance regressions or breaking changes.
The code follows established patterns in the codebase and maintains backward compatibility for admin users while properly securing access for non-admin users.
The [4.9.4] section captured only the entries present in [Unreleased] when
the release branch was cut. Add the remaining changes that shipped in
v4.9.4 but merged without their own CHANGELOG entries: {DATE}/{TIME}
tokens (#3382), MeshCore JSONL export (#3391), system theme (#3344),
Map Pin Style fix (#3364), MeshCore connecting state (#3380), the Map
Analysis security gate (#3365/#3366), and the 11 dependency bumps.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Fixes #3365 — the
/api/analysis/positions(Trails view) and/api/analysis/coverage-gridendpoints were returning GPS position data without enforcing the two finer-grained permission checks that the main map andv1/position-historyalready apply:viewOnMap— nodes on channels the user lacksviewOnMappermission for were still included in the response.positionOverrideIsPrivate— nodes marked with a private position override were returned without requiringnodes_private:read.Changes
src/server/routes/analysisRoutes.tsbuildPositionFilter(user, sourceIds)helper: pre-fetches the user's channel permission set (once per source), virtual-channel DB permissions, and node metadata (channel+positionOverrideIsPrivate) in bulk, then returns a cheap synchronous predicate./positionshandler: applies the predicate toresult.itemsaftergetPositions()returns./coverage-gridhandler: passes the predicate aspostFilterintogetCoverageGrid(); also skips the shared in-memory cache for non-admin users since results are now user-specific.src/db/repositories/analysis.tspostFilter?: (pos: PositionRow) => booleantoGetCoverageGridArgs.getCoverageGrid()applies the filter inside its internal pagination loop.src/server/routes/analysisRoutes.test.tsnodes.getAllNodes,getChannelDatabasePermissionsForUserAsSetAsync.nodes_private:read; admin bypass (filter never invoked).Permission check comparison (after fix)
/position-history/positions/coverage-gridnodes:readchannel_N.viewOnMappositionOverrideIsPrivate→nodes_private:readTest plan
nodes:readbutchannel_0.viewOnMap = false: verify/api/analysis/positionsreturns no items for nodes on channel 0nodes_private:read: verify nodes withpositionOverrideIsPrivate = trueare excluded from/api/analysis/positionshttps://claude.ai/code/session_011GB1VoJXTVbFh2wdaUoArj
Generated by Claude Code