Análise Detalhada — #08 Cross-Tenant Data Leak
Data: 2026-07-08
Vetor: GET/POST/DELETE /api/payment/profile-fraud + endpoints /api/payment/* sem JWT
1. O Ataque
1.1 ProfileFraudController — zero auth
Arquivo: coezzion-service-checkout/src/Checkout.API/Controllers/ProfileFraudController.cs
[Route("api/payment/profile-fraud")]
[ApiController]
public class ProfileFraudController(IZZMediator mediator) : BaseController
{
[HttpPost]
public async Task<IActionResult> CreateProfileFraud([FromBody] CreateProfileFraudCommand command) { }
[HttpDelete]
public async Task<IActionResult> DeleteProfileFraud([FromBody] DeleteProfileFraudCommand command) { }
[HttpGet]
public async Task<IActionResult> GetProfilesFraud([FromQuery] FindProfilesFraudQuery query) { }
[HttpGet("status")]
public async Task<IActionResult> GetProfileFraudById([FromQuery] FindProfileFraudStatusQuery query) { }
[HttpGet("log")]
public async Task<IActionResult> GetProfilesFraudLog([FromQuery] FindProfilesFraudLogQuery query) { }
}
Zero [Authorize], zero [UseApiKey], zero [AddSchema]. Completamente público.
1.2 Fluxo de dados
GET /api/payment/profile-fraud?storeId=0
│
▼
ProfileFraudQueryHandler
│ FindProfilesFraudQuery → ProfileFraudDTO
▼
CustomerProfileFraudRepository.GetCustomersProfileFraudParamsAsync(0, 0)
│ EF Core LINQ: WHERE storeId == 0 OR x.StoreId == storeId
│ storeId=0 → ALL rows do schema atual
▼
Para cada CustomerProfileFraudModel:
│ _orderRepository.GetCustomerByIdAsync(customerId) → Customer (CPF, Name, CellPhone)
│ _storeRepository.GetStoreAsync(storeId) → Store (StoreName, ShowName, CodeStore)
▼
ProfileFraudDTO → 238 registros com PII completo
1.3 DTO exposto
// Checkout.Domain/DTO/ProfileFraud/ProfileFraudDTO.cs
public class ProfileFraudData
{
public int Id { get; set; }
public int CustomerId { get; set; }
public string CustomerCPF { get; set; } // ← PII
public string CustomerName { get; set; } // ← PII
public string CustomerCellPhone { get; set; } // ← PII
public int StoreId { get; set; }
public string StoreName { get; set; }
public string ShowName { get; set; }
public string CodeStore { get; set; }
public bool Blocked { get; set; }
public bool Loyalty { get; set; }
}
1.4 DELETE — bypass do sistema de fraude
// ProfileFraudCommandHandler.cs
public async Task<BaseResult> Handle(DeleteProfileFraudCommand command, CancellationToken cancellationToken)
{
var profileFraud = await _customerProfileFraudRepository
.GetCustomersProfileFraudParamsAsync(command.Id);
profileFraud.DeleteEntity(); // soft delete
await _customerProfileFraudRepository.UnitOfWork.CommitAsync();
}
Atacante pode remover qualquer perfil de fraude de qualquer schema. Sem rastreamento de quem fez a remoção (sem UserActionId no delete).
2. Tenant Selection
2.1 UserProvider.GetSchemaName() — resolução
Arquivo: coezzion-nuget-common/src/Coezzion.Common/Providers/UserProvider.cs
Prioridade de resolução do schema:
| Pri | Fonte | Quando usado |
|---|---|---|
| 1 | x-api-key == venda_ai → "arezzo" | Requisições com chave venda_ai |
| 2 | JWT claim schema | Requisições autenticadas |
| 3 | Header api-company-target | Fallback — atacante controla |
| 4 | SchemaNameScopedWrapper.SchemaName | Último recurso |
Sem JWT (prioridade 2 ausente) → cai na prioridade 3 → header api-company-target → tenant arbitrário.
2.2 DatabaseConfig.GetSchema() — via EF Core
Arquivo: coezzion-service-checkout/src/Checkout.API/Configuration/DatabaseConfig.cs
public static string GetSchema(IServiceProvider provider)
{
var schemaName = "";
var contextAccessor = provider.GetService<IHttpContextAccessor>();
if (contextAccessor.HttpContext != null)
{
var identity = contextAccessor.HttpContext.User?.Identity as ClaimsIdentity;
if (identity?.FindFirst("schema") != null)
schemaName = identity.FindFirst("schema").Value;
// fallback para UserProvider (que cai no header)
if (string.IsNullOrEmpty(schemaName))
{
var userProvider = provider.GetService<IUserProvider>();
schemaName = userProvider.GetSchemaName();
}
}
else
{
// background: SchemaNameScopedWrapper
}
return schemaName;
}
Sem JWT → identity.FindFirst("schema") retorna null → fallback para IUserProvider.GetSchemaName() → cai no header.
3. Clientes: zzapp e zzportal
3.1 zzapp (Flutter)
Profile-fraud: código comentado (desabilitado no app). Método findProfileFraud() em profile_fraud_controller.dart só chama update(). Função isCustomerBlocked() retorna hardcoded false.
Endpoints ativos chamados pelo zzapp:
| Endpoint | Método | Arquivo |
|---|---|---|
/api/payment-report/report/sales/{orderId}/5 | GET | cart_link_controller.dart |
/api/payment-report/dashboard/app | GET | omni_indicators_link_controller.dart |
/api/store/payment/{storeId}/app | GET | store_payment_config_controller.dart |
Auth: AuthInterceptor adiciona Authorization: Bearer {token} em todas as requests.
3.2 zzportal (React)
Profile-fraud: ativamente usado.
| Endpoint | Método | Arquivo |
|---|---|---|
/api/payment/profile-fraud | GET, POST, DELETE | src/services/payment/index.ts |
/api/payment/approve | POST | src/services/payment/index.ts |
/api/payment/reverse | POST | src/services/payment/index.ts |
/api/payment/cancel | POST | src/services/payment/index.ts |
/api/payment/antifraud/retry/{id} | POST | src/services/payment/index.ts |
/api/payment/history/{id} | GET | src/services/payment/index.ts |
/api/payment/control-operation/store | PUT, POST, DELETE | src/services/payment/index.ts |
/api/payment/control-operation | PUT | src/services/payment/index.ts |
Auth: api.defaults.headers.common.Authorization = Bearer {token} setado no sign-in, refresh, e rehydration. Todas requests do portal enviam Bearer.
3.3 Conclusão
Ambos clientes já enviam Authorization: Bearer em todas as requests. Adicionar [Authorize(JwtBearer)] no backend não causa breaking change.
4. Endpoints Afetados (Checkout Service)
4.1 ProfileFraudController — 5 actions, zero auth
| Action | HTTP | Dado |
|---|---|---|
CreateProfileFraud | POST | Cria perfil de fraude |
DeleteProfileFraud | DELETE | Remove perfil de fraude |
GetProfilesFraud | GET | Lista perfis com PII |
GetProfileFraudById | GET | Query por storeId+customerId |
GetProfilesFraudLog | GET | Log de modificações |
4.2 PaymentController — 6 actions sem JWT
| Action | HTTP | Auth atual | Dado |
|---|---|---|---|
GetOrderHistory | GET | Nenhum | Histórico de pedido |
CreatePayment | POST | [AddSchema] | Cria pagamento |
GetInfoAsync | GET | [AddSchema] | Dados do pagamento |
GetInstallmentsDetailsAsync | GET | [AddSchema] | Parcelamento |
GetReasonsScoreAsync | GET | [AddSchema] | Motivos por score |
NPSAsync | POST | [AddSchema] | Pesquisa NPS |
4.3 Já protegidos (sem mudança)
| Action | Auth |
|---|---|
ApprovePayment | JWT ✅ |
ReversePayment | JWT ✅ |
CancelOrder | JWT ✅ |
FinishOrder | PaymentsScheme JWT ✅ |
RetryAsync | PaymentsScheme JWT ✅ |
AddRecommendedItem | PaymentsScheme JWT ✅ |
RemoveRecommendedItem | PaymentsScheme JWT ✅ |
ControlOperationController (todos) | JWT ✅ |
5. Cross-Tenant Binding
Após adicionar JWT:
UserProvider.GetSchemaName()encontra claimschemano JWT (prioridade 2)- Retorna o schema do token — não do header
- Header
api-company-targeté ignorado (prioridade 3 nunca alcançada) - Fix do #07 (schema whitelist) atua como defense-in-depth: mesmo que o header seja lido, só
"arezzo"passa
Exemplo: Usuário logado na Arezzo (JWT claim schema: arezzo) faz request com header api-company-target: brizza. O backend usa arezzo (do JWT), ignora brizza (do header).