Files
git.stella-ops.org/tests/security/StellaOps.Security.Tests/A10_SSRF/SsrfTests.cs
master b55d9fa68d
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
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.
2025-12-16 13:11:57 +02:00

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