Validate user on Streamable HTTP session DELETE (#1604)
This commit is contained in:
parent
55646c3ad7
commit
453132009a
3 changed files with 88 additions and 2 deletions
2
.github/workflows/ci-code-coverage.yml
vendored
2
.github/workflows/ci-code-coverage.yml
vendored
|
|
@ -24,7 +24,7 @@ jobs:
|
||||||
pattern: testresults-*
|
pattern: testresults-*
|
||||||
|
|
||||||
- name: Combine coverage reports
|
- name: Combine coverage reports
|
||||||
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.5
|
uses: danielpalme/ReportGenerator-GitHub-Action@7ae927204961589fcb0b0be245c51fbbc87cbca2 # 5.5.5
|
||||||
with:
|
with:
|
||||||
reports: "**/*.cobertura.xml"
|
reports: "**/*.cobertura.xml"
|
||||||
targetdir: "${{ github.workspace }}/report"
|
targetdir: "${{ github.workspace }}/report"
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,20 @@ internal sealed class StreamableHttpHandler(
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
|
var sessionId = context.Request.Headers[McpSessionIdHeaderName].ToString();
|
||||||
if (sessionManager.TryRemove(sessionId, out var session))
|
if (string.IsNullOrEmpty(sessionId) || !sessionManager.TryGetValue(sessionId, out var session))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.HasSameUserId(context.User))
|
||||||
|
{
|
||||||
|
await WriteJsonRpcErrorAsync(context,
|
||||||
|
"Forbidden: The currently authenticated user does not match the user who initiated the session.",
|
||||||
|
StatusCodes.Status403Forbidden);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionManager.TryRemove(sessionId, out session))
|
||||||
{
|
{
|
||||||
await session.DisposeAsync();
|
await session.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ using ModelContextProtocol.Server;
|
||||||
using ModelContextProtocol.Tests.Utils;
|
using ModelContextProtocol.Tests.Utils;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Security.Claims;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
|
@ -868,4 +869,76 @@ public class MapMcpStreamableHttpTests(ITestOutputHelper outputHelper) : MapMcpT
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteRequest_FromDifferentUser_IsRejected_AndSessionSurvives()
|
||||||
|
{
|
||||||
|
Assert.SkipWhen(Stateless, "Sessions don't exist in stateless mode.");
|
||||||
|
|
||||||
|
Builder.Services.AddMcpServer().WithHttpTransport(ConfigureStateless).WithTools<EchoHttpContextUserTools>();
|
||||||
|
Builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
|
await using var app = Builder.Build();
|
||||||
|
|
||||||
|
// Pick the user from a test header so different HttpClient requests can act as different users.
|
||||||
|
app.Use(next => async context =>
|
||||||
|
{
|
||||||
|
var name = context.Request.Headers["X-Test-User"].ToString();
|
||||||
|
if (!string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
context.User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
|
[new Claim("name", name), new Claim(ClaimTypes.NameIdentifier, name)],
|
||||||
|
"TestAuthType", "name", "role"));
|
||||||
|
}
|
||||||
|
await next(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapMcp();
|
||||||
|
await app.StartAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
const string initializeRequest = """
|
||||||
|
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}
|
||||||
|
""";
|
||||||
|
|
||||||
|
using var initRequest = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/")
|
||||||
|
{
|
||||||
|
Content = new StringContent(initializeRequest, System.Text.Encoding.UTF8, "application/json"),
|
||||||
|
};
|
||||||
|
initRequest.Headers.Add("X-Test-User", "Alice");
|
||||||
|
initRequest.Headers.Accept.ParseAdd("application/json");
|
||||||
|
initRequest.Headers.Accept.ParseAdd("text/event-stream");
|
||||||
|
|
||||||
|
using var initResponse = await HttpClient.SendAsync(initRequest, TestContext.Current.CancellationToken);
|
||||||
|
Assert.True(initResponse.IsSuccessStatusCode);
|
||||||
|
var sessionId = Assert.Single(initResponse.Headers.GetValues("Mcp-Session-Id"));
|
||||||
|
|
||||||
|
// A DELETE from a different authenticated user must not be able to tear down Alice's session.
|
||||||
|
using var bobDelete = new HttpRequestMessage(HttpMethod.Delete, "http://localhost:5000/");
|
||||||
|
bobDelete.Headers.Add("X-Test-User", "Bob");
|
||||||
|
bobDelete.Headers.Add("Mcp-Session-Id", sessionId);
|
||||||
|
using var bobDeleteResponse = await HttpClient.SendAsync(bobDelete, TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, bobDeleteResponse.StatusCode);
|
||||||
|
|
||||||
|
// Alice should still be able to use the session.
|
||||||
|
const string toolCallRequest = """
|
||||||
|
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
|
||||||
|
""";
|
||||||
|
using var aliceCall = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/")
|
||||||
|
{
|
||||||
|
Content = new StringContent(toolCallRequest, System.Text.Encoding.UTF8, "application/json"),
|
||||||
|
};
|
||||||
|
aliceCall.Headers.Add("X-Test-User", "Alice");
|
||||||
|
aliceCall.Headers.Add("Mcp-Session-Id", sessionId);
|
||||||
|
aliceCall.Headers.Accept.ParseAdd("application/json");
|
||||||
|
aliceCall.Headers.Accept.ParseAdd("text/event-stream");
|
||||||
|
using var aliceCallResponse = await HttpClient.SendAsync(aliceCall, TestContext.Current.CancellationToken);
|
||||||
|
Assert.True(aliceCallResponse.IsSuccessStatusCode);
|
||||||
|
|
||||||
|
// Alice can still terminate her own session.
|
||||||
|
using var aliceDelete = new HttpRequestMessage(HttpMethod.Delete, "http://localhost:5000/");
|
||||||
|
aliceDelete.Headers.Add("X-Test-User", "Alice");
|
||||||
|
aliceDelete.Headers.Add("Mcp-Session-Id", sessionId);
|
||||||
|
using var aliceDeleteResponse = await HttpClient.SendAsync(aliceDelete, TestContext.Current.CancellationToken);
|
||||||
|
Assert.True(aliceDeleteResponse.IsSuccessStatusCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue