// ============================================================================= // 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; /// /// Tests for Server-Side Request Forgery (SSRF) vulnerabilities including: /// - Internal network access attempts /// - Cloud metadata endpoint access /// - URL allowlist bypass attempts /// - Protocol smuggling /// [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 GetInternalUrlPayloads() { var data = new TheoryData(); foreach (var url in MaliciousPayloads.Ssrf.InternalUrls) { data.Add(url); } return data; } public static TheoryData GetCloudMetadataPayloads() { var data = new TheoryData(); foreach (var url in MaliciousPayloads.Ssrf.CloudMetadata) { data.Add(url); } return data; } public static TheoryData GetBypassPayloads() { var data = new TheoryData(); foreach (var url in MaliciousPayloads.Ssrf.Bypass) { data.Add(url); } return data; } } /// /// URL validator for SSRF prevention. /// In production, this would be the actual URL validation service. /// file class UrlValidator { private readonly bool _allowlistMode; private readonly HashSet _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; } }