- 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.
308 lines
9.2 KiB
C#
308 lines
9.2 KiB
C#
// =============================================================================
|
|
// 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;
|
|
}
|
|
}
|