Skip to content

Commit 21e1638

Browse files
Block mcp oauth
1 parent 87779d1 commit 21e1638

4 files changed

Lines changed: 100 additions & 4 deletions

File tree

EssentialCSharp.Web.Tests/McpRateLimitingTests.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,34 @@ await Assert.That(getResponse.StatusCode)
212212
await Assert.That(rateLimitedGetResponse.StatusCode).IsEqualTo(HttpStatusCode.TooManyRequests);
213213
}
214214
}
215+
216+
[NotInParallel("McpTests")]
217+
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerClass)]
218+
public class McpWellKnownIsolationRateLimitingTests(WebApplicationFactory factory)
219+
{
220+
[Test]
221+
public async Task WellKnownRequests_DoNotConsumeContentLimiterBudget()
222+
{
223+
HttpClient client = McpTestHelper.CreateClient(factory);
224+
225+
for (int i = 0; i < 10; i++)
226+
{
227+
using HttpResponseMessage wellKnownResponse =
228+
await client.GetAsync("/.well-known/oauth-protected-resource");
229+
await Assert.That(wellKnownResponse.StatusCode)
230+
.IsEqualTo(HttpStatusCode.NotFound)
231+
.Because($"well-known request {i + 1} should short-circuit with 404");
232+
}
233+
234+
for (int i = 0; i < 10; i++)
235+
{
236+
using HttpResponseMessage contentResponse = await client.GetAsync("/hello-world");
237+
await Assert.That(contentResponse.StatusCode)
238+
.IsNotEqualTo(HttpStatusCode.TooManyRequests)
239+
.Because($"content request {i + 1} should still have its full limiter budget");
240+
}
241+
242+
using HttpResponseMessage rateLimitedResponse = await client.GetAsync("/hello-world");
243+
await Assert.That(rateLimitedResponse.StatusCode).IsEqualTo(HttpStatusCode.TooManyRequests);
244+
}
245+
}

EssentialCSharp.Web.Tests/McpTests.cs

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,23 @@ public async Task McpEndpoint_WithoutToken_Returns401()
2828
using var request = McpTestHelper.CreateInitializeRequest("/mcp");
2929
using HttpResponseMessage response = await client.SendAsync(request);
3030

31-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
31+
await AssertUnauthorizedMcpChallengeAsync(response);
32+
}
33+
34+
[Test]
35+
public async Task McpEndpoint_WithSiteCookieButWithoutBearer_Returns401()
36+
{
37+
string cookieUserId = await McpTestHelper.CreateUserAsync(factory, "mcp-cookie-only");
38+
(string cookieName, string cookieValue) =
39+
await McpTestHelper.CreateIdentityApplicationCookieAsync(factory, cookieUserId);
40+
41+
HttpClient client = McpTestHelper.CreateClient(factory);
42+
using var request = McpTestHelper.CreateInitializeRequest("/mcp");
43+
McpTestHelper.AddCookie(request, cookieName, cookieValue);
44+
45+
using HttpResponseMessage response = await client.SendAsync(request);
46+
47+
await AssertUnauthorizedMcpChallengeAsync(response);
3248
}
3349

3450
[Test]
@@ -96,7 +112,7 @@ public async Task McpEndpoint_WithInvalidToken_Returns401()
96112
using var request = McpTestHelper.CreateInitializeRequest("/mcp");
97113
McpTestHelper.AddBearerToken(request, "mcp_invalid_token_that_does_not_exist");
98114
using HttpResponseMessage response = await client.SendAsync(request);
99-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
115+
await AssertUnauthorizedMcpChallengeAsync(response);
100116
}
101117

102118
[Test]
@@ -115,7 +131,7 @@ public async Task McpEndpoint_WithRevokedToken_Returns401()
115131
using var request = McpTestHelper.CreateInitializeRequest("/mcp");
116132
McpTestHelper.AddBearerToken(request, rawToken);
117133
using HttpResponseMessage response = await client.SendAsync(request);
118-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
134+
await AssertUnauthorizedMcpChallengeAsync(response);
119135
}
120136

121137
[Test]
@@ -131,7 +147,25 @@ public async Task McpEndpoint_WithExpiredToken_Returns401()
131147
using var request = McpTestHelper.CreateInitializeRequest("/mcp");
132148
McpTestHelper.AddBearerToken(request, rawToken);
133149
using HttpResponseMessage response = await client.SendAsync(request);
134-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
150+
await AssertUnauthorizedMcpChallengeAsync(response);
151+
}
152+
153+
[Test]
154+
public async Task WellKnownOAuthProtectedResource_AllMethodsReturn404WithoutRedirectAndNoStore()
155+
{
156+
HttpClient client = McpTestHelper.CreateClient(factory);
157+
158+
foreach (HttpMethod method in new[] { HttpMethod.Get, HttpMethod.Post, HttpMethod.Options })
159+
{
160+
using var request = new HttpRequestMessage(method, "/.well-known/oauth-protected-resource");
161+
using HttpResponseMessage response = await client.SendAsync(request);
162+
163+
await Assert.That(response.StatusCode)
164+
.IsEqualTo(HttpStatusCode.NotFound)
165+
.Because($"/.well-known should short-circuit for {method} requests");
166+
await Assert.That(response.Headers.Location).IsNull();
167+
await Assert.That(response.Headers.CacheControl?.NoStore ?? false).IsTrue();
168+
}
135169
}
136170

137171
[Test]
@@ -169,4 +203,12 @@ public async Task McpEndpoint_GetFromLoopbackOrigin_Returns405WithoutRedirect()
169203
await Assert.That(response.Headers.TryGetValues("Access-Control-Allow-Origin", out IEnumerable<string>? origins)).IsTrue();
170204
await Assert.That(origins?.SingleOrDefault()).IsEqualTo("http://localhost:6274");
171205
}
206+
207+
private static async Task AssertUnauthorizedMcpChallengeAsync(HttpResponseMessage response)
208+
{
209+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
210+
await Assert.That(response.Headers.Location).IsNull();
211+
await Assert.That(response.Headers.TryGetValues("WWW-Authenticate", out IEnumerable<string>? values)).IsTrue();
212+
await Assert.That(values?.Any(value => value.Contains("Bearer", StringComparison.OrdinalIgnoreCase)) ?? false).IsTrue();
213+
}
172214
}

EssentialCSharp.Web/Auth/McpApiKeyAuthenticationHandler.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,17 @@ public class McpApiKeyAuthenticationHandler(
1818
McpApiTokenService tokenService)
1919
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
2020
{
21+
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
22+
{
23+
Response.StatusCode = StatusCodes.Status401Unauthorized;
24+
if (!Response.Headers.ContainsKey("WWW-Authenticate"))
25+
{
26+
Response.Headers.Append("WWW-Authenticate", "Bearer");
27+
}
28+
29+
return Task.CompletedTask;
30+
}
31+
2132
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
2233
{
2334
if (!McpBearerAuthentication.TryGetRawToken(Request, out string? rawToken))

EssentialCSharp.Web/Program.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,18 @@ await McpJsonRpcResponseWriter.WriteErrorAsync(
553553
.RequireAuthorization("McpPolicy")
554554
.RequireRateLimiting(McpRateLimiterPolicy.PolicyName);
555555

556+
app.Map("/.well-known", (HttpResponse response) =>
557+
{
558+
response.Headers.CacheControl = "no-store";
559+
return Results.NotFound();
560+
}).DisableRateLimiting();
561+
562+
app.Map("/.well-known/{**path}", (HttpResponse response) =>
563+
{
564+
response.Headers.CacheControl = "no-store";
565+
return Results.NotFound();
566+
}).DisableRateLimiting();
567+
556568
app.MapFallbackToController("Index", "Home");
557569

558570
// Generate sitemap.xml at startup

0 commit comments

Comments
 (0)