Fix router ASP.NET request body binding

This commit is contained in:
master
2026-03-07 04:26:54 +02:00
parent 4bec133724
commit afa23fc504
6 changed files with 225 additions and 10 deletions

View File

@@ -176,6 +176,7 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
httpRequest.QueryString = queryString;
httpRequest.Scheme = "https"; // Router always uses secure transport conceptually
httpRequest.Host = new HostString(_options.ServiceName);
httpRequest.Protocol = "HTTP/2";
// Copy headers
foreach (var (key, value) in request.Headers)
@@ -189,13 +190,22 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
httpRequest.Body = new MemoryStream(request.Payload.ToArray());
httpRequest.ContentLength = request.Payload.Length;
// Try to set Content-Type from headers
if (request.Headers.TryGetValue("Content-Type", out var contentType))
// HTTP header names are case-insensitive; browsers often emit lowercase
// names over HTTP/2, so read back from the copied header collection.
if (httpRequest.Headers.TryGetValue("Content-Type", out var contentType))
{
httpRequest.ContentType = contentType;
httpRequest.ContentType = contentType.ToString();
}
}
httpContext.Features.Set<IHttpRequestBodyDetectionFeature>(new RouterRequestBodyDetectionFeature
{
CanHaveBody = request.Payload.Length > 0
|| HttpMethods.IsPost(request.Method)
|| HttpMethods.IsPut(request.Method)
|| HttpMethods.IsPatch(request.Method)
});
// Populate identity from StellaOps headers
var identityResult = PopulateIdentity(httpContext, request.Headers);
if (identityResult == IdentityPopulationResult.Rejected)
@@ -253,14 +263,14 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
{
failureReason = null;
if (!headers.TryGetValue(IdentityEnvelopeHeader, out var encodedEnvelope) ||
if (!TryGetHeaderValue(headers, IdentityEnvelopeHeader, out var encodedEnvelope) ||
string.IsNullOrWhiteSpace(encodedEnvelope))
{
failureReason = "envelope header missing";
return false;
}
if (!headers.TryGetValue(IdentityEnvelopeSignatureHeader, out var encodedSignature) ||
if (!TryGetHeaderValue(headers, IdentityEnvelopeSignatureHeader, out var encodedSignature) ||
string.IsNullOrWhiteSpace(encodedSignature))
{
failureReason = "envelope signature header missing";
@@ -357,26 +367,26 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
var claims = new List<Claim>();
// Actor (subject/user ID)
if (headers.TryGetValue(ActorHeader, out var actor) && !string.IsNullOrEmpty(actor))
if (TryGetHeaderValue(headers, ActorHeader, out var actor) && !string.IsNullOrEmpty(actor))
{
claims.Add(new Claim(ClaimTypes.NameIdentifier, actor));
claims.Add(new Claim("sub", actor));
}
// Tenant
if (headers.TryGetValue(TenantHeader, out var tenant) && !string.IsNullOrEmpty(tenant))
if (TryGetHeaderValue(headers, TenantHeader, out var tenant) && !string.IsNullOrEmpty(tenant))
{
claims.Add(new Claim("tenant", tenant));
}
// Session
if (headers.TryGetValue(SessionHeader, out var session) && !string.IsNullOrEmpty(session))
if (TryGetHeaderValue(headers, SessionHeader, out var session) && !string.IsNullOrEmpty(session))
{
claims.Add(new Claim("session", session));
}
// Scopes (space-separated)
if (headers.TryGetValue(ScopesHeader, out var scopes) && !string.IsNullOrEmpty(scopes))
if (TryGetHeaderValue(headers, ScopesHeader, out var scopes) && !string.IsNullOrEmpty(scopes))
{
foreach (var scope in scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
@@ -385,7 +395,7 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
}
// Roles (space-separated)
if (headers.TryGetValue(RolesHeader, out var roles) && !string.IsNullOrEmpty(roles))
if (TryGetHeaderValue(headers, RolesHeader, out var roles) && !string.IsNullOrEmpty(roles))
{
foreach (var role in roles.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
@@ -400,6 +410,29 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
}
}
private static bool TryGetHeaderValue(
IReadOnlyDictionary<string, string> headers,
string name,
out string value)
{
if (headers.TryGetValue(name, out value!))
{
return true;
}
foreach (var (key, candidate) in headers)
{
if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase))
{
value = candidate;
return true;
}
}
value = string.Empty;
return false;
}
#pragma warning disable CS1998
private async Task<RouteEndpoint?> MatchEndpointAsync(HttpContext httpContext)
{
@@ -768,6 +801,11 @@ public sealed class AspNetRouterRequestDispatcher : IAspNetRouterRequestDispatch
public RouteValueDictionary RouteValues { get; set; } = new();
}
private sealed class RouterRequestBodyDetectionFeature : IHttpRequestBodyDetectionFeature
{
public bool CanHaveBody { get; init; }
}
/// <summary>
/// Populates the IStellaOpsTenantAccessor. This mimics what StellaOpsTenantMiddleware does
/// in the normal HTTP pipeline: resolves the tenant from claims/headers and sets it

View File

@@ -10,3 +10,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0388-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.Microservice.AspNetCore. |
| AUDIT-0388-A | TODO | Revalidated 2026-01-07 (open findings). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| HDR-CASE-01 | DONE | Sprint `docs/implplan/SPRINT_20260307_011_Router_case_insensitive_forwarded_header_binding.md`: restored case-insensitive forwarded header handling in the ASP.NET bridge so lowercase HTTP/2 `content-type` reaches JSON-bound endpoints. |

View File

@@ -1,4 +1,5 @@
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Routing.Matching;
@@ -103,6 +104,100 @@ public sealed class AspNetRouterRequestDispatcherTests
Assert.Equal("alice", Encoding.UTF8.GetString(response.Payload.ToArray()));
}
[Fact]
public async Task DispatchAsync_RoundTrippedLowercaseContentType_PreservesJsonRequestMetadata()
{
var routeEndpoint = new RouteEndpoint(
async context =>
{
using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true);
var body = await reader.ReadToEndAsync();
var isJson = string.Equals(context.Request.ContentType, "application/json", StringComparison.OrdinalIgnoreCase);
context.Response.StatusCode = isJson && body == "{\"name\":\"stella\"}"
? StatusCodes.Status200OK
: StatusCodes.Status400BadRequest;
},
RoutePatternFactory.Parse("/api/v1/context/preferences"),
order: 0,
new EndpointMetadataCollection(new HttpMethodMetadata(["PUT"])),
displayName: "Preferences");
var dispatcher = CreateDispatcher(
routeEndpoint,
new StellaRouterBridgeOptions
{
ServiceName = "platform",
Version = "1.0.0-alpha1",
Region = "local",
AuthorizationTrustMode = GatewayAuthorizationTrustMode.ServiceEnforced
});
var transportFrame = FrameConverter.ToFrame(new RequestFrame
{
RequestId = "req-3",
Method = "PUT",
Path = "/api/v1/context/preferences",
Headers = new Dictionary<string, string>
{
["content-type"] = "application/json"
},
Payload = Encoding.UTF8.GetBytes("{\"name\":\"stella\"}")
});
var roundTrippedRequest = FrameConverter.ToRequestFrame(transportFrame);
Assert.NotNull(roundTrippedRequest);
var response = await dispatcher.DispatchAsync(roundTrippedRequest!);
Assert.Equal(StatusCodes.Status200OK, response.StatusCode);
}
[Fact]
public async Task DispatchAsync_RoundTrippedLowercaseContentType_BindsMinimalApiJsonBody()
{
var builder = WebApplication.CreateBuilder();
var app = builder.Build();
app.MapPut("/api/v1/context/preferences", (PreferencesBody body) => Results.Ok(body));
var endpointRouteBuilder = (IEndpointRouteBuilder)app;
var endpointDataSource = new StaticEndpointDataSource(
endpointRouteBuilder.DataSources.SelectMany(static dataSource => dataSource.Endpoints).ToArray());
var dispatcher = new AspNetRouterRequestDispatcher(
app.Services,
endpointDataSource,
new StellaRouterBridgeOptions
{
ServiceName = "platform",
Version = "1.0.0-alpha1",
Region = "local",
AuthorizationTrustMode = GatewayAuthorizationTrustMode.ServiceEnforced
},
NullLogger<AspNetRouterRequestDispatcher>.Instance);
var transportFrame = FrameConverter.ToFrame(new RequestFrame
{
RequestId = "req-4",
Method = "PUT",
Path = "/api/v1/context/preferences",
Headers = new Dictionary<string, string>
{
["content-type"] = "application/json"
},
Payload = Encoding.UTF8.GetBytes("{\"regions\":[],\"environments\":[\"dev\"],\"timeWindow\":\"24h\"}")
});
var roundTrippedRequest = FrameConverter.ToRequestFrame(transportFrame);
Assert.NotNull(roundTrippedRequest);
var response = await dispatcher.DispatchAsync(roundTrippedRequest!);
var responseBody = Encoding.UTF8.GetString(response.Payload.ToArray());
Assert.Equal(StatusCodes.Status200OK, response.StatusCode);
Assert.Contains("\"environments\":[\"dev\"]", responseBody, StringComparison.Ordinal);
Assert.Contains("\"timeWindow\":\"24h\"", responseBody, StringComparison.Ordinal);
}
private static AspNetRouterRequestDispatcher CreateDispatcher(RouteEndpoint endpoint, StellaRouterBridgeOptions options)
{
var services = new ServiceCollection();
@@ -130,4 +225,6 @@ public sealed class AspNetRouterRequestDispatcherTests
{
public override Task SelectAsync(HttpContext httpContext, CandidateSet candidates) => Task.CompletedTask;
}
private sealed record PreferencesBody(string[] Regions, string[] Environments, string TimeWindow);
}

View File

@@ -7,3 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| RVM-02 | DONE | Added `AddRouterMicroservice()` DI tests for disabled mode, gateway validation, TCP registration, and Valkey messaging options wiring via plugin-based transport activation; extended ASP.NET discovery tests for OpenAPI metadata/schema extraction and typed response-schema selection fallback. |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaOps.Router.AspNet.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| HDR-CASE-01 | DONE | Added the lowercase `content-type` request-frame regression so JSON body dispatch survives Router frame round-trips. |