Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
369 lines
11 KiB
C#
369 lines
11 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using StellaOps.Notifier.Worker.Security;
|
|
|
|
namespace StellaOps.Notifier.Tests.Security;
|
|
|
|
public class WebhookSecurityServiceTests
|
|
{
|
|
private readonly FakeTimeProvider _timeProvider;
|
|
private readonly WebhookSecurityOptions _options;
|
|
private readonly InMemoryWebhookSecurityService _webhookService;
|
|
|
|
public WebhookSecurityServiceTests()
|
|
{
|
|
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
|
_options = new WebhookSecurityOptions
|
|
{
|
|
DefaultAlgorithm = "SHA256",
|
|
EnableReplayProtection = true,
|
|
NonceCacheExpiry = TimeSpan.FromMinutes(10)
|
|
};
|
|
_webhookService = new InMemoryWebhookSecurityService(
|
|
Options.Create(_options),
|
|
_timeProvider,
|
|
NullLogger<InMemoryWebhookSecurityService>.Instance);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_NoConfig_ReturnsValidWithWarning()
|
|
{
|
|
// Arrange
|
|
var request = new WebhookValidationRequest
|
|
{
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
Body = "{\"test\": \"data\"}"
|
|
};
|
|
|
|
// Act
|
|
var result = await _webhookService.ValidateAsync(request);
|
|
|
|
// Assert
|
|
Assert.True(result.IsValid);
|
|
Assert.Contains(result.Warnings, w => w.Contains("No webhook security configuration"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_ValidSignature_ReturnsValid()
|
|
{
|
|
// Arrange
|
|
var config = new WebhookSecurityConfig
|
|
{
|
|
ConfigId = "config-001",
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
SecretKey = "test-secret-key",
|
|
Algorithm = "SHA256",
|
|
RequireSignature = true
|
|
};
|
|
await _webhookService.RegisterWebhookAsync(config);
|
|
|
|
var body = "{\"test\": \"data\"}";
|
|
var signature = _webhookService.GenerateSignature(body, config.SecretKey);
|
|
|
|
var request = new WebhookValidationRequest
|
|
{
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
Body = body,
|
|
Signature = signature
|
|
};
|
|
|
|
// Act
|
|
var result = await _webhookService.ValidateAsync(request);
|
|
|
|
// Assert
|
|
Assert.True(result.IsValid);
|
|
Assert.True(result.PassedChecks.HasFlag(WebhookValidationChecks.SignatureValid));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_InvalidSignature_ReturnsDenied()
|
|
{
|
|
// Arrange
|
|
var config = new WebhookSecurityConfig
|
|
{
|
|
ConfigId = "config-001",
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
SecretKey = "test-secret-key",
|
|
RequireSignature = true
|
|
};
|
|
await _webhookService.RegisterWebhookAsync(config);
|
|
|
|
var request = new WebhookValidationRequest
|
|
{
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
Body = "{\"test\": \"data\"}",
|
|
Signature = "invalid-signature"
|
|
};
|
|
|
|
// Act
|
|
var result = await _webhookService.ValidateAsync(request);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.True(result.FailedChecks.HasFlag(WebhookValidationChecks.SignatureValid));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_MissingSignature_ReturnsDenied()
|
|
{
|
|
// Arrange
|
|
var config = new WebhookSecurityConfig
|
|
{
|
|
ConfigId = "config-001",
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
SecretKey = "test-secret-key",
|
|
RequireSignature = true
|
|
};
|
|
await _webhookService.RegisterWebhookAsync(config);
|
|
|
|
var request = new WebhookValidationRequest
|
|
{
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
Body = "{\"test\": \"data\"}"
|
|
};
|
|
|
|
// Act
|
|
var result = await _webhookService.ValidateAsync(request);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Contains(result.Errors, e => e.Contains("Missing signature"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_IpNotInAllowlist_ReturnsDenied()
|
|
{
|
|
// Arrange
|
|
var config = new WebhookSecurityConfig
|
|
{
|
|
ConfigId = "config-001",
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
SecretKey = "test-secret-key",
|
|
RequireSignature = false,
|
|
EnforceIpAllowlist = true,
|
|
AllowedIps = ["192.168.1.0/24"]
|
|
};
|
|
await _webhookService.RegisterWebhookAsync(config);
|
|
|
|
var request = new WebhookValidationRequest
|
|
{
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
Body = "{\"test\": \"data\"}",
|
|
SourceIp = "10.0.0.1"
|
|
};
|
|
|
|
// Act
|
|
var result = await _webhookService.ValidateAsync(request);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.True(result.FailedChecks.HasFlag(WebhookValidationChecks.IpAllowed));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_IpInAllowlist_ReturnsValid()
|
|
{
|
|
// Arrange
|
|
var config = new WebhookSecurityConfig
|
|
{
|
|
ConfigId = "config-001",
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
SecretKey = "test-secret-key",
|
|
RequireSignature = false,
|
|
EnforceIpAllowlist = true,
|
|
AllowedIps = ["192.168.1.0/24"]
|
|
};
|
|
await _webhookService.RegisterWebhookAsync(config);
|
|
|
|
var request = new WebhookValidationRequest
|
|
{
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
Body = "{\"test\": \"data\"}",
|
|
SourceIp = "192.168.1.100"
|
|
};
|
|
|
|
// Act
|
|
var result = await _webhookService.ValidateAsync(request);
|
|
|
|
// Assert
|
|
Assert.True(result.IsValid);
|
|
Assert.True(result.PassedChecks.HasFlag(WebhookValidationChecks.IpAllowed));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_ExpiredTimestamp_ReturnsDenied()
|
|
{
|
|
// Arrange
|
|
var config = new WebhookSecurityConfig
|
|
{
|
|
ConfigId = "config-001",
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
SecretKey = "test-secret-key",
|
|
RequireSignature = false,
|
|
MaxRequestAge = TimeSpan.FromMinutes(5)
|
|
};
|
|
await _webhookService.RegisterWebhookAsync(config);
|
|
|
|
var request = new WebhookValidationRequest
|
|
{
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
Body = "{\"test\": \"data\"}",
|
|
Timestamp = _timeProvider.GetUtcNow().AddMinutes(-10)
|
|
};
|
|
|
|
// Act
|
|
var result = await _webhookService.ValidateAsync(request);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.True(result.FailedChecks.HasFlag(WebhookValidationChecks.NotExpired));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateAsync_ReplayAttack_ReturnsDenied()
|
|
{
|
|
// Arrange
|
|
var config = new WebhookSecurityConfig
|
|
{
|
|
ConfigId = "config-001",
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
SecretKey = "test-secret-key",
|
|
RequireSignature = true
|
|
};
|
|
await _webhookService.RegisterWebhookAsync(config);
|
|
|
|
var body = "{\"test\": \"data\"}";
|
|
var signature = _webhookService.GenerateSignature(body, config.SecretKey);
|
|
|
|
var request = new WebhookValidationRequest
|
|
{
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
Body = body,
|
|
Signature = signature,
|
|
Timestamp = _timeProvider.GetUtcNow()
|
|
};
|
|
|
|
// First request should succeed
|
|
var result1 = await _webhookService.ValidateAsync(request);
|
|
Assert.True(result1.IsValid);
|
|
|
|
// Act - second request with same signature should fail
|
|
var result2 = await _webhookService.ValidateAsync(request);
|
|
|
|
// Assert
|
|
Assert.False(result2.IsValid);
|
|
Assert.True(result2.FailedChecks.HasFlag(WebhookValidationChecks.NotReplay));
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateSignature_ProducesConsistentOutput()
|
|
{
|
|
// Arrange
|
|
var payload = "{\"test\": \"data\"}";
|
|
var secretKey = "test-secret";
|
|
|
|
// Act
|
|
var sig1 = _webhookService.GenerateSignature(payload, secretKey);
|
|
var sig2 = _webhookService.GenerateSignature(payload, secretKey);
|
|
|
|
// Assert
|
|
Assert.Equal(sig1, sig2);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UpdateAllowlistAsync_UpdatesConfig()
|
|
{
|
|
// Arrange
|
|
var config = new WebhookSecurityConfig
|
|
{
|
|
ConfigId = "config-001",
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
SecretKey = "test-secret-key",
|
|
EnforceIpAllowlist = true,
|
|
AllowedIps = ["192.168.1.0/24"]
|
|
};
|
|
await _webhookService.RegisterWebhookAsync(config);
|
|
|
|
// Act
|
|
await _webhookService.UpdateAllowlistAsync(
|
|
"tenant1", "channel1", ["10.0.0.0/8"], "admin");
|
|
|
|
// Assert
|
|
var updatedConfig = await _webhookService.GetConfigAsync("tenant1", "channel1");
|
|
Assert.NotNull(updatedConfig);
|
|
Assert.Single(updatedConfig.AllowedIps);
|
|
Assert.Equal("10.0.0.0/8", updatedConfig.AllowedIps[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IsIpAllowedAsync_NoConfig_ReturnsTrue()
|
|
{
|
|
// Act
|
|
var allowed = await _webhookService.IsIpAllowedAsync("tenant1", "channel1", "192.168.1.1");
|
|
|
|
// Assert
|
|
Assert.True(allowed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IsIpAllowedAsync_CidrMatch_ReturnsTrue()
|
|
{
|
|
// Arrange
|
|
var config = new WebhookSecurityConfig
|
|
{
|
|
ConfigId = "config-001",
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
SecretKey = "test-secret-key",
|
|
EnforceIpAllowlist = true,
|
|
AllowedIps = ["192.168.1.0/24"]
|
|
};
|
|
await _webhookService.RegisterWebhookAsync(config);
|
|
|
|
// Act
|
|
var allowed = await _webhookService.IsIpAllowedAsync("tenant1", "channel1", "192.168.1.50");
|
|
|
|
// Assert
|
|
Assert.True(allowed);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IsIpAllowedAsync_ExactMatch_ReturnsTrue()
|
|
{
|
|
// Arrange
|
|
var config = new WebhookSecurityConfig
|
|
{
|
|
ConfigId = "config-001",
|
|
TenantId = "tenant1",
|
|
ChannelId = "channel1",
|
|
SecretKey = "test-secret-key",
|
|
EnforceIpAllowlist = true,
|
|
AllowedIps = ["192.168.1.100"]
|
|
};
|
|
await _webhookService.RegisterWebhookAsync(config);
|
|
|
|
// Act
|
|
var allowed = await _webhookService.IsIpAllowedAsync("tenant1", "channel1", "192.168.1.100");
|
|
|
|
// Assert
|
|
Assert.True(allowed);
|
|
}
|
|
}
|