Security Patterns — Authorization, PII, OWASP
Назначение
Документ описывает обязательные security patterns для backend разработки Pin: многоуровневая авторизация, защита PII и секретов, input validation, OWASP top-10 mapping, tenant isolation, rate limiting и принцип defence-in-depth. Fintech/gambling платформа обязана применять defence-in-depth — несколько независимых барьеров, ни один из которых не является единственным.
Многоуровневая Авторизация (Defence-in-Depth)
Четыре Слоя Защиты
HTTP Request
│
▼
[1] Endpoint Policy (ASP.NET Authorization)
│ RequireAuthorization("user") / RequirePermission("withdrawals.approve")
▼
[2] MediatR AuthorizationBehavior
│ Проверка ActorContext.HasPermission(command.RequiredPermission)
▼
[3] Application Handler / Domain Service
│ TenantId/BrandId scope check, ownership check
▼
[4] Repository Scope Filters (EF Interceptors)
│ tenant_id и brand_id фильтры автоматически
▼
Database Query
Нарушение любого слоя → reject. Ни один слой не доверяет предыдущему безоговорочно.
Слой 1 — Endpoint Policy
// Каждый endpoint явно помечен политикой
[Authorize(Policy = AuthorizationPolicies.AdminWithMfa)]
[HttpPut("withdrawals/{id}/approve")]
[AuditApiAction("withdrawal.approve")]
public async Task<IActionResult> ApproveWithdrawal(
[FromRoute] Guid id,
[FromBody] ApproveWithdrawalRequest request,
CancellationToken ct)
{
var command = new ApproveWithdrawalCommand
{
WithdrawalId = id,
ExpectedVersion = request.ExpectedVersion,
DecisionReasonCode = request.DecisionReasonCode
};
var result = await _mediator.Send(command, ct);
return result.ToActionResult();
}
// Политики
public static class AuthorizationPolicies
{
public const string User = "user";
public const string Partner = "partner";
public const string Admin = "admin";
public const string AdminWithMfa = "admin-mfa";
public const string InternalService = "internal-service";
}
Слой 2 — MediatR AuthorizationBehavior
public sealed class AuthorizationBehavior<TRequest, TResponse>(
IActorContext actorContext,
ILogger<AuthorizationBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : ICommand
where TResponse : IResultBase
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken ct)
{
// IRequiresPermission — маркерный интерфейс с RequiredPermission
if (request is IRequiresPermission permissionRequest)
{
if (!actorContext.HasPermission(permissionRequest.RequiredPermission))
{
logger.LogWarning(
"Authorization failed. Actor={ActorId} Type={ActorType} Permission={Permission} Command={Command}",
actorContext.ActorId,
actorContext.ActorType,
permissionRequest.RequiredPermission,
typeof(TRequest).Name);
return (TResponse)(IResultBase)Result.Fail(
new ForbiddenError(permissionRequest.RequiredPermission));
}
}
return await next();
}
}
Слой 3 — Handler Ownership Check
public async Task<Result<ApproveWithdrawalResult>> Handle(
ApproveWithdrawalCommand command,
CancellationToken ct)
{
var withdrawal = await _repository.Find(command.WithdrawalId, ct);
if (withdrawal is null)
return Result.Fail(new NotFoundError(typeof(WithdrawalRequest), command.WithdrawalId.Value));
// ОБЯЗАТЕЛЬНО: проверить tenant ownership даже если запрос прошёл через auth.
// Repository scope filter уже фильтрует по tenant, но явная проверка —
// дополнительный барьер от context manipulation.
if (withdrawal.TenantId != _actorContext.TenantId)
{
_logger.LogWarning(
"Tenant mismatch. WithdrawalTenant={WithdrawalTenant} ActorTenant={ActorTenant}",
withdrawal.TenantId,
_actorContext.TenantId);
return Result.Fail(new ForbiddenError("withdrawals.approve"));
}
// ...
}
Secrets И PII В Логах
Что Никогда Не Логируется
| Категория | Примеры |
|---|---|
| Credentials | пароли, PIN, секретные ключи, API tokens |
| Payment secrets | CVV, full PAN, bank account numbers |
| JWT / session tokens | access token, refresh token, session cookie |
| KYC documents | document scans, raw OCR text |
| Raw PII | full name + DOB + address в одном log entry |
| Provider credentials | webhook secret, OAuth client_secret |
| Encryption keys | AES keys, RSA private keys |
Serilog Destructuring — Маскирование
// Настройка Serilog с destructuring policy
Log.Logger = new LoggerConfiguration()
.Destructure.With<SensitiveDataMaskingPolicy>()
.CreateLogger();
// Политика маскирования
public sealed class SensitiveDataMaskingPolicy : IDestructuringPolicy
{
private static readonly HashSet<string> SensitivePropertyNames =
["password", "token", "secret", "key", "cvv", "pan", "ssn", "iban",
"accessToken", "refreshToken", "apiKey", "clientSecret"];
public bool TryDestructureInstance(object value, ILogEventPropertyValueFactory factory,
out LogEventPropertyValue? result)
{
result = null;
return false; // используем атрибутный подход
}
}
// Атрибут для маскирования полей DTO
[AttributeUsage(AttributeTargets.Property)]
public sealed class SensitiveAttribute : Attribute { }
// Применение
public sealed record CreateWithdrawalRequest
{
public required decimal Amount { get; init; }
public required short CurrencyId { get; init; } // ссылка на справочник handbook (display-код — только в CurrencySnapshot)
[Sensitive]
public required string UserIban { get; init; } // маскируется в логах
}
Structured Logging — Правила
// ХОРОШО — структурированный лог без PII
_logger.LogInformation(
"Withdrawal approved. WithdrawalId={WithdrawalId} Amount={Amount} Currency={Currency} " +
"UserId={UserId} ActorId={ActorId} TenantId={TenantId}",
command.WithdrawalId.Value,
withdrawal.Amount.Value,
withdrawal.Amount.Currency.Value,
withdrawal.UserId.Value,
_actorContext.ActorId,
_actorContext.TenantId);
// ПЛОХО — PII в логе
_logger.LogInformation(
"Withdrawal approved for user {UserName} ({UserEmail}), IBAN: {Iban}",
user.FullName, // PII
user.Email, // PII
user.Iban); // sensitive financial data
Log Levels Семантика
| Level | Когда |
|---|---|
Trace | Детальный flow — только local debug, запрещен в prod |
Debug | Диагностика — только в staging |
Information | Нормальный flow операции: started, completed, key steps |
Warning | Recoverable issues: idempotent duplicate, rate limited, validation fail |
Error | Unexpected failures: provider error, DB constraint, unhandled exception |
Critical | Система нефункциональна: DB down, out of memory, security breach |
Input Validation
Два Уровня Валидации
HTTP Request Body / Query
│
▼
[1] FluentValidation API Validator (format, range, required)
│ Срабатывает до MediatR — возвращает 422 Unprocessable Entity
▼
[2] FluentValidation MediatR Validator (business rules)
│ Срабатывает в ValidationBehavior — возвращает Result.Fail
▼
Handler
API-Level Validator — Format Только
// Проверяет формат и ограничения, НЕ бизнес-правила
public sealed class ApproveWithdrawalRequestValidator
: AbstractValidator<ApproveWithdrawalRequest>
{
public ApproveWithdrawalRequestValidator()
{
RuleFor(x => x.ExpectedVersion)
.GreaterThanOrEqualTo(0)
.WithMessage("ExpectedVersion must be >= 0.");
RuleFor(x => x.DecisionReasonCode)
.NotEmpty()
.MaximumLength(100)
.Matches(@"^[a-z_]+$") // only lowercase snake_case
.WithMessage("DecisionReasonCode must be lowercase snake_case, max 100 chars.");
}
}
Против Injection И Mass Assignment
// ХОРОШО — явный mapping из request DTO в command
// Command содержит только то, что handler реально использует
var command = new ApproveWithdrawalCommand
{
WithdrawalId = id, // из URL route, не из body
ExpectedVersion = request.ExpectedVersion,
DecisionReasonCode = request.DecisionReasonCode
};
// ПЛОХО — AutoMapper/mass assignment из request напрямую в entity
_mapper.Map(request, existingEntity); // обходит доменные проверки
Против Open Redirect И Path Traversal
// Файловые операции — whitelist extensions, нет path traversal
public static class FileUploadRules
{
private static readonly HashSet<string> AllowedExtensions = [".jpg", ".png", ".pdf"];
private static readonly long MaxSizeBytes = 10 * 1024 * 1024; // 10 MB
public static Result Validate(IFormFile file)
{
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext))
return Result.Fail(new ValidationError($"File type '{ext}' is not allowed."));
if (file.Length > MaxSizeBytes)
return Result.Fail(new ValidationError($"File exceeds maximum size of 10 MB."));
// Имя файла санируется перед сохранением
return Result.Ok();
}
public static string SanitizeFileName(string rawName)
{
var name = Path.GetFileNameWithoutExtension(rawName);
var ext = Path.GetExtension(rawName);
// Удаляем path separators и control characters
var safe = string.Concat(name.Where(c => char.IsLetterOrDigit(c) || c is '-' or '_'));
return $"{safe}{ext}";
}
}
SQL И NoSQL Injection Prevention
EF Core — Параметризация By Default
// ХОРОШО — EF параметризует автоматически
var withdrawals = await _dbContext.Withdrawals
.Where(w => w.UserId == userId && w.Status == WithdrawalStatus.Pending)
.ToListAsync(ct);
// ПЛОХО — raw SQL с интерполяцией
var sql = $"SELECT * FROM withdrawals WHERE user_id = '{userId}'"; // SQL injection!
await _dbContext.Database.ExecuteSqlRawAsync(sql);
// ХОРОШО — raw SQL только с FromSql и параметрами
var withdrawals = await _dbContext.Withdrawals
.FromSql($"SELECT * FROM withdrawals WHERE user_id = {userId.Value}")
.ToListAsync(ct); // FormattableString — безопасно
Elasticsearch — Избегать String Concatenation
// ХОРОШО — typed query builder
var searchRequest = new SearchRequest<UserSearchDocument>("users")
{
Query = new BoolQuery
{
Must = [
new TermQuery(Infer.Field<UserSearchDocument>(f => f.TenantId))
{ Value = tenantId.Value },
new MatchQuery(Infer.Field<UserSearchDocument>(f => f.DisplayName))
{ Query = query }
]
}
};
OWASP Top-10 Mapping (.NET / Fintech)
| OWASP | Угроза | Pin Защита |
|---|---|---|
| A01 Broken Access Control | Доступ к чужим данным | Repository scope filters, ownership check в handler, permission per endpoint |
| A02 Cryptographic Failures | Утечка PII / токенов | SensitiveDataMaskingPolicy, secrets в Vault/k8s, TLS everywhere |
| A03 Injection | SQL/NoSQL injection | EF parameterized queries, typed Elasticsearch builder, FluentValidation |
| A04 Insecure Design | Отсутствие defence-in-depth | 4-слойная авторизация, Domain invariants, idempotency constraints |
| A05 Security Misconfiguration | Открытые endpoints, debug в prod | [Authorize] обязателен, IOptions ValidateOnStart, env-based config |
| A06 Vulnerable Components | Уязвимые NuGet пакеты | dotnet audit, Dependabot, CI security gate |
| A07 Auth Failures | Brute force, token theft | Rate limiting per IP/actor, short token TTL, refresh rotation |
| A08 Software Integrity | Поддельные events/callbacks | HMAC signature validation на callbacks, Kafka signed envelopes |
| A09 Logging Failures | Логов нет / слишком много PII | Structured logging, SensitiveDataMaskingPolicy, audit trail |
| A10 SSRF | Внутренние запросы через redirect | Whitelist provider URLs, no user-controlled redirect targets |
Rate Limiting
Per-Endpoint Rate Limits
// Program.cs — конфигурация rate limiter
builder.Services.AddRateLimiter(o =>
{
// Global API limit
o.AddFixedWindowLimiter("api-global", opts =>
{
opts.PermitLimit = 200;
opts.Window = TimeSpan.FromSeconds(1);
opts.QueueLimit = 0;
});
// Sensitive operations — strict per actor
o.AddSlidingWindowLimiter("sensitive-ops", opts =>
{
opts.PermitLimit = 10;
opts.Window = TimeSpan.FromMinutes(1);
opts.SegmentsPerWindow = 6;
opts.QueueLimit = 0;
});
o.OnRejected = async (context, ct) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
await context.HttpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 429,
Title = "Too Many Requests",
Detail = "Rate limit exceeded. Retry after the window resets."
}, ct);
};
});
// Endpoint level — применение
[EnableRateLimiting("sensitive-ops")]
[HttpPost("withdrawals")]
public async Task<IActionResult> CreateWithdrawal(...) { }
SignalR Rate Limit
// Не более 10 push-уведомлений в секунду на игрока
// Реализуется в RealtimeNotificationRelay через per-user token bucket
JWT И Token Security
Валидация
// Жёсткая конфигурация TokenValidationParameters
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtOptions.Issuer,
ValidAudiences = jwtOptions.Audiences,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtOptions.SigningKey)),
ClockSkew = TimeSpan.FromSeconds(30) // минимальный skew
};
// Запрет небезопасных алгоритмов
o.TokenValidationParameters.ValidAlgorithms = ["HS256", "RS256"];
});
Token Lifetime
| Тип токена | TTL | Ротация |
|---|---|---|
| User access token | 15 минут | При каждом refresh |
| User refresh token | 7 дней | Rotation on use |
| Admin access token | 8 часов | При каждом refresh, MFA required |
| Internal service token | 24 часа | Ротация через secrets manager |
| Webhook HMAC | Бессрочный | Ротация вручную при компрометации |
Tenant Isolation
Repository Scope — Автоматический Фильтр
// EF Interceptor — добавляет tenant_id автоматически ко всем запросам
// Ни один query не возвращает данные чужого tenant
public sealed class TenantScopeInterceptor(IActorContext actorContext)
: IScopeInterceptor
{
public IQueryable<T> ApplyScope<T>(IQueryable<T> query) where T : ITenantScoped
{
// ActorContext.TenantId приходит из JWT claim — нельзя подменить в request body
return query.Where(e => e.TenantId == actorContext.TenantId);
}
}
Bypass — Только С Явным Разрешением И Аудитом
// Global admin bypass требует:
// 1. Permission "admin.global.bypass"
// 2. Explicit audit log
// 3. Не используется в prod без MFA
public sealed class GlobalScopeBypassOptions
{
[Sensitive]
public bool IsGlobalAdminBypassEnabled { get; init; }
}
Callback Signature Validation
Каждый webhook callback от payment provider валидируется по HMAC:
public sealed class WebhookSignatureValidationFilter : IAsyncActionFilter
{
private readonly IWebhookSignatureValidator _validator;
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var signature = context.HttpContext.Request.Headers["X-Signature"].FirstOrDefault();
if (string.IsNullOrEmpty(signature))
{
context.Result = new UnauthorizedResult();
return;
}
context.HttpContext.Request.EnableBuffering();
var body = await new StreamReader(context.HttpContext.Request.Body)
.ReadToEndAsync();
context.HttpContext.Request.Body.Position = 0;
if (!_validator.IsValid(body, signature))
{
_logger.LogWarning("Invalid webhook signature. Path={Path}", context.HttpContext.Request.Path);
context.Result = new UnauthorizedResult();
return;
}
await next();
}
}
Sensitive Operations — Checklist
Для каждой чувствительной операции (KYC, withdrawal approve, balance adjustment, limit change, PII access):
- MFA проверяется через
AuthorizationPolicies.AdminWithMfa - Explicit permission в handler (
IRequiresPermission) - Ownership/tenant check в handler
-
[AuditApiAction("event-type")]на endpoint - EF
AuditDbContextфиксирует before/after state - Лог не содержит PII/secrets
- Rate limit применён
- Idempotency key проверен
Security Code Review Checklist
- Каждый endpoint имеет
[Authorize(Policy = ...)] - Каждый command handler реализует ownership check
- Repository scope filters включены для tenant-scoped сущностей
- Нет интерполяции строк в SQL/Elasticsearch queries
- Логи не содержат password, token, secret, cvv, pan, iban, email, full name
- Input validator проверяет формат строк (MaxLength, Regex для кодов)
- Callback endpoints валидируют HMAC подпись
- Rate limit настроен для auth, withdrawal, KYC и admin endpoints
- JWT validation включает алгоритм whitelist и минимальный ClockSkew
- Secrets берутся из IOptions
/ Vault / k8s Secrets, не из appsettings
Ссылки
- 05 — Repository Pipeline
- 06 — MediatR CQRS Pipeline
- 12 — Resilience, FluentValidation, Audit
- ADR-0046 — Security Coding Standards
- Security Coding Rules
- Actors And Identity Building Blocks
Обратные ссылки
Автоссылки: где используется этот документ.
- adr (1): ADR-0046: Security Coding Standards
- index.md (1): Backend SDK — Руководство Разработчика
- backend-shared-standards-index.md (1): Backend Shared Standards Index
- security-coding-rules.md (1): Правила Security Кодирования