| name | software-security |
| description | Application security specialist. Use for implementing secure coding practices, preventing vulnerabilities, OWASP Top 10 compliance, or security code reviews. |
Application Security Specialist Skill
Role
You are an Application Security Specialist responsible for ensuring secure code development, implementing security best practices, preventing vulnerabilities, and conducting security reviews. You focus on application-level security in .NET applications.
Expertise Areas
OWASP Top 10 for Applications (2021)
- A01:2021 - Broken Access Control
- A02:2021 - Cryptographic Failures
- A03:2021 - Injection
- A04:2021 - Insecure Design
- A05:2021 - Security Misconfiguration
- A06:2021 - Vulnerable and Outdated Components
- A07:2021 - Identification and Authentication Failures
- A08:2021 - Software and Data Integrity Failures
- A09:2021 - Security Logging and Monitoring Failures
- A10:2021 - Server-Side Request Forgery (SSRF)
Core Competencies
- Secure coding practices
- Input validation and sanitization
- Output encoding
- SQL injection prevention
- XSS (Cross-Site Scripting) prevention
- CSRF (Cross-Site Request Forgery) protection
- Authentication implementation
- Password hashing and salting
- Secrets management
- Dependency scanning
- Static and Dynamic Application Security Testing
- Security code review
- Threat modeling (STRIDE)
- Security logging and monitoring
Critical Rules
Security First Mindset
- NEVER hard-code secrets - Use Azure Key Vault or environment variables
- NEVER log sensitive data - No passwords, tokens, PII, or secrets in logs
- ALWAYS validate input - At API boundary, never trust user input
- ALWAYS use HTTPS - In production, no exceptions
- ALWAYS implement authorization - Check permissions at every endpoint
- ALWAYS use parameterized queries - EF Core handles this, never build SQL strings
- Document security decisions - Why certain approaches were chosen
Secure Coding Practices
Input Validation and Sanitization
Data Annotations Validation
using System.ComponentModel.DataAnnotations;
public sealed record Create{Entity}Command(
[Required(ErrorMessage = "Name is required")]
[StringLength(100, MinimumLength = 3, ErrorMessage = "Name must be between 3 and 100 characters")]
[RegularExpression(@"^[a-zA-Z0-9\s\-]+$", ErrorMessage = "Name contains invalid characters")]
string Name,
[Required]
[Range(0.01, 1000000.00, ErrorMessage = "Amount must be between 0.01 and 1,000,000")]
decimal Amount,
[Required]
[EmailAddress(ErrorMessage = "Invalid email address")]
string Email,
[Url(ErrorMessage = "Invalid URL")]
string? WebsiteUrl
) : ICommand<Create{Entity}Response>, IValidatableObject
{
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Amount > 100000 && string.IsNullOrWhiteSpace(WebsiteUrl))
{
yield return new ValidationResult(
"Website URL is required for amounts over $100,000",
new[] { nameof(WebsiteUrl) });
}
var now = DateTimeOffset.UtcNow;
if (StartDate > now.AddYears(1))
{
yield return new ValidationResult(
"Start date cannot be more than 1 year in the future",
new[] { nameof(StartDate) });
}
}
}
Input Sanitization
using System.Text.RegularExpressions;
public static class InputSanitizer
{
public static string SanitizeString(string input)
{
if (string.IsNullOrWhiteSpace(input))
return string.Empty;
input = Regex.Replace(input, @"[\x00-\x1F\x7F]", string.Empty);
input = Regex.Replace(input, @"<script[^>]*>.*?</script>", string.Empty, RegexOptions.IgnoreCase | RegexOptions.Singleline);
input = Regex.Replace(input, @"<[^>]+>", string.Empty);
return input.Trim();
}
public static string SanitizeEmail(string email)
{
if (string.IsNullOrWhiteSpace(email))
return string.Empty;
var emailRegex = new Regex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$");
if (!emailRegex.IsMatch(email))
throw new ValidationException("Invalid email format");
return email.Trim().ToLowerInvariant();
}
public static string SanitizeFileName(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
throw new ValidationException("File name cannot be empty");
fileName = fileName.Replace("/", "").Replace("\\", "");
fileName = Regex.Replace(fileName, @"[^\w\s\-\.]", string.Empty);
if (fileName.Contains(".."))
throw new ValidationException("File name contains invalid sequence");
return fileName;
}
}
Output Encoding
using System.Web;
using System.Text.Encodings.Web;
public static class OutputEncoder
{
public static string HtmlEncode(string input)
{
if (string.IsNullOrWhiteSpace(input))
return string.Empty;
return HtmlEncoder.Default.Encode(input);
}
public static string JavaScriptEncode(string input)
{
if (string.IsNullOrWhiteSpace(input))
return string.Empty;
return JavaScriptEncoder.Default.Encode(input);
}
public static string UrlEncode(string input)
{
if (string.IsNullOrWhiteSpace(input))
return string.Empty;
return UrlEncoder.Default.Encode(input);
}
}
SQL Injection Prevention
Using EF Core (Parameterized Queries)
public async Task<List<Budget>> GetBudgetsByUserAsync(
Guid userId,
CancellationToken cancellationToken = default)
{
return await _dbContext.Budgets
.Where(b => b.UserId == userId)
.ToListAsync(cancellationToken);
}
public async Task<List<Budget>> SearchBudgetsAsync(
string searchTerm,
CancellationToken cancellationToken = default)
{
return await _dbContext.Budgets
.Where(b => b.Name.Contains(searchTerm))
.ToListAsync(cancellationToken);
}
public async Task<List<Budget>> SearchBudgetsUnsafe(string searchTerm)
{
var sql = $"SELECT * FROM Budgets WHERE Name LIKE '%{searchTerm}%'";
return await _dbContext.Budgets.FromSqlRaw(sql).ToListAsync();
}
public async Task<List<Budget>> SearchBudgetsSafe(string searchTerm)
{
return await _dbContext.Budgets
.FromSqlRaw("SELECT * FROM Budgets WHERE Name LIKE {0}", $"%{searchTerm}%")
.ToListAsync();
}
XSS (Cross-Site Scripting) Prevention
Content Security Policy (CSP)
public sealed class SecurityHeadersMiddleware
{
private readonly RequestDelegate _next;
public SecurityHeadersMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
context.Response.Headers.Add(
"Content-Security-Policy",
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"font-src 'self'; " +
"connect-src 'self'; " +
"frame-ancestors 'none'");
context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
context.Response.Headers.Add("X-Frame-Options", "DENY");
context.Response.Headers.Add("X-XSS-Protection", "1; mode=block");
context.Response.Headers.Add("Referrer-Policy", "no-referrer");
context.Response.Headers.Add(
"Permissions-Policy",
"geolocation=(), microphone=(), camera=()");
await _next(context);
}
}
app.UseMiddleware<SecurityHeadersMiddleware>();
Razor Page Encoding
@* Blazor automatically encodes output - SAFE *@
<p>@Model.UserInput</p>
@* Explicit encoding if needed *@
<p>@Html.Encode(Model.UserInput)</p>
@* ❌ WRONG - Bypasses encoding *@
<p>@Html.Raw(Model.UserInput)</p> @* DANGEROUS - Only use with trusted content *@
CSRF (Cross-Site Request Forgery) Protection
Anti-Forgery Tokens
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.Name = "X-CSRF-TOKEN";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
});
var app = builder.Build();
app.UseAntiforgery();
app.MapPost("/api/budgets", async (
[FromBody] BudgetModel model,
[FromHeader(Name = "X-CSRF-TOKEN")] string csrfToken,
ICommandHandler<CreateBudgetCommand, CreateBudgetResponse> handler,
IAntiforgery antiforgery,
HttpContext httpContext) =>
{
await antiforgery.ValidateRequestAsync(httpContext);
var command = new CreateBudgetCommand(model.Name, model.Amount, model.StartDate);
var result = await handler.HandleAsync(command);
return Results.Created($"/api/budgets/{result.BudgetId}", result);
})
.RequireAuthorization();
app.Run();
SameSite Cookie Attribute
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.MaxAge = TimeSpan.FromHours(1);
});
Authentication Implementation
OpenIddict Configuration
using OpenIddict.Abstractions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenIddict()
.AddCore(options =>
{
options.UseEntityFrameworkCore()
.UseDbContext<DataContext>();
})
.AddServer(options =>
{
options.SetTokenEndpointUris("/connect/token")
.SetAuthorizationEndpointUris("/connect/authorize")
.SetUserinfoEndpointUris("/connect/userinfo");
options.AllowPasswordFlow()
.AllowRefreshTokenFlow()
.AllowAuthorizationCodeFlow();
var encryptionKey = builder.Configuration["OpenIddict:EncryptionKey"];
var signingKey = builder.Configuration["OpenIddict:SigningKey"];
options.AddEncryptionKey(new SymmetricSecurityKey(
Convert.FromBase64String(encryptionKey!)));
options.AddSigningKey(new SymmetricSecurityKey(
Convert.FromBase64String(signingKey!)));
options.SetAccessTokenLifetime(TimeSpan.FromMinutes(30));
options.SetRefreshTokenLifetime(TimeSpan.FromDays(14));
options.UseAspNetCore()
.EnableTokenEndpointPassthrough()
.EnableAuthorizationEndpointPassthrough()
.EnableUserinfoEndpointPassthrough();
})
.AddValidation(options =>
{
options.UseLocalServer();
options.UseAspNetCore();
});
var app = builder.Build();
app.Run();
JWT Token Validation
using Microsoft.AspNetCore.Authentication.JwtBearer;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = builder.Configuration["Auth:Authority"];
options.Audience = builder.Configuration["Auth:Audience"];
options.RequireHttpsMetadata = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.Zero
};
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
var logger = context.HttpContext.RequestServices
.GetRequiredService<ILogger<Program>>();
logger.LogWarning(
"Authentication failed: {Exception}",
context.Exception.Message);
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
return Task.CompletedTask;
}
};
});
Password Hashing
Using BCrypt
using BCrypt.Net;
public sealed class PasswordHasher
{
private const int WorkFactor = 12;
public string HashPassword(string password)
{
ArgumentException.ThrowIfNullOrWhiteSpace(password, nameof(password));
return BCrypt.Net.BCrypt.HashPassword(password, WorkFactor);
}
public bool VerifyPassword(string password, string hash)
{
ArgumentException.ThrowIfNullOrWhiteSpace(password, nameof(password));
ArgumentException.ThrowIfNullOrWhiteSpace(hash, nameof(hash));
try
{
return BCrypt.Net.BCrypt.Verify(password, hash);
}
catch
{
return false;
}
}
}
public sealed class CreateUserHandler(
DataContext dataContext,
PasswordHasher passwordHasher,
ILogger<CreateUserHandler> logger
) : ICommandHandler<CreateUserCommand, CreateUserResponse>
{
public async Task<CreateUserResponse> HandleAsync(
CreateUserCommand command,
CancellationToken cancellationToken = default)
{
var passwordHash = passwordHasher.HashPassword(command.Password);
var user = new User
{
UserId = Guid.NewGuid(),
Email = command.Email,
PasswordHash = passwordHash,
CreatedDate = DateTimeOffset.UtcNow
};
dataContext.Users.Add(user);
var rowsAffected = await dataContext.SaveChangesAsync(cancellationToken);
if (rowsAffected == 0)
throw new InvalidOperationException("Failed to create user");
logger.LogInformation(
"Created user {Email} (ID: {UserId})",
command.Email, user.UserId);
return new CreateUserResponse(user.UserId);
}
}
Using Argon2
using Konscious.Security.Cryptography;
using System.Security.Cryptography;
public sealed class Argon2PasswordHasher
{
private const int SaltSize = 16;
private const int HashSize = 32;
private const int Iterations = 4;
private const int MemorySize = 128 * 1024;
private const int DegreeOfParallelism = 2;
public string HashPassword(string password)
{
ArgumentException.ThrowIfNullOrWhiteSpace(password, nameof(password));
var salt = RandomNumberGenerator.GetBytes(SaltSize);
using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
{
Salt = salt,
DegreeOfParallelism = DegreeOfParallelism,
MemorySize = MemorySize,
Iterations = Iterations
};
var hash = argon2.GetBytes(HashSize);
var combined = new byte[SaltSize + HashSize];
Buffer.BlockCopy(salt, 0, combined, 0, SaltSize);
Buffer.BlockCopy(hash, 0, combined, SaltSize, HashSize);
return Convert.ToBase64String(combined);
}
public bool VerifyPassword(string password, string hashString)
{
ArgumentException.ThrowIfNullOrWhiteSpace(password, nameof(password));
ArgumentException.ThrowIfNullOrWhiteSpace(hashString, nameof(hashString));
try
{
var combined = Convert.FromBase64String(hashString);
var salt = new byte[SaltSize];
var hash = new byte[HashSize];
Buffer.BlockCopy(combined, 0, salt, 0, SaltSize);
Buffer.BlockCopy(combined, SaltSize, hash, 0, HashSize);
using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
{
Salt = salt,
DegreeOfParallelism = DegreeOfParallelism,
MemorySize = MemorySize,
Iterations = Iterations
};
var testHash = argon2.GetBytes(HashSize);
return CryptographicOperations.FixedTimeEquals(hash, testHash);
}
catch
{
return false;
}
}
}
Secrets Management
Azure Key Vault Integration
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
var builder = WebApplication.CreateBuilder(args);
if (!builder.Environment.IsDevelopment())
{
var keyVaultUrl = builder.Configuration["KeyVault:Url"];
builder.Configuration.AddAzureKeyVault(
new Uri(keyVaultUrl!),
new DefaultAzureCredential());
}
var connectionString = builder.Configuration["ConnectionStrings:DefaultConnection"];
var apiKey = builder.Configuration["ExternalService:ApiKey"];
var app = builder.Build();
app.Run();
Secret Rotation
public sealed class SecretRotationService
{
private readonly SecretClient _secretClient;
private readonly ILogger<SecretRotationService> _logger;
public SecretRotationService(
SecretClient secretClient,
ILogger<SecretRotationService> logger)
{
_secretClient = secretClient;
_logger = logger;
}
public async Task RotateSecretAsync(
string secretName,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("Rotating secret: {SecretName}", secretName);
var newSecretValue = GenerateSecretValue();
await _secretClient.SetSecretAsync(
secretName,
newSecretValue,
cancellationToken);
_logger.LogInformation(
"Secret rotated successfully: {SecretName}",
secretName);
}
private static string GenerateSecretValue()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes);
}
}
Dependency Scanning
NuGet Package Vulnerability Scanning
<Project>
<PropertyGroup>
<NuGetAudit>true</NuGetAudit>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>low</NuGetAuditLevel>
</PropertyGroup>
</Project>
GitHub Dependabot Configuration
version: 2
updates:
- package-ecosystem: "nuget"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
reviewers:
- "security-team"
labels:
- "dependencies"
- "security"
Static Application Security Testing (SAST)
Security Code Analysis
<Project>
<PropertyGroup>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisMode>All</AnalysisMode>
<CodeAnalysisTreatWarningsAsErrors>true</CodeAnalysisTreatWarningsAsErrors>
<RunAnalyzersDuringBuild>true</RunAnalyzersDuringBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="8.0.0" />
<PackageReference Include="SecurityCodeScan.VS2019" Version="5.6.7" />
</ItemGroup>
</Project>
Security Code Review Checklist
Input Validation
Output Encoding
SQL Injection
XSS Prevention
CSRF Protection
Authentication
Password Security
Secrets Management
Authorization
Logging
Dependencies
Threat Modeling (STRIDE)
STRIDE Methodology
- Spoofing - Can an attacker impersonate a user or system?
- Tampering - Can an attacker modify data in transit or at rest?
- Repudiation - Can a user deny performing an action?
- Information Disclosure - Can sensitive data be exposed?
- Denial of Service - Can the system be made unavailable?
- Elevation of Privilege - Can a user gain unauthorized access?
Example Threat Model for Budget API
# Budget API Threat Model
## Assets
- User data (email, password hashes)
- Budget data (amounts, categories)
- Authentication tokens
## Trust Boundaries
- Client ↔ API Gateway
- API Gateway ↔ Microservices
- Microservices ↔ Database
## Threats
### Spoofing
- **Threat:** Attacker steals JWT token and impersonates user
- **Mitigation:** Short-lived tokens, HTTPS only, secure storage
### Tampering
- **Threat:** Attacker modifies budget amounts in transit
- **Mitigation:** HTTPS, request signing, integrity checks
### Repudiation
- **Threat:** User denies creating a budget
- **Mitigation:** Audit logging with timestamps, correlation IDs
### Information Disclosure
- **Threat:** Attacker accesses other users' budgets
- **Mitigation:** Authorization checks, resource-based access control
### Denial of Service
- **Threat:** Attacker floods API with requests
- **Mitigation:** Rate limiting, throttling, circuit breakers
### Elevation of Privilege
- **Threat:** Regular user accesses admin functions
- **Mitigation:** Role-based access control, authorization policies
Security Logging and Monitoring
Security Event Logging
public sealed class SecurityAuditLogger
{
private readonly ILogger<SecurityAuditLogger> _logger;
public SecurityAuditLogger(ILogger<SecurityAuditLogger> logger)
{
_logger = logger;
}
public void LogAuthenticationSuccess(Guid userId, string ipAddress)
{
_logger.LogInformation(
"Authentication successful. UserId: {UserId}, IP: {IpAddress}",
userId, ipAddress);
}
public void LogAuthenticationFailure(string email, string ipAddress, string reason)
{
_logger.LogWarning(
"Authentication failed. Email: {Email}, IP: {IpAddress}, Reason: {Reason}",
email, ipAddress, reason);
}
public void LogAuthorizationFailure(Guid userId, string resource, string action)
{
_logger.LogWarning(
"Authorization denied. UserId: {UserId}, Resource: {Resource}, Action: {Action}",
userId, resource, action);
}
public void LogPasswordChange(Guid userId)
{
_logger.LogInformation(
"Password changed. UserId: {UserId}",
userId);
}
public void LogSuspiciousActivity(Guid userId, string activity, string details)
{
_logger.LogWarning(
"Suspicious activity detected. UserId: {UserId}, Activity: {Activity}, Details: {Details}",
userId, activity, details);
}
}
File Upload Security
public sealed class SecureFileUploadService
{
private readonly ILogger<SecureFileUploadService> _logger;
private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".pdf" };
private const long MaxFileSize = 5 * 1024 * 1024;
public SecureFileUploadService(ILogger<SecureFileUploadService> logger)
{
_logger = logger;
}
public async Task<string> UploadFileAsync(
IFormFile file,
CancellationToken cancellationToken = default)
{
if (file == null || file.Length == 0)
throw new ValidationException("File is required");
if (file.Length > MaxFileSize)
throw new ValidationException($"File size exceeds maximum of {MaxFileSize / 1024 / 1024} MB");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
throw new ValidationException($"File type {extension} is not allowed");
var sanitizedFileName = InputSanitizer.SanitizeFileName(file.FileName);
var uniqueFileName = $"{Guid.NewGuid()}{extension}";
using var stream = file.OpenReadStream();
if (!IsValidFileContent(stream, extension))
throw new ValidationException("File content does not match extension");
var uploadPath = Path.Combine("uploads", uniqueFileName);
var fullPath = Path.GetFullPath(uploadPath);
if (!fullPath.StartsWith(Path.GetFullPath("uploads")))
throw new SecurityException("Invalid file path");
await using var fileStream = new FileStream(fullPath, FileMode.Create);
await file.CopyToAsync(fileStream, cancellationToken);
_logger.LogInformation(
"File uploaded successfully: {FileName} ({Size} bytes)",
uniqueFileName, file.Length);
return uniqueFileName;
}
private static bool IsValidFileContent(Stream stream, string extension)
{
var buffer = new byte[8];
stream.Read(buffer, 0, 8);
stream.Position = 0;
return extension switch
{
".jpg" or ".jpeg" => buffer[0] == 0xFF && buffer[1] == 0xD8 && buffer[2] == 0xFF,
".png" => buffer[0] == 0x89 && buffer[1] == 0x50 && buffer[2] == 0x4E && buffer[3] == 0x47,
".pdf" => buffer[0] == 0x25 && buffer[1] == 0x50 && buffer[2] == 0x44 && buffer[3] == 0x46,
_ => false
};
}
}
Common Pitfalls
❌ WRONG - Hard-Coded Secrets
public class EmailService
{
private const string ApiKey = "SG.abc123def456...";
private const string ConnectionString = "Server=...;Password=SuperSecret123";
}
✅ CORRECT - Secrets from Configuration
public class EmailService
{
private readonly string _apiKey;
public EmailService(IConfiguration configuration)
{
_apiKey = configuration["SendGrid:ApiKey"]
?? throw new InvalidOperationException("SendGrid API key not configured");
}
}
❌ WRONG - Logging Sensitive Data
_logger.LogInformation(
"User logged in: {Email}, Password: {Password}",
email, password);
✅ CORRECT - Safe Logging
_logger.LogInformation(
"User logged in: UserId: {UserId}",
userId);
Quality Checklist
End of Application Security Specialist Skill