Skip to content

fix(dashboard): scope local-node injection lookup to the source (no cross-source leak)#3757

Merged
Yeraze merged 1 commit into
mainfrom
fix/local-node-source-scope
Jun 25, 2026
Merged

fix(dashboard): scope local-node injection lookup to the source (no cross-source leak)#3757
Yeraze merged 1 commit into
mainfrom
fix/local-node-source-scope

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Summary

Tightens a pre-existing cross-source edge case surfaced in the #3748 review.

buildSourceNodes injects the manager's local node (when it isn't already in the source's node list) by fetching its full DB record. That lookup was calling getNode(localNodeInfo.nodeNum) with no sourceId — and getNode without a sourceId does a cross-source first-match. On a multi-source deployment where two sources have heard the same nodeNum, this could inject another source's row (and its position) into the current source's node list.

Fix: pass source.id so the lookup is per-source. If this source hasn't stored the local node yet, it falls through to the synthesized minimal record exactly as before. The /:id/status route already scoped this lookup correctly (getNode(nodeNum, source.id)); this brings the nodes path in line.

History

This was verbatim pre-existing behavior in the /:id/nodes route (origin/main sourceRoutes.ts:990, commented "regardless of sourceId"), carried over unchanged during the #3748 extraction so that PR stayed behavior-preserving. Now that both the /:id/nodes and /:id/dashboard (+ /unified/dashboard) callers share buildSourceNodes, this one-line fix corrects all of them.

Tests

  • sourceRoutes.dashboard.test.ts — new case asserting the injection lookup is called with (nodeNum, source.id) and falls through to the source-scoped synthesized record.
  • Full suite: 7554 passed, 0 failed.

🤖 Generated with Claude Code

buildSourceNodes injected the manager's local node by calling
getNode(localNodeInfo.nodeNum) with no sourceId — a cross-source first-match
that, on a multi-source deployment where two sources have heard the same
nodeNum, could inject another source's row (and its position) into this
source's node list. Pass source.id so the lookup is per-source; if this source
hasn't stored the local node yet, it falls through to the synthesized minimal
record as before.

Pre-existing behavior carried over verbatim during the #3748 extraction
(origin/main sourceRoutes.ts:990, 'regardless of sourceId'); surfaced in the
#3748 review. The /:id/status route already scoped this lookup correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SJPe6J5vKrcbwzt6vCdtrt
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review

  • Read changed files
  • Analyze related code for context
  • Provide review feedback

Summary: This is a clean, targeted security/correctness fix. The change is correct and the test is well-written. No blocking issues found.


Code Quality

sourceDashboardData.ts:105 — The one-line fix is exactly right:

- const localNode = await databaseService.nodes.getNode(localNodeInfo.nodeNum);
+ const localNode = await databaseService.nodes.getNode(localNodeInfo.nodeNum, source.id);

The getNode signature at nodes.ts:130 confirms that omitting sourceId does a cross-source first-match (eq(nodes.nodeNum, nodeNum) only), which is the documented legacy behavior. Passing source.id selects the correct WHERE clause (and(eq(nodes.nodeNum, nodeNum), eq(nodes.sourceId, sourceId))). The fix is minimal and correct.

The inline comment added at lines 99–104 is thorough — it explains the "why" (cross-source first-match risk), the historical origin, and the fallthrough behavior. This is good practice for a subtle concurrency/multi-source correctness fix.

Test Coverage

sourceRoutes.dashboard.test.ts:119-138 — The new test case covers the key invariants:

  1. getNode is called with (nodeNum, source.id) — asserted at line 133 ✅
  2. When getNode returns null (not yet stored under this source), the synthesized fallback record is used ✅
  3. The synthesized node carries sourceId: 'src-mqtt' (line 137), confirming it's source-scoped ✅

One minor observation: the test uses mockResolvedValue(null) for getNode regardless of arguments (line 124), which means it would also pass if the implementation called getNode(nodeNum) without a sourceId — the mock doesn't verify the second argument is specifically source.id. However, the expect(...).toHaveBeenCalledWith(42, 'src-mqtt') assertion at line 133 catches this correctly. The test is sound.

Potential Issues

None found. The fallthrough path (lines 109–118 in sourceDashboardData.ts) was already source-scoped — it injects sourceId: source.id into the synthesized record, so the synthesized node was never the problem; only the DB-lookup path was. This fix closes that gap.

Performance

No regression. This is a strict improvement — the per-source WHERE clause can use the composite (nodeNum, sourceId) index (established in migration 029), whereas the old cross-source query would scan all rows matching nodeNum and return the first.

Security

This is a data isolation fix. Without it, on a multi-source deployment where two sources have heard the same nodeNum, the local node injection could pull another source's DB row (including its position data) into the current source's response. The fix correctly prevents this cross-source data leak.


Verdict: ✅ Approve. The fix is correct, minimal, well-commented, and well-tested. The PR description accurately describes the root cause and impact.

@Yeraze Yeraze merged commit e53a713 into main Jun 25, 2026
19 checks passed
@Yeraze Yeraze deleted the fix/local-node-source-scope branch June 25, 2026 17:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant