Перейти к основному содержимому

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 secretsCVV, full PAN, bank account numbers
JWT / session tokensaccess token, refresh token, session cookie
KYC documentsdocument scans, raw OCR text
Raw PIIfull name + DOB + address в одном log entry
Provider credentialswebhook secret, OAuth client_secret
Encryption keysAES 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
WarningRecoverable issues: idempotent duplicate, rate limited, validation fail
ErrorUnexpected 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 InjectionSQL/NoSQL injectionEF parameterized queries, typed Elasticsearch builder, FluentValidation
A04 Insecure DesignОтсутствие defence-in-depth4-слойная авторизация, 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 FailuresBrute force, token theftRate limiting per IP/actor, short token TTL, refresh rotation
A08 Software IntegrityПоддельные events/callbacksHMAC signature validation на callbacks, Kafka signed envelopes
A09 Logging FailuresЛогов нет / слишком много PIIStructured logging, SensitiveDataMaskingPolicy, audit trail
A10 SSRFВнутренние запросы через redirectWhitelist 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 token15 минутПри каждом refresh
User refresh token7 днейRotation on use
Admin access token8 часовПри каждом refresh, MFA required
Internal service token24 часаРотация через 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

Ссылки

Обратные ссылки

Автоссылки: где используется этот документ.