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

@@ -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;
}
}