Add comprehensive security tests for OWASP A03 (Injection) and A10 (SSRF)
- Implemented InjectionTests.cs to cover various injection vulnerabilities including SQL, NoSQL, Command, LDAP, and XPath injections. - Created SsrfTests.cs to test for Server-Side Request Forgery (SSRF) vulnerabilities, including internal URL access, cloud metadata access, and URL allowlist bypass attempts. - Introduced MaliciousPayloads.cs to store a collection of malicious payloads for testing various security vulnerabilities. - Added SecurityAssertions.cs for common security-specific assertion helpers. - Established SecurityTestBase.cs as a base class for security tests, providing common infrastructure and mocking utilities. - Configured the test project StellaOps.Security.Tests.csproj with necessary dependencies for testing.
This commit is contained in:
307
tests/security/StellaOps.Security.Tests/A10_SSRF/SsrfTests.cs
Normal file
307
tests/security/StellaOps.Security.Tests/A10_SSRF/SsrfTests.cs
Normal file
@@ -0,0 +1,307 @@
|
||||
// =============================================================================
|
||||
// A10_SSRF/SsrfTests.cs
|
||||
// OWASP A10:2021 - Server-Side Request Forgery
|
||||
// Tests for SSRF vulnerabilities
|
||||
// =============================================================================
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Security.Tests.Infrastructure;
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Security.Tests.A10_SSRF;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Server-Side Request Forgery (SSRF) vulnerabilities including:
|
||||
/// - Internal network access attempts
|
||||
/// - Cloud metadata endpoint access
|
||||
/// - URL allowlist bypass attempts
|
||||
/// - Protocol smuggling
|
||||
/// </summary>
|
||||
[Trait("Category", "Security")]
|
||||
[Trait("OWASP", "A10")]
|
||||
[OwaspCategory("A10:2021", "Server-Side Request Forgery")]
|
||||
public class SsrfTests : SecurityTestBase
|
||||
{
|
||||
[Theory]
|
||||
[MemberData(nameof(GetInternalUrlPayloads))]
|
||||
public void Should_Block_Internal_URL_Access(string maliciousUrl)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new UrlValidator();
|
||||
|
||||
// Act
|
||||
var isAllowed = validator.IsUrlAllowed(maliciousUrl);
|
||||
|
||||
// Assert
|
||||
isAllowed.Should().BeFalse($"Internal URL '{maliciousUrl}' should be blocked");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetCloudMetadataPayloads))]
|
||||
public void Should_Block_Cloud_Metadata_Access(string metadataUrl)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new UrlValidator();
|
||||
|
||||
// Act
|
||||
var isAllowed = validator.IsUrlAllowed(metadataUrl);
|
||||
|
||||
// Assert
|
||||
isAllowed.Should().BeFalse($"Cloud metadata URL '{metadataUrl}' should be blocked");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetBypassPayloads))]
|
||||
public void Should_Block_SSRF_Bypass_Attempts(string bypassUrl)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new UrlValidator();
|
||||
|
||||
// Act
|
||||
var isAllowed = validator.IsUrlAllowed(bypassUrl);
|
||||
|
||||
// Assert
|
||||
isAllowed.Should().BeFalse($"SSRF bypass URL '{bypassUrl}' should be blocked");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("file:///etc/passwd")]
|
||||
[InlineData("file:///C:/Windows/System32/config/SAM")]
|
||||
[InlineData("gopher://localhost:25/")]
|
||||
[InlineData("dict://localhost:11211/")]
|
||||
public void Should_Block_Dangerous_Protocols(string url)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new UrlValidator();
|
||||
|
||||
// Act
|
||||
var isAllowed = validator.IsUrlAllowed(url);
|
||||
|
||||
// Assert
|
||||
isAllowed.Should().BeFalse($"Dangerous protocol URL '{url}' should be blocked");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://api.example.com/data", true)]
|
||||
[InlineData("https://registry.npmjs.org/package", true)]
|
||||
[InlineData("http://127.0.0.1", false)]
|
||||
[InlineData("http://localhost:8080", false)]
|
||||
public void Should_Enforce_URL_Allowlist(string url, bool expectedAllowed)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new UrlValidator(allowlistMode: true);
|
||||
validator.AddToAllowlist("api.example.com");
|
||||
validator.AddToAllowlist("registry.npmjs.org");
|
||||
|
||||
// Act
|
||||
var isAllowed = validator.IsUrlAllowed(url);
|
||||
|
||||
// Assert
|
||||
isAllowed.Should().Be(expectedAllowed, $"URL '{url}' allowlist check failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Resolve_DNS_And_Validate_IP()
|
||||
{
|
||||
// This tests that DNS resolution is validated, not just hostname checking
|
||||
// Attackers can use DNS rebinding or custom DNS to resolve to internal IPs
|
||||
var validator = new UrlValidator();
|
||||
|
||||
// Even if hostname looks external, resolved IP must be validated
|
||||
var externalLookingUrl = "http://attacker-controlled.example.com";
|
||||
|
||||
// Simulate DNS resolving to internal IP
|
||||
var resolvedIp = IPAddress.Parse("127.0.0.1");
|
||||
var isIpAllowed = validator.IsIpAllowed(resolvedIp);
|
||||
|
||||
isIpAllowed.Should().BeFalse("Resolved internal IP should be blocked even with external hostname");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Block_Redirects_To_Internal_URLs()
|
||||
{
|
||||
// Arrange
|
||||
var validator = new UrlValidator();
|
||||
var initialUrl = "https://attacker.com/redirect";
|
||||
var redirectTarget = "http://169.254.169.254/latest/meta-data/";
|
||||
|
||||
// Act - Check if redirect target is safe
|
||||
var isRedirectSafe = validator.IsUrlAllowed(redirectTarget);
|
||||
|
||||
// Assert
|
||||
isRedirectSafe.Should().BeFalse("Redirect to metadata endpoint should be blocked");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0x7f.0x0.0x0.0x1")] // Hex encoded localhost
|
||||
[InlineData("0177.0.0.1")] // Octal encoded localhost
|
||||
[InlineData("2130706433")] // Decimal encoded 127.0.0.1
|
||||
[InlineData("127.1")] // Short form localhost
|
||||
public void Should_Block_IP_Obfuscation_Attempts(string obfuscatedIp)
|
||||
{
|
||||
// Arrange
|
||||
var validator = new UrlValidator();
|
||||
var url = $"http://{obfuscatedIp}/";
|
||||
|
||||
// Act
|
||||
var isAllowed = validator.IsUrlAllowed(url);
|
||||
|
||||
// Assert
|
||||
isAllowed.Should().BeFalse($"Obfuscated IP '{obfuscatedIp}' should be blocked");
|
||||
}
|
||||
|
||||
public static TheoryData<string> GetInternalUrlPayloads()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
foreach (var url in MaliciousPayloads.Ssrf.InternalUrls)
|
||||
{
|
||||
data.Add(url);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public static TheoryData<string> GetCloudMetadataPayloads()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
foreach (var url in MaliciousPayloads.Ssrf.CloudMetadata)
|
||||
{
|
||||
data.Add(url);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
public static TheoryData<string> GetBypassPayloads()
|
||||
{
|
||||
var data = new TheoryData<string>();
|
||||
foreach (var url in MaliciousPayloads.Ssrf.Bypass)
|
||||
{
|
||||
data.Add(url);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// URL validator for SSRF prevention.
|
||||
/// In production, this would be the actual URL validation service.
|
||||
/// </summary>
|
||||
file class UrlValidator
|
||||
{
|
||||
private readonly bool _allowlistMode;
|
||||
private readonly HashSet<string> _allowlist = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static readonly string[] BlockedHosts =
|
||||
[
|
||||
"localhost", "127.0.0.1", "::1", "0.0.0.0", "[::1]",
|
||||
"169.254.169.254", "metadata.google.internal"
|
||||
];
|
||||
|
||||
private static readonly string[] BlockedSchemes =
|
||||
[
|
||||
"file", "gopher", "dict", "ldap", "tftp"
|
||||
];
|
||||
|
||||
public UrlValidator(bool allowlistMode = false)
|
||||
{
|
||||
_allowlistMode = allowlistMode;
|
||||
}
|
||||
|
||||
public void AddToAllowlist(string host)
|
||||
{
|
||||
_allowlist.Add(host);
|
||||
}
|
||||
|
||||
public bool IsUrlAllowed(string url)
|
||||
{
|
||||
if (string.IsNullOrEmpty(url)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
var uri = new Uri(url, UriKind.Absolute);
|
||||
|
||||
// Block dangerous schemes
|
||||
if (BlockedSchemes.Contains(uri.Scheme.ToLowerInvariant()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block known internal hosts
|
||||
if (BlockedHosts.Any(h => uri.Host.Equals(h, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block private IP ranges
|
||||
if (IPAddress.TryParse(uri.Host, out var ip))
|
||||
{
|
||||
if (!IsIpAllowed(ip)) return false;
|
||||
}
|
||||
|
||||
// Check for IP obfuscation
|
||||
if (IsObfuscatedIp(uri.Host))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for metadata patterns
|
||||
if (uri.Host.Contains("metadata", StringComparison.OrdinalIgnoreCase) ||
|
||||
uri.Host.Contains("169.254", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// In allowlist mode, only allow explicitly listed hosts
|
||||
if (_allowlistMode)
|
||||
{
|
||||
return _allowlist.Contains(uri.Host);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsIpAllowed(IPAddress ip)
|
||||
{
|
||||
var bytes = ip.GetAddressBytes();
|
||||
|
||||
if (bytes.Length == 4)
|
||||
{
|
||||
// Block loopback
|
||||
if (bytes[0] == 127) return false;
|
||||
// Block 10.0.0.0/8
|
||||
if (bytes[0] == 10) return false;
|
||||
// Block 172.16.0.0/12
|
||||
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return false;
|
||||
// Block 192.168.0.0/16
|
||||
if (bytes[0] == 192 && bytes[1] == 168) return false;
|
||||
// Block link-local
|
||||
if (bytes[0] == 169 && bytes[1] == 254) return false;
|
||||
// Block 0.0.0.0
|
||||
if (bytes.All(b => b == 0)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsObfuscatedIp(string host)
|
||||
{
|
||||
// Check for hex notation
|
||||
if (host.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
|
||||
// Check for octal notation (leading zeros)
|
||||
if (host.StartsWith("0") && host.Contains('.') &&
|
||||
host.Split('.').Any(p => p.StartsWith('0') && p.Length > 1)) return true;
|
||||
|
||||
// Check for decimal notation (single large number)
|
||||
if (long.TryParse(host, out var decimalIp) && decimalIp > 0) return true;
|
||||
|
||||
// Check for short form
|
||||
if (host.Split('.').Length < 4 && host.Split('.').All(p => int.TryParse(p, out _))) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user