Skip to content

fix: isolate candidate credential handshakes - #6877

Merged
ja8zyjits merged 5 commits into
mainfrom
fix/candidate-only-credential-validation
Oct 6, 2026
Merged

ja8zyjits merged 5 commits into
mainfrom
fix/candidate-only-credential-validation

Conversation

@rakdutta

Copy link
Copy Markdown
Collaborator

Summary

  • let handshake callers select one exact authorized gateway instead of relying on ambiguous URL lookup
  • add a backward-compatible candidate-only mode that excludes stored upstream authentication
  • preserve existing handshake behavior when the new request fields are omitted

Closes #6876

@rakdutta
rakdutta force-pushed the fix/candidate-only-credential-validation branch from 6ffbc71 to a03a1f1 Compare September 21, 2026 05:32
@ja8zyjits ja8zyjits added the 1.1-Phase-2 Phase 2 Protocol Matching in 1.1 Delivery. No inter protocol connection. label Sep 29, 2026
@ja8zyjits ja8zyjits self-assigned this Sep 30, 2026

@msureshkumar88 msureshkumar88 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking | tests/unit/mcpgateway/routers/test_mcp_servers_router.py:1347 | Candidate-only tests call the handler directly, bypassing FastAPI validation and @require_permission. Add a live-gateway black-box POST /v1/mcp-servers/test-handshake test for successful candidate credentials plus hidden/mismatched gateway denial.

@ja8zyjits

Copy link
Copy Markdown
Collaborator

⚠️ Warnings [Non Blocking]

  • gateway_id field description omits credential scope in default mode

    • File: mcpgateway/schemas.py (diff line 9)
    • Code: gateway_id: Optional[str] = Field(None, description="Exact registered gateway to use for transport and connection settings")
    • Problem: Description covers only transport. In default stored_with_override mode, selected gateway also contributes stored credentials (auth headers, OAuth token, client cert). Caller omitting credential_mode would not know stored credentials are applied.
    • Fix: "Exact registered gateway to use for transport, TLS, and stored-credential settings; combined with credential_mode to control whether stored credentials are applied"
  • credential_mode field description does not explain literal values

    • File: mcpgateway/schemas.py (diff line 10)
    • Code: credential_mode: Literal["stored_with_override", "candidate_only"] = Field("stored_with_override", description="Whether the handshake may use stored gateway credentials")
    • Problem: Description does not distinguish stored_with_override from candidate_only. Caller cannot infer that stored_with_override applies stored auth (form headers override) while candidate_only suppresses all stored credentials including OAuth tokens and client certs.
    • Fix: "Credential handling mode. 'stored_with_override' applies stored gateway credentials; form headers take precedence. 'candidate_only' suppresses all stored credentials so only the supplied headers are tested"

ja8zyjits
ja8zyjits previously approved these changes Oct 1, 2026

@ja8zyjits ja8zyjits left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@rakdutta
rakdutta force-pushed the fix/candidate-only-credential-validation branch 4 times, most recently from 920b045 to f049eb6 Compare October 5, 2026 07:04
@rakdutta

rakdutta commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

@msureshkumar88 Added two TestClient tests that drive POST /v1/mcp-servers/test-handshake through the real ASGI stack (FastAPI validation + @require_permission live): one for successful candidate_only credential isolation, one for hidden-gateway denial. Commit 920b045f.

@ja8zyjits Updated gateway_id and credential_mode field descriptions to clarify credential scope and both literal values. Commit 920b045f.

ja8zyjits
ja8zyjits previously approved these changes Oct 5, 2026

@ja8zyjits ja8zyjits left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@msureshkumar88 msureshkumar88 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schema descriptions are fixed, and CI passes. The previous live-gateway coverage requirement remains unresolved.

blocking | tests/unit/mcpgateway/routers/test_mcp_servers_router.py:2183 | The new TestClient tests mock authentication, permissions, and upstream calls; they do not provide the requested live-gateway coverage. | Add a black-box test against a running gateway for candidate credential success and hidden/mismatched gateway denial.

The tests mount the router on a separate FastAPI app and override get_current_user_with_permissions. The autouse fixture grants permissions through MockPermissionService. Both tests replace ResilientHttpClient with a mock. These tests exercise request validation and response serialization, but they do not verify the complete gateway authentication and upstream handshake paths. Add coverage under tests/live_gateway/ with authenticated HTTP requests and an upstream that checks candidate credentials. Verify that hidden and URL-mismatched gateway requests send no upstream probe.

@rakdutta
rakdutta force-pushed the fix/candidate-only-credential-validation branch from 1e15617 to 9d8703f Compare October 5, 2026 12:55
@rakdutta

rakdutta commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

@msureshkumar88

Added tests/live_gateway/e2e/test_candidate_credential_handshake.py with three black-box scenarios against a real gateway subprocess (SQLite-backed, no Docker/compose):

  1. candidate_only success — upstream stub records received headers; asserts X-Stored-Key absent and X-Candidate present at the upstream level, credentialSource=form in the response.
  2. hidden gateway denial — public-only token cannot see a team-scoped private gateway; assert recorded == [] confirms no upstream probe fired.
  3. URL-mismatched gateway denial — assert recorded == [] confirms no upstream probe fired before the transport rejection.

The _make_recording_handler() factory gives each test its own isolated handler class and request list. CI passes.

@msureshkumar88 msureshkumar88 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backend changes fit #6876, and the schema descriptions are corrected. The requested live-gateway verification remains incomplete at 86f84e1aa6cbc314e1e36485c4ff9743aab79ce4.

blocking | tests/live_gateway/e2e/test_candidate_credential_handshake.py:157 | The success test fails URL policy before candidate credentials reach the upstream. | Permit the loopback stub in the isolated subprocess configuration and verify successful candidate authentication.

Edit the environment configuration near lines 154–159. Add SSRF_PROTECTION_ENABLED=false only to this isolated test subprocess. Keep the explicit localhost allowlist and production SSRF protection unchanged. SSRF_ALLOW_LOCALHOST=true alone does not bypass the resolved-loopback rejection in validate_gateway_test_url().

Edit the upstream handler near lines 97–103 to return 401 for an incorrect X-Candidate value. Strengthen assertions near lines 349–358 to check the exact candidate value and exclude stored credentials from every recorded handshake request. Verify that a valid candidate succeeds with credentialSource=form; an invalid candidate must fail even when a stored credential is valid.

blocking | tests/live_gateway/e2e/test_candidate_credential_handshake.py:398 | The hidden-gateway test returns HTTP 401 before gateway authorization, so it does not verify cross-team isolation. | Create an authenticated caller with gateways.read and a valid team-scoped token before requesting the hidden gateway.

Replace the caller setup near lines 393–404. Create a persisted team-A user, establish team-A membership, and grant a role containing gateways.read. Generate a valid token for that user with teams=[team_a_id]. The current teams=[] token names an uncreated user and does not represent team-A membership.

Before the hidden request near lines 406–422, prove that the same caller can handshake with a visible gateway. Clear the upstream recorder after that control request. Then request the team-B gateway and retain the assertions for success=false, failureClass=transport, and zero upstream requests. Do not relax authentication or permission enforcement to make the test pass.

blocking | tests/live_gateway/e2e/test_candidate_credential_handshake.py:451 | The mismatch test passes through URL-policy rejection without exercising the new exact-gateway URL comparison. | Replace the disallowed hostname with a permitted reachable URL that differs from the registered gateway URL.

Edit the setup near lines 437–445 to start two localhost upstreams on different ports and register their URLs. Replace base_url at line 451 with the second upstream URL while retaining the first gateway ID. First prove that each matching ID/URL pair succeeds. Clear both recorders, then verify that the mismatched pair fails and contacts neither upstream.

Keep this regression sensitive to the comparison in mcpgateway/services/gateway_service.py:8712: removing that comparison must make the test fail.

Validation at this head:

rtk proxy .venv/bin/python -m pytest tests/unit/mcpgateway/routers/test_mcp_servers_router.py tests/live_gateway/e2e/test_candidate_credential_handshake.py -q --tb=short -o addopts=

Result: 100 passed, 2 failed. Repeating the live module independently produces 1 passed, 2 failed. The success test reports URL-policy rejection; the hidden-gateway test reports HTTP 401. The passing mismatch test does not establish the requested URL-match denial.

@rakdutta
rakdutta force-pushed the fix/candidate-only-credential-validation branch from e3a6004 to dad9c48 Compare October 6, 2026 05:02
@rakdutta

rakdutta commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

@msureshkumar88 Addressed the live-gateway coverage findings in commit e3a6004c8.

  • Added SSRF_PROTECTION_ENABLED=false only to the isolated test subprocess while retaining the explicit localhost allowlist.
  • Updated the upstream stub to validate the exact X-Candidate value and return 401 for an invalid candidate.
  • Verified a valid candidate succeeds with credentialSource=form.
  • Verified an invalid candidate fails with failureClass=auth, although the stored credential is valid.
  • Verified every handshake request excludes X-Stored-Key.
  • Created a persisted team-A user with team membership and a team-scoped token.
  • Added a successful visible team-A gateway control before testing team-B gateway denial.
  • Verified the hidden gateway returns success=false, failureClass=transport, and sends no upstream request.
  • Added two reachable localhost upstreams for URL mismatch coverage.
  • Verified both matching gateway ID/URL pairs succeed before testing the mismatched pair.
  • Verified the mismatched pair fails without contacting either upstream.

Validation:

.venv/bin/python -m pytest tests/unit/mcpgateway/routers/test_mcp_servers_router.py tests/live_gateway/e2e/test_candidate_credential_handshake.py -q --tb=short -o addopts=

102 passed, 1 unrelated Starlette deprecation warning

msureshkumar88
msureshkumar88 previously approved these changes Oct 6, 2026

@msureshkumar88 msureshkumar88 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All previous requested changes are implemented at dad9c483a2237814dde6dc29e8127cd02a6e9b49.

Verified commit patches and independently ran the focused unit and live-gateway modules: 102 passed.
The live tests prove valid candidate success, invalid candidate failure, authenticated cross-team denial, and exact gateway URL mismatch without upstream probes.
Focused unit coverage covers all 20 added executable production lines.

The backend fix matches #6876 and preserves existing defaults. No UI or database migration changes are required.
No blocking or functionally-impacting findings remain. Current CI has no failing checks.

Optional follow-ups: add a user-facing request example, broaden live TLS/SSE coverage, and improve internal exact-gateway denial diagnostics.

@ja8zyjits
ja8zyjits added this pull request to the merge queue Oct 6, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 6, 2026
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
rakdutta and others added 3 commits October 6, 2026 10:24
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Signed-off-by: Jitesh Nair <jiteshnair@ibm.com>
@ja8zyjits
ja8zyjits force-pushed the fix/candidate-only-credential-validation branch from dad9c48 to 1b45b7a Compare October 6, 2026 09:50
Signed-off-by: Jitesh Nair <jiteshnair@ibm.com>

@ja8zyjits ja8zyjits left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@ja8zyjits
ja8zyjits added this pull request to the merge queue Oct 6, 2026
Merged via the queue into main with commit faa31ea Oct 6, 2026
62 checks passed
@ja8zyjits
ja8zyjits deleted the fix/candidate-only-credential-validation branch October 6, 2026 12:01
MohanLaksh added a commit that referenced this pull request Oct 7, 2026
Picks up four commits, including #7079 (standardize error response
format) and #6877 (isolate candidate credential handshakes).

One textual conflict, in .secrets.baseline, resolved from main per
AGENTS.md and left for the hook to regenerate.

#7079 also changed behaviour this branch asserted on, with no conflict
marker to signal it. update_gateway's catch-all handler now routes
through unexpected_error_detail(), which keeps project exception messages
and replaces anything else with a correlation reference. The shared key
validator raises a bare ValueError, so its text is now sanitized by
design. The deny-path test asserted that text; it now asserts the
outcome -- the update is refused, rollback runs, nothing is committed --
and records why the message is deliberately not pinned.

Verified all three blocking findings still hold after the merge: a masked
update cannot switch signing algorithms, a mask at create is refused, a
ciphertext wrapping malformed PEM is refused, 1024-bit RSA is refused
while 2048-bit passes, and empty or whitespace methods still resolve to
client_secret_post.

Signed-off-by: Mohan Lakshmaiah <mohan.economist@gmail.com>
@ja8zyjits ja8zyjits mentioned this pull request Oct 12, 2026
4 of 9 tasks
ja8zyjits added a commit that referenced this pull request Oct 12, 2026
Restore permission decorators for HTTP-stack checks. Add type hints to recovered live tests. Correct the temporary database description.

Refs #6877

Signed-off-by: Jitesh Nair <jiteshnair@ibm.com>
kimsehwan96 pushed a commit to kimsehwan96/mcp-context-forge that referenced this pull request Oct 12, 2026
* test: add HTTP-stack tests for candidate-only handshake and fix field descriptions

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
(cherry picked from commit 9dd5764)
Signed-off-by: Jitesh Nair <jiteshnair@ibm.com>

* test: add live-gateway e2e tests for candidate-only credential handshake

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
(cherry picked from commit 4bd2ce0)
Signed-off-by: Jitesh Nair <jiteshnair@ibm.com>

* test: fix handler state isolation and defensive id extraction

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
(cherry picked from commit 817b209)
Signed-off-by: Jitesh Nair <jiteshnair@ibm.com>

* test: strengthen candidate-only handshake e2e coverage

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
(cherry picked from commit dad9c48)
Signed-off-by: Jitesh Nair <jiteshnair@ibm.com>

* test: adapt restored candidate handshake fixtures

Restore permission decorators for HTTP-stack checks. Add type hints to recovered live tests. Correct the temporary database description.

Refs IBM#6877

Signed-off-by: Jitesh Nair <jiteshnair@ibm.com>

---------

Signed-off-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Signed-off-by: Jitesh Nair <jiteshnair@ibm.com>
Co-authored-by: Rakhi Dutta <rakhibiswas@yahoo.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

1.1-Phase-2 Phase 2 Protocol Matching in 1.1 Delivery. No inter protocol connection.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]: Handshake can validate with the wrong stored gateway credential

3 participants