feat: implement self-connection guard for MCP endpoints#83
Conversation
Added a mechanism to prevent the daemon from connecting to its own MCP HTTP and gRPC endpoints. This includes: - Updated `McpClientPool` to track listening ports and reject self-referential connections. - Introduced `isSelfConnectionUrl` utility for URL validation against local addresses. - Enhanced tests to verify self-connection rejection and allow non-self remote MCP URLs. Additionally, updated documentation to reflect these changes and ensure clarity on the new self-connection behavior.
Resolve conflicts: keep TLS/HTTP security options from main, renumber self-connection decision to DR-038, and preserve listen-endpoint wiring.
Optional-chain setListenEndpoints for test doubles, and compare config save paths via realpath on macOS.
sudhirverma
left a comment
There was a problem hiding this comment.
Contributor review (maintainer assist)
Verdict: needs work before merge — H7 guard implementation and tests look solid and CI is green, but there are merge-blocking doc/coordination items and one fail-open gap vs the stated DR.
Checklist
- Up to date with main (0 behind / 3 ahead)
- CI green (lint-and-test, build matrix, container, package-python)
- PR description updated to Summary / Changes / Test plan
- DR numbers coordinated with PR #81 (both claim DR-038)
What looks solid
- Closes the real pre-PR gap:
isSelfConnection()is no longer a permanentreturn false isSelfConnectionUrl()compares host (loopback / hostname / local iface IPs) + daemon HTTP/gRPC listen ports- Wired early via
setListenEndpoints()instartDaemon(httpPort/grpcPort) and refreshed when the embedded web server binds connect()/connectDynamic()throw a clear self-connection error;connectAllskips self without aborting other servers- Unit coverage for
localhost/127.0.0.1/[::1]/ hostname / remote-allowed; E2E inmcp-self-connection.test.tsrejects self/mcpand allows a different-port mock - Exported helper + regression tests make a silent no-op hard to reintroduce
- ARCHITECTURE + DR entry document the behavior
Blockers / must coordinate before merge
- DR-038 collision with PR #81 (
fix(daemon): gate dynamic stdio MCP spawn) — #81 also adds DR-038 (stdio allowlist). Renumber this PR’s decision (e.g. self-connection → DR-039 if #81 lands first, or the reverse) and update ARCHITECTURE / cross-refs. - PR description format — body is prose bullets only. Per project PR readiness, rewrite to Summary / Changes / Test plan (map to H7 acceptance: self URL rejected; localhost variants detected; remote/mock allowed; guard not a no-op).
Should-fix (security posture vs DR text)
- Empty listen-port set fail-opens — if
httpPortis not passed tostartDaemon,httpPortsstays[]until the web server binds;isSelfConnectionUrlthen returnsfalsefor loopback self URLs.abbenay webpasseshttpPort, so the main path is OK, but any MCP init before ports are registered can still self-connect. DR-038 implies self URLs are refused; prefer fail-closed for local hosts when HTTP MCP is enabled and no listen ports are known yet (or always register expected HTTP port beforeinitMcpConnections()).
Non-blocking follow-ups
| Topic | Note |
|---|---|
| Fail-closed when listen ports unknown | Should-fix above |
IPv4-mapped IPv6 (::ffff:127.0.0.1) |
Not normalized to 127.0.0.1; easy unit case |
Config connect() status UX |
Self-reject throws before status is recorded, so dashboard may show “no status” rather than the error |
| Persist-time validation | Self URLs can still be saved in config; rejected only at connect (acceptable if documented) |
Optional nits (not blocking)
- Blocking any path on matching host:port (not only
/mcp) is correct/stricter — keep it localHostAddresses()rescans interfaces per check — fine at connect frequencypackage-lock.jsonone-line churn — confirm intentional / rebase artifact
Maintainer assist — agent-executable follow-upContext: This PR closes product-security finding H7 / AAP-82830 (epic AAP-80944): Current status: Implementation + unit/E2E tests look solid and CI is green (0 behind / 3 ahead of How to use this comment: Execute items in order. Treat P0 as merge-blocking. Do not invent extra scope. Do not open GitHub issues unless a human explicitly asks. When done, reply on this thread with commit SHAs and a short checklist of what landed. P0-1 — Resolve DR-038 collision with PR #81Problem: On current
Why it matters: Duplicate DR numbers break the decision log as an ordered record. Reviewers and future agents cannot cite “DR-038” unambiguously. Merge of either PR without renumbering will force a conflict or silent overwrite risk for the other. What to do:
Acceptance:
P0-2 — Rewrite PR description to project template + JiraProblem: Body is unstructured prose bullets. Project PR readiness expects Summary / Changes / Test plan. Jira AAP-82830 is not linked in the description. Why it matters: Reviewers and release archaeology need the H7 acceptance criteria and the security ticket in the PR record, not only in chat/Jira. What to do: Replace the PR body with something equivalent to: ## Summary
Close product-security finding **H7** ([AAP-82830](https://redhat.atlassian.net/browse/AAP-82830)): implement a real MCP self-connection guard so the daemon cannot connect to its own HTTP/SSE `/mcp` (or gRPC listen port) endpoints and recurse/amplify tool calls.
Previously `isSelfConnection()` always returned `false`.
## Changes
- Track daemon HTTP/gRPC listen ports via `McpClientPool.setListenEndpoints()`
- Add exported `isSelfConnectionUrl()` (local hosts × listen ports)
- Register ports early in `startDaemon` (`httpPort` / `grpcPort`) and refresh when the embedded web server binds
- Reject self URLs in `connect()` / `connectDynamic()`; skip self in `connectAll` without aborting other servers
- Document as **DR-0XX** (number after collision resolution) in `docs/decisions.md` + ARCHITECTURE note
- Unit + E2E coverage for localhost variants and non-self remote/mock URLs
## Test plan
- [x] Unit: `localhost` / `127.0.0.1` / `[::1]` / hostname + matching port → rejected
- [x] Unit: remote host or localhost different port → allowed
- [x] Unit/E2E: guard cannot remain a permanent no-op (`connect` / `connectDynamic` throw)
- [x] E2E: self `/mcp` rejected; different-port mock MCP allowed
- [x] CI green (`lint-and-test`, build matrix, container, package-python)
- [ ] Manual (optional): configure MCP URL to this daemon’s `/mcp` → clear self-connection errorUse Acceptance:
P0-3 — Fail-closed when listen ports are unknown (security posture)Problem: it('returns false when no listen ports are configured', () => {
expect(isSelfConnectionUrl('http://127.0.0.1:8787/mcp', {
httpPorts: [],
grpcPorts: [],
})).toBe(false);
});Why it matters: DR text says self URLs targeting the daemon’s own endpoints are refused. Preferred fix (pick one; A is preferred): Option A — Fail-closed for local hosts when no listen ports are known
Option B — Always register expected HTTP port before MCP init
Justification for preferring A: Port registration order is easy to regress; fail-closed on “local host, unknown listen set” matches secure-by-default and the H7 DoD (“self-connection … is detected and blocked”). Legitimate other-localhost MCP servers on different ports still work once ports are registered; when ports are unknown, refusing local URLs is the safer temporary posture. Acceptance:
P1 — Optional hardening (non-blocking; do in this PR only if cheap)Do these only if they fit naturally while touching P1-a — Normalize IPv4-mapped IPv6 loopback
P1-b — Record status on self-reject for config
|
Renumber self-connection decision to DR-039 (avoids collision with #81), refuse local MCP URLs when listen ports are unknown, normalize IPv4-mapped loopback, record failed status on self-reject, and drop unintentional package-lock engines churn.
Follow-up complete (maintainer assist)Landed on
Commit: Local gates before push: lint (0 errors), daemon tests 718 passed, vscode 24 passed, |
sudhirverma
left a comment
There was a problem hiding this comment.
Re-review — follow-up verified
Verified against e225ee3 on fix/mcp-self-connection-guard. All P0/P1 items from the prior review and @cidrblock’s maintainer-assist comment are addressed.
| Item | Status |
|---|---|
| P0-1 DR collision | Self-connection is DR-039; no DR-038 on this branch (#81 keeps DR-038). Single --- above DR-029. |
| P0-2 PR body | Summary / Changes / Test plan + AAP-82830; DR-039 matches docs. |
| P0-3 fail-closed | Option A: empty listen-port set + local host → self; remote still allowed. Unit tests updated. |
| P1-a IPv4-mapped | normalizeHost maps ::ffff:127.0.0.1 / ::ffff:7f00:1 → 127.0.0.1 + tests. |
| P1-b status UX | assertNotSelfConnection records connected: false + error before throw; unit asserts status. |
| P1-c package-lock | No package-lock.json diff vs main. |
Verdict: LGTM — ready to merge once remaining CI (container) is green. Persist-time validation remains an acceptable non-goal for H7.
Manual verification (LGTM)Validated H7 / DR-039 end-to-end with Ollama on Verified
Also confirmed from review follow-up: DR-039 (no clash with #81), PR body format + AAP-82830, fail-closed Option A, IPv4-mapped normalize, status on self-reject. Approve — ready to merge once CI is green. |
Resolve conflicts with PR #83 self-connection guard (DR-039) while keeping stdio spawn allowlist + approval (DR-038 / H6). Co-authored-by: Cursor <cursoragent@cursor.com>
Keep #83 MCP self-connection as DR-039; renumber A1/A3 provider endpoint policy to DR-040 after the merge collision.
Preserve DR-039 / MCP self-connection guard from #83 alongside DR-038 air-gap docs.
Summary
Close product-security finding H7 (AAP-82830): implement a real MCP self-connection guard so the daemon cannot connect to its own HTTP/SSE
/mcp(or gRPC listen port) endpoints and recurse/amplify tool calls.Previously
isSelfConnection()always returnedfalse.Changes
McpClientPool.setListenEndpoints()isSelfConnectionUrl()(local hosts × listen ports)startDaemon(httpPort/grpcPort) and refresh when the embedded web server binds::ffff:127.0.0.1/ Node’s::ffff:7f00:1)connect()/connectDynamic()with a recorded failed status; skip self inconnectAllwithout aborting other serversdocs/decisions.md+ ARCHITECTURE noteTest plan
localhost/127.0.0.1/[::1]/ hostname + matching port → rejected[::ffff:127.0.0.1]/[::ffff:7f00:1]) → rejectedconnect/connectDynamicthrow); status records error/mcprejected; different-port mock MCP allowedlint-and-test, build matrix, container, package-python)/mcp→ clear self-connection error