5100* tests strengthtenen work
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user