5100* tests strengthtenen work

This commit is contained in:
StellaOps Bot
2025-12-24 12:38:34 +02:00
parent 9a08d10b89
commit 02772c7a27
117 changed files with 29941 additions and 66 deletions

View File

@@ -102,6 +102,19 @@ public sealed class GatewayAuthOptions
public bool AllowAnonymous { get; set; } = true;
/// <summary>
/// Enable legacy X-Stella-* headers in addition to X-StellaOps-* headers.
/// Default: true (for migration compatibility).
/// </summary>
public bool EnableLegacyHeaders { get; set; } = true;
/// <summary>
/// Allow client-provided scope headers in offline/pre-prod mode.
/// Default: false (forbidden for security).
/// WARNING: Only enable this in explicitly isolated offline/pre-prod environments.
/// </summary>
public bool AllowScopeHeader { get; set; } = false;
public GatewayAuthorityOptions Authority { get; set; } = new();
}

View File

@@ -3,7 +3,11 @@ namespace StellaOps.Gateway.WebService.Middleware;
public static class GatewayContextKeys
{
public const string TenantId = "Gateway.TenantId";
public const string ProjectId = "Gateway.ProjectId";
public const string Actor = "Gateway.Actor";
public const string Scopes = "Gateway.Scopes";
public const string DpopThumbprint = "Gateway.DpopThumbprint";
public const string MtlsThumbprint = "Gateway.MtlsThumbprint";
public const string CnfJson = "Gateway.CnfJson";
public const string IsAnonymous = "Gateway.IsAnonymous";
}

View File

@@ -0,0 +1,333 @@
using System.Security.Claims;
using System.Text.Json;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Gateway.WebService.Middleware;
/// <summary>
/// Middleware that enforces the Gateway identity header policy:
/// 1. Strips all reserved identity headers from incoming requests (prevents spoofing)
/// 2. Computes effective identity from validated principal claims
/// 3. Writes downstream identity headers for microservice consumption
/// 4. Stores normalized identity context in HttpContext.Items
/// </summary>
/// <remarks>
/// This middleware replaces the legacy ClaimsPropagationMiddleware and TenantMiddleware
/// which used "set-if-missing" semantics that allowed client header spoofing.
/// </remarks>
public sealed class IdentityHeaderPolicyMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<IdentityHeaderPolicyMiddleware> _logger;
private readonly IdentityHeaderPolicyOptions _options;
/// <summary>
/// Reserved identity headers that must never be trusted from external clients.
/// These are stripped from incoming requests and overwritten from validated claims.
/// </summary>
private static readonly string[] ReservedHeaders =
[
// StellaOps canonical headers
"X-StellaOps-Tenant",
"X-StellaOps-Project",
"X-StellaOps-Actor",
"X-StellaOps-Scopes",
"X-StellaOps-Client",
// Legacy Stella headers (compatibility)
"X-Stella-Tenant",
"X-Stella-Project",
"X-Stella-Actor",
"X-Stella-Scopes",
// Raw claim headers (internal/legacy pass-through)
"sub",
"tid",
"scope",
"scp",
"cnf",
"cnf.jkt"
];
public IdentityHeaderPolicyMiddleware(
RequestDelegate next,
ILogger<IdentityHeaderPolicyMiddleware> logger,
IdentityHeaderPolicyOptions options)
{
_next = next;
_logger = logger;
_options = options;
}
public async Task InvokeAsync(HttpContext context)
{
// Skip processing for system paths (health, metrics, openapi, etc.)
if (GatewayRoutes.IsSystemPath(context.Request.Path))
{
await _next(context);
return;
}
// Step 1: Strip all reserved identity headers from incoming request
StripReservedHeaders(context);
// Step 2: Extract identity from validated principal
var identity = ExtractIdentity(context);
// Step 3: Store normalized identity in HttpContext.Items
StoreIdentityContext(context, identity);
// Step 4: Write downstream identity headers
WriteDownstreamHeaders(context, identity);
await _next(context);
}
private void StripReservedHeaders(HttpContext context)
{
foreach (var header in ReservedHeaders)
{
if (context.Request.Headers.ContainsKey(header))
{
_logger.LogDebug(
"Stripped reserved identity header {Header} from request {TraceId}",
header,
context.TraceIdentifier);
context.Request.Headers.Remove(header);
}
}
}
private IdentityContext ExtractIdentity(HttpContext context)
{
var principal = context.User;
var isAuthenticated = principal.Identity?.IsAuthenticated == true;
if (!isAuthenticated)
{
return new IdentityContext
{
IsAnonymous = true,
Actor = "anonymous",
Scopes = _options.AnonymousScopes ?? []
};
}
// Extract subject (actor)
var actor = principal.FindFirstValue(StellaOpsClaimTypes.Subject);
// Extract tenant - try canonical claim first, then legacy 'tid'
var tenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant)
?? principal.FindFirstValue("tid");
// Extract project (optional)
var project = principal.FindFirstValue(StellaOpsClaimTypes.Project);
// Extract scopes - try 'scp' claims first (individual items), then 'scope' (space-separated)
var scopes = ExtractScopes(principal);
// Extract cnf (confirmation claim) for DPoP/sender constraint
var cnfJson = principal.FindFirstValue("cnf");
string? dpopThumbprint = null;
if (!string.IsNullOrWhiteSpace(cnfJson))
{
TryParseCnfThumbprint(cnfJson, out dpopThumbprint);
}
return new IdentityContext
{
IsAnonymous = false,
Actor = actor,
Tenant = tenant,
Project = project,
Scopes = scopes,
CnfJson = cnfJson,
DpopThumbprint = dpopThumbprint
};
}
private static HashSet<string> ExtractScopes(ClaimsPrincipal principal)
{
var scopes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// First try individual scope claims (scp)
var scpClaims = principal.FindAll(StellaOpsClaimTypes.ScopeItem);
foreach (var claim in scpClaims)
{
if (!string.IsNullOrWhiteSpace(claim.Value))
{
scopes.Add(claim.Value.Trim());
}
}
// If no scp claims, try space-separated scope claim
if (scopes.Count == 0)
{
var scopeClaims = principal.FindAll(StellaOpsClaimTypes.Scope);
foreach (var claim in scopeClaims)
{
if (!string.IsNullOrWhiteSpace(claim.Value))
{
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var part in parts)
{
scopes.Add(part);
}
}
}
}
return scopes;
}
private void StoreIdentityContext(HttpContext context, IdentityContext identity)
{
context.Items[GatewayContextKeys.IsAnonymous] = identity.IsAnonymous;
if (!string.IsNullOrEmpty(identity.Actor))
{
context.Items[GatewayContextKeys.Actor] = identity.Actor;
}
if (!string.IsNullOrEmpty(identity.Tenant))
{
context.Items[GatewayContextKeys.TenantId] = identity.Tenant;
}
if (!string.IsNullOrEmpty(identity.Project))
{
context.Items[GatewayContextKeys.ProjectId] = identity.Project;
}
if (identity.Scopes.Count > 0)
{
context.Items[GatewayContextKeys.Scopes] = identity.Scopes;
}
if (!string.IsNullOrEmpty(identity.CnfJson))
{
context.Items[GatewayContextKeys.CnfJson] = identity.CnfJson;
}
if (!string.IsNullOrEmpty(identity.DpopThumbprint))
{
context.Items[GatewayContextKeys.DpopThumbprint] = identity.DpopThumbprint;
}
}
private void WriteDownstreamHeaders(HttpContext context, IdentityContext identity)
{
var headers = context.Request.Headers;
// Actor header
if (!string.IsNullOrEmpty(identity.Actor))
{
headers["X-StellaOps-Actor"] = identity.Actor;
if (_options.EnableLegacyHeaders)
{
headers["X-Stella-Actor"] = identity.Actor;
}
}
// Tenant header
if (!string.IsNullOrEmpty(identity.Tenant))
{
headers["X-StellaOps-Tenant"] = identity.Tenant;
if (_options.EnableLegacyHeaders)
{
headers["X-Stella-Tenant"] = identity.Tenant;
}
}
// Project header (optional)
if (!string.IsNullOrEmpty(identity.Project))
{
headers["X-StellaOps-Project"] = identity.Project;
if (_options.EnableLegacyHeaders)
{
headers["X-Stella-Project"] = identity.Project;
}
}
// Scopes header (space-delimited, sorted for determinism)
if (identity.Scopes.Count > 0)
{
var sortedScopes = identity.Scopes.OrderBy(s => s, StringComparer.Ordinal);
var scopesValue = string.Join(" ", sortedScopes);
headers["X-StellaOps-Scopes"] = scopesValue;
if (_options.EnableLegacyHeaders)
{
headers["X-Stella-Scopes"] = scopesValue;
}
}
else if (identity.IsAnonymous)
{
// Explicit empty scopes for anonymous to prevent ambiguity
headers["X-StellaOps-Scopes"] = string.Empty;
if (_options.EnableLegacyHeaders)
{
headers["X-Stella-Scopes"] = string.Empty;
}
}
// DPoP thumbprint (if present)
if (!string.IsNullOrEmpty(identity.DpopThumbprint))
{
headers["cnf.jkt"] = identity.DpopThumbprint;
}
}
private static bool TryParseCnfThumbprint(string json, out string? jkt)
{
jkt = null;
try
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.TryGetProperty("jkt", out var jktElement) &&
jktElement.ValueKind == JsonValueKind.String)
{
jkt = jktElement.GetString();
}
return !string.IsNullOrWhiteSpace(jkt);
}
catch (JsonException)
{
return false;
}
}
private sealed class IdentityContext
{
public bool IsAnonymous { get; init; }
public string? Actor { get; init; }
public string? Tenant { get; init; }
public string? Project { get; init; }
public HashSet<string> Scopes { get; init; } = [];
public string? CnfJson { get; init; }
public string? DpopThumbprint { get; init; }
}
}
/// <summary>
/// Configuration options for the identity header policy middleware.
/// </summary>
public sealed class IdentityHeaderPolicyOptions
{
/// <summary>
/// Enable legacy X-Stella-* headers in addition to X-StellaOps-* headers.
/// Default: true (for migration compatibility).
/// </summary>
public bool EnableLegacyHeaders { get; set; } = true;
/// <summary>
/// Scopes to assign to anonymous requests.
/// Default: empty (no scopes).
/// </summary>
public HashSet<string>? AnonymousScopes { get; set; }
/// <summary>
/// Allow client-provided scope headers in offline/pre-prod mode.
/// Default: false (forbidden for security).
/// </summary>
public bool AllowScopeHeaderOverride { get; set; } = false;
}

View File

@@ -21,6 +21,7 @@ using StellaOps.Router.Gateway.RateLimit;
using StellaOps.Router.Gateway.Routing;
using StellaOps.Router.Transport.Tcp;
using StellaOps.Router.Transport.Tls;
using StellaOps.Router.AspNet;
var builder = WebApplication.CreateBuilder(args);
@@ -64,17 +65,33 @@ builder.Services.AddHostedService<GatewayHealthMonitorService>();
builder.Services.AddSingleton<IDpopReplayCache, InMemoryDpopReplayCache>();
builder.Services.AddSingleton<IDpopProofValidator, DpopProofValidator>();
// Identity header policy options
builder.Services.AddSingleton(new IdentityHeaderPolicyOptions
{
EnableLegacyHeaders = bootstrapOptions.Auth.EnableLegacyHeaders,
AllowScopeHeaderOverride = bootstrapOptions.Auth.AllowScopeHeader
});
ConfigureAuthentication(builder, bootstrapOptions);
ConfigureGatewayOptionsMapping(builder, bootstrapOptions);
// Stella Router integration
var routerOptions = builder.Configuration.GetSection("Gateway:Router").Get<StellaRouterOptionsBase>();
builder.Services.TryAddStellaRouter(
serviceName: "gateway",
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
routerOptions: routerOptions);
var app = builder.Build();
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseAuthentication();
app.UseMiddleware<SenderConstraintMiddleware>();
app.UseMiddleware<TenantMiddleware>();
app.UseMiddleware<ClaimsPropagationMiddleware>();
// IdentityHeaderPolicyMiddleware replaces TenantMiddleware and ClaimsPropagationMiddleware
// It strips reserved identity headers and overwrites them from validated claims (security fix)
app.UseMiddleware<IdentityHeaderPolicyMiddleware>();
app.UseMiddleware<HealthCheckMiddleware>();
app.TryUseStellaRouter(routerOptions);
if (bootstrapOptions.OpenApi.Enabled)
{
@@ -95,6 +112,9 @@ app.UseWhen(
branch.UseMiddleware<RequestRoutingMiddleware>();
});
// Refresh Router endpoint cache
app.TryRefreshStellaRouterEndpoints(routerOptions);
await app.RunAsync();
static void ConfigureAuthentication(WebApplicationBuilder builder, GatewayOptions options)

View File

@@ -13,5 +13,6 @@
<ProjectReference Include="..\..\__Libraries\StellaOps.Auth.Security\StellaOps.Auth.Security.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj" />
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,502 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Auth.Abstractions;
using StellaOps.Gateway.WebService.Middleware;
namespace StellaOps.Gateway.WebService.Tests.Middleware;
/// <summary>
/// Unit tests for <see cref="IdentityHeaderPolicyMiddleware"/>.
/// Verifies that:
/// 1. Reserved identity headers are stripped from incoming requests
/// 2. Headers are overwritten from validated claims (not "set-if-missing")
/// 3. Client-provided headers cannot spoof identity
/// 4. Canonical and legacy headers are written correctly
/// </summary>
public sealed class IdentityHeaderPolicyMiddlewareTests
{
private readonly IdentityHeaderPolicyOptions _options;
private bool _nextCalled;
public IdentityHeaderPolicyMiddlewareTests()
{
_options = new IdentityHeaderPolicyOptions
{
EnableLegacyHeaders = true,
AllowScopeHeaderOverride = false
};
_nextCalled = false;
}
private IdentityHeaderPolicyMiddleware CreateMiddleware()
{
_nextCalled = false;
return new IdentityHeaderPolicyMiddleware(
_ =>
{
_nextCalled = true;
return Task.CompletedTask;
},
NullLogger<IdentityHeaderPolicyMiddleware>.Instance,
_options);
}
#region Reserved Header Stripping
[Fact]
public async Task InvokeAsync_StripsAllReservedStellaOpsHeaders()
{
var middleware = CreateMiddleware();
var context = CreateHttpContext("/api/scan");
// Client attempts to spoof identity headers
context.Request.Headers["X-StellaOps-Tenant"] = "spoofed-tenant";
context.Request.Headers["X-StellaOps-Project"] = "spoofed-project";
context.Request.Headers["X-StellaOps-Actor"] = "spoofed-actor";
context.Request.Headers["X-StellaOps-Scopes"] = "admin superuser";
context.Request.Headers["X-StellaOps-Client"] = "spoofed-client";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Spoofed values should be replaced with anonymous identity values
Assert.DoesNotContain("X-StellaOps-Tenant", context.Request.Headers.Keys); // No tenant for anonymous
Assert.DoesNotContain("X-StellaOps-Project", context.Request.Headers.Keys); // No project for anonymous
// Actor is overwritten with "anonymous", not spoofed value
Assert.Equal("anonymous", context.Request.Headers["X-StellaOps-Actor"].ToString());
// Spoofed scopes are replaced with empty scopes for anonymous
Assert.Equal(string.Empty, context.Request.Headers["X-StellaOps-Scopes"].ToString());
}
[Fact]
public async Task InvokeAsync_StripsAllReservedLegacyHeaders()
{
var middleware = CreateMiddleware();
var context = CreateHttpContext("/api/scan");
// Client attempts to spoof legacy headers
context.Request.Headers["X-Stella-Tenant"] = "spoofed-tenant";
context.Request.Headers["X-Stella-Project"] = "spoofed-project";
context.Request.Headers["X-Stella-Actor"] = "spoofed-actor";
context.Request.Headers["X-Stella-Scopes"] = "admin";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Spoofed values should be replaced with anonymous identity values
Assert.DoesNotContain("X-Stella-Tenant", context.Request.Headers.Keys); // No tenant for anonymous
Assert.DoesNotContain("X-Stella-Project", context.Request.Headers.Keys); // No project for anonymous
// Actor is overwritten with "anonymous" (legacy headers enabled by default)
Assert.Equal("anonymous", context.Request.Headers["X-Stella-Actor"].ToString());
// Spoofed scopes are replaced with empty scopes for anonymous
Assert.Equal(string.Empty, context.Request.Headers["X-Stella-Scopes"].ToString());
}
[Fact]
public async Task InvokeAsync_StripsRawClaimHeaders()
{
var middleware = CreateMiddleware();
var context = CreateHttpContext("/api/scan");
// Client attempts to spoof raw claim headers
context.Request.Headers["sub"] = "spoofed-subject";
context.Request.Headers["tid"] = "spoofed-tenant";
context.Request.Headers["scope"] = "admin superuser";
context.Request.Headers["scp"] = "admin";
context.Request.Headers["cnf"] = "{\"jkt\":\"spoofed-thumbprint\"}";
context.Request.Headers["cnf.jkt"] = "spoofed-thumbprint";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Raw claim headers should be stripped
Assert.DoesNotContain("sub", context.Request.Headers.Keys);
Assert.DoesNotContain("tid", context.Request.Headers.Keys);
Assert.DoesNotContain("scope", context.Request.Headers.Keys);
Assert.DoesNotContain("scp", context.Request.Headers.Keys);
Assert.DoesNotContain("cnf", context.Request.Headers.Keys);
Assert.DoesNotContain("cnf.jkt", context.Request.Headers.Keys);
}
#endregion
#region Header Overwriting (Not Set-If-Missing)
[Fact]
public async Task InvokeAsync_OverwritesSpoofedTenantWithClaimValue()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Tenant, "real-tenant"),
new Claim(StellaOpsClaimTypes.Subject, "real-subject")
};
var context = CreateHttpContext("/api/scan", claims);
// Client attempts to spoof tenant
context.Request.Headers["X-StellaOps-Tenant"] = "spoofed-tenant";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Header should contain claim value, not spoofed value
Assert.Equal("real-tenant", context.Request.Headers["X-StellaOps-Tenant"].ToString());
}
[Fact]
public async Task InvokeAsync_OverwritesSpoofedActorWithClaimValue()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "real-actor")
};
var context = CreateHttpContext("/api/scan", claims);
// Client attempts to spoof actor
context.Request.Headers["X-StellaOps-Actor"] = "spoofed-actor";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal("real-actor", context.Request.Headers["X-StellaOps-Actor"].ToString());
}
[Fact]
public async Task InvokeAsync_OverwritesSpoofedScopesWithClaimValue()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim(StellaOpsClaimTypes.Scope, "read write")
};
var context = CreateHttpContext("/api/scan", claims);
// Client attempts to spoof scopes
context.Request.Headers["X-StellaOps-Scopes"] = "admin superuser delete-all";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Should contain actual scopes, not spoofed scopes
var actualScopes = context.Request.Headers["X-StellaOps-Scopes"].ToString();
Assert.Contains("read", actualScopes);
Assert.Contains("write", actualScopes);
Assert.DoesNotContain("admin", actualScopes);
Assert.DoesNotContain("superuser", actualScopes);
Assert.DoesNotContain("delete-all", actualScopes);
}
#endregion
#region Claim Extraction
[Fact]
public async Task InvokeAsync_ExtractsSubjectFromSubClaim()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user-123")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal("user-123", context.Request.Headers["X-StellaOps-Actor"].ToString());
Assert.Equal("user-123", context.Items[GatewayContextKeys.Actor]);
}
[Fact]
public async Task InvokeAsync_ExtractsTenantFromStellaOpsTenantClaim()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim(StellaOpsClaimTypes.Tenant, "tenant-abc")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal("tenant-abc", context.Request.Headers["X-StellaOps-Tenant"].ToString());
Assert.Equal("tenant-abc", context.Items[GatewayContextKeys.TenantId]);
}
[Fact]
public async Task InvokeAsync_ExtractsTenantFromTidClaimAsFallback()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim("tid", "legacy-tenant-456")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal("legacy-tenant-456", context.Request.Headers["X-StellaOps-Tenant"].ToString());
}
[Fact]
public async Task InvokeAsync_ExtractsScopesFromSpaceSeparatedScopeClaim()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim(StellaOpsClaimTypes.Scope, "read write delete")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
var scopes = (HashSet<string>)context.Items[GatewayContextKeys.Scopes]!;
Assert.Contains("read", scopes);
Assert.Contains("write", scopes);
Assert.Contains("delete", scopes);
}
[Fact]
public async Task InvokeAsync_ExtractsScopesFromIndividualScpClaims()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim(StellaOpsClaimTypes.ScopeItem, "read"),
new Claim(StellaOpsClaimTypes.ScopeItem, "write"),
new Claim(StellaOpsClaimTypes.ScopeItem, "admin")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
var scopes = (HashSet<string>)context.Items[GatewayContextKeys.Scopes]!;
Assert.Contains("read", scopes);
Assert.Contains("write", scopes);
Assert.Contains("admin", scopes);
}
[Fact]
public async Task InvokeAsync_ScopesAreSortedDeterministically()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim(StellaOpsClaimTypes.ScopeItem, "zebra"),
new Claim(StellaOpsClaimTypes.ScopeItem, "apple"),
new Claim(StellaOpsClaimTypes.ScopeItem, "mango")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal("apple mango zebra", context.Request.Headers["X-StellaOps-Scopes"].ToString());
}
#endregion
#region Legacy Header Compatibility
[Fact]
public async Task InvokeAsync_WritesLegacyHeadersWhenEnabled()
{
_options.EnableLegacyHeaders = true;
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user-123"),
new Claim(StellaOpsClaimTypes.Tenant, "tenant-abc"),
new Claim(StellaOpsClaimTypes.Scope, "read write")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Both canonical and legacy headers should be present
Assert.Equal("user-123", context.Request.Headers["X-StellaOps-Actor"].ToString());
Assert.Equal("user-123", context.Request.Headers["X-Stella-Actor"].ToString());
Assert.Equal("tenant-abc", context.Request.Headers["X-StellaOps-Tenant"].ToString());
Assert.Equal("tenant-abc", context.Request.Headers["X-Stella-Tenant"].ToString());
}
[Fact]
public async Task InvokeAsync_OmitsLegacyHeadersWhenDisabled()
{
_options.EnableLegacyHeaders = false;
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user-123"),
new Claim(StellaOpsClaimTypes.Tenant, "tenant-abc")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Only canonical headers should be present
Assert.Equal("user-123", context.Request.Headers["X-StellaOps-Actor"].ToString());
Assert.DoesNotContain("X-Stella-Actor", context.Request.Headers.Keys);
Assert.Equal("tenant-abc", context.Request.Headers["X-StellaOps-Tenant"].ToString());
Assert.DoesNotContain("X-Stella-Tenant", context.Request.Headers.Keys);
}
#endregion
#region Anonymous Identity
[Fact]
public async Task InvokeAsync_UnauthenticatedRequest_SetsAnonymousIdentity()
{
var middleware = CreateMiddleware();
var context = CreateHttpContext("/api/scan");
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.True((bool)context.Items[GatewayContextKeys.IsAnonymous]!);
Assert.Equal("anonymous", context.Items[GatewayContextKeys.Actor]);
}
[Fact]
public async Task InvokeAsync_AuthenticatedRequest_SetsIsAnonymousFalse()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user-123")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.False((bool)context.Items[GatewayContextKeys.IsAnonymous]!);
}
[Fact]
public async Task InvokeAsync_AnonymousRequest_WritesEmptyScopes()
{
var middleware = CreateMiddleware();
var context = CreateHttpContext("/api/scan");
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal(string.Empty, context.Request.Headers["X-StellaOps-Scopes"].ToString());
}
#endregion
#region DPoP Thumbprint
[Fact]
public async Task InvokeAsync_ExtractsDpopThumbprintFromCnfClaim()
{
var middleware = CreateMiddleware();
const string jkt = "SHA256-thumbprint-abc123";
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim("cnf", $"{{\"jkt\":\"{jkt}\"}}")
};
var context = CreateHttpContext("/api/scan", claims);
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
Assert.Equal(jkt, context.Request.Headers["cnf.jkt"].ToString());
Assert.Equal(jkt, context.Items[GatewayContextKeys.DpopThumbprint]);
}
[Fact]
public async Task InvokeAsync_InvalidCnfJson_DoesNotThrow()
{
var middleware = CreateMiddleware();
var claims = new[]
{
new Claim(StellaOpsClaimTypes.Subject, "user"),
new Claim("cnf", "not-valid-json")
};
var context = CreateHttpContext("/api/scan", claims);
var exception = await Record.ExceptionAsync(() => middleware.InvokeAsync(context));
Assert.Null(exception);
Assert.True(_nextCalled);
Assert.DoesNotContain("cnf.jkt", context.Request.Headers.Keys);
}
#endregion
#region System Path Bypass
[Theory]
[InlineData("/health")]
[InlineData("/health/ready")]
[InlineData("/metrics")]
[InlineData("/openapi.json")]
[InlineData("/openapi.yaml")]
public async Task InvokeAsync_SystemPath_SkipsProcessing(string path)
{
var middleware = CreateMiddleware();
var context = CreateHttpContext(path);
// Add spoofed headers
context.Request.Headers["X-StellaOps-Tenant"] = "spoofed";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// System paths skip processing, so spoofed headers remain (not stripped)
Assert.Equal("spoofed", context.Request.Headers["X-StellaOps-Tenant"].ToString());
}
[Theory]
[InlineData("/api/scan")]
[InlineData("/api/v1/sbom")]
[InlineData("/jobs")]
public async Task InvokeAsync_NonSystemPath_ProcessesHeaders(string path)
{
var middleware = CreateMiddleware();
var context = CreateHttpContext(path);
// Add spoofed headers
context.Request.Headers["X-StellaOps-Tenant"] = "spoofed";
await middleware.InvokeAsync(context);
Assert.True(_nextCalled);
// Non-system paths strip spoofed headers
Assert.DoesNotContain("X-StellaOps-Tenant", context.Request.Headers.Keys);
}
#endregion
private static DefaultHttpContext CreateHttpContext(string path, params Claim[] claims)
{
var context = new DefaultHttpContext();
context.Request.Path = new PathString(path);
if (claims.Length > 0)
{
context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
}
return context;
}
}