| name | zenticalab-security |
| description | OWASP Top 10 2025 security guidance for ZENTICALAB. Broken Access Control, Cryptographic Failures, Injection, and more — mapped to .NET + Angular.
|
| metadata | {"author":"lucy-camilo","version":"1.0","project":"ZENTICALAB"} |
ZENTICALAB Security — OWASP Top 10 2025 + Best Practices
Skill para asegurar ZENTICALAB contra las vulnerabilidades más críticas. Cada regla está mapeada al OWASP Top 10 2025.
Fuentes: OWASP Top 10 2025 | Angular Security | Microsoft OWASP Training
Regla de oro
Seguridad no es feature — es requisito. Toda PR debe pasar el checklist de seguridad antes de merge. Si hay duda, preguntar antes de merge, no después.
OWASP Top 10 2025 — Mapeo para ZENTICALAB
A01:2025 — Broken Access Control
Riesgo: Un usuario accede a datos de otro tenant. En ZENTICALAB esto es crítico — schema-per-tenant significa que un acceso cruzado revela datos de OTRA dental lab.
Backend
ZONA CRÍTICA — Multi-tenant:
[HttpGet("items/{id}")]
public async Task<IActionResult> GetItem(Guid id)
{
var item = await _context.SupplyItems.FindAsync(id);
return Ok(item);
}
** ✅ Correcto — siempre verificar tenant del contexto:**
[HttpGet("items/{id}")]
[RequirePermissions("Inventario.Leer")]
public async Task<IActionResult> GetItem(Guid id, CancellationToken ct)
{
var ctx = TenantHttpContext.GetTenantRequestContext(HttpContext);
if (ctx is null) return Forbid();
var item = await _context.SupplyItems
.FirstOrDefaultAsync(i => i.Id == id && i.TenantId == ctx.TenantId, ct);
if (item is null) return NotFound();
return Ok(item);
}
Excepciones del middleware:
- El
TenantResolutionMiddleware inyecta el schema en cada request
- Cada query LINQ/EF Core sobre tablas tenant-debe usar el schema correcto
- Nunca hacer queries sobre
public.Suppliers o public.SupplyItems — solo sobre el schema del tenant
IDs en URLs (IDOR):
GET /tenant/inventory/items/3fa85f64-5717-4562-b3fc-2c963f66afa6
Regla de oro: El TenantId o TenantSchema nunca se toma de la URL o query string. Siempre del JWT token / contexto de la request.
Frontend
- Nunca guardar
tenantId o tenantSchema en localStorage como dato legible
- El
TenantAuthStore debe obtener el schema exclusivamente del token JWT decodificado
- Los guards de ruta deben verificar que el tenant del componente coincide con el token
Checklist A01
A02:2025 — Security Misconfiguration
Riesgo: Headers faltantes, CORS abierto, DEBUG en producción, secrets en código.
Backend
Headers de seguridad — Program.cs:
app.Use(async (context, next) =>
{
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
context.Response.Headers.Append("X-Frame-Options", "DENY");
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
context.Response.Headers.Append("Permissions-Policy", "geolocation=(), microphone=(), camera=()");
await next();
});
app.Use(async (context, next) =>
{
context.Response.Headers.Append(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';");
await next();
});
CORS — nunca AllowAnyOrigin():
services.AddCors(options => options.AddPolicy("dev", b => b.AllowAnyOrigin()));
services.AddCors(options => options.AddPolicy("allowed", b =>
{
b.WithOrigins("https://zenticalab.com", "https://admin.zenticalab.com")
.AllowCredentials()
.AllowMethods("GET", "POST", "PUT", "DELETE")
.AllowHeaders("Authorization", "Content-Type", "X-Requested-With");
}));
Variables de entorno obligatorias antes de iniciar:
var jwtKey = builder.Configuration["Jwt__Key"];
if (string.IsNullOrWhiteSpace(jwtKey) || jwtKey.Length < 32)
throw new InvalidOperationException("Jwt__Key must be at least 32 characters.");
DEBUG mode:
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning"
}
}
Eliminar meta-info de errores:
app.UseDeveloperExceptionPage();
app.UseExceptionHandler("/error");
app.UseStatusCodePagesWithReExecute("/error/{0}");
Frontend
environment.ts (dev) puede tener API URL local; environment.prod.ts nunca commit
APP_ENV production build usa siempre NG_APP_API_URL de env var
- No exponer
tenantId o apiKey en archivos de código fuente
Checklist A02
A03:2025 — Software Supply Chain Failures
Riesgo: Paquetes comprometidos, dependencias con vulnerabilidades, ataques a la cadena de suministro.
Backend
Auditar dependencias NuGet:
dotnet list package --outdated
dotnet restore && dotnet audit
Fijar versiones exactas en producción:
<PackageReference Update="FluentValidation.AspNetCore" Version="11.3.0" />
No usar Version="*" — fija la versión para que dotnet restore sea determinístico.
Verificar origen de paquetes:
dotnet nuget list source
GitHub Actions — dependencias en PRs:
- name: Check for vulnerable dependencies
run: dotnet audit --ignore-failed-source
Frontend
npm audit --audit-level=high
npx license-checker --summary
No instalar paquetes de fuentes no verificadas:
- Solo
npm install desde package.json de confianza
- Verificar el repo GitHub de paquetes nuevos antes de instalar
Checklist A03
A04:2025 — Cryptographic Failures
Riesgo: Datos sensibles en texto plano, algoritmos débiles, keys hardcodeadas.
Backend
JWT — algoritmo y key:
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("short-key"));
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_configuration["Jwt__Key"] ?? throw new InvalidOperationException()));
No guardar datos sensibles en texto plano:
public class SupplierDto { public string Nit { get; set; } public string BankAccount { get; set; } }
Hash de passwords:
- ZENTICALAB usa ASP.NET Core Identity con bcrypt — no implementar hash propio
- Si se usa custom password hash, usar
BCrypt.Net-Next
TLS en producción:
app.UseHsts();
app.UseHttpsRedirection();
Certificados:
- Azurite usa clave hardcodeada en dev — nunca en prod
- Producción: Azure Blob Storage con Managed Identity, no connection strings
Checklist A04
A05:2025 — Injection
Riesgo: SQL injection, XSS, command injection.
Backend — SQL Injection
ZENTICALAB usa EF Core + raw SQL:
var sql = $"SELECT * FROM \"{schema}\".\"Items\" WHERE name LIKE '%{userInput}%'";
await _context.Database
.SqlQueryRaw<SupplyItemRow>(
$"SELECT * FROM \"{safeSchema}\".\"SupplyItems\" WHERE \"Name\" LIKE {{{0}}}",
$"%{sanitized}%")
Validación de input con FluentValidation:
public class CreateSupplyItemValidator : AbstractValidator<CreateSupplyItemRequestDto>
{
public CreateSupplyItemValidator()
{
RuleFor(x => x.Name)
.NotEmpty().MaximumLength(255)
.Matches(@"^[a-zA-Z0-9\s\-áéíóúÁÉÍÓÚñÑ]+$")
.WithMessage("Nombre solo puede contener letras, números, espacios y guiones.");
RuleFor(x => x.Nit)
.NotEmpty()
.Matches(@"^\d{9,15}-?\d$")
.WithMessage("NIT inválido.");
}
}
Schema validation:
var safeSchema = TenantSqlBuilder.ValidateSchema(context.TenantSchema);
Frontend — XSS
Angular sanitiza por defecto, pero:
<div [innerHTML]="userProvidedHtml"></div>
<div>{{ userInput }}</div>
⚠️ WARNING: bypassSecurityTrustHtml disables ALL Angular sanitization. This reintroduces XSS risk if the HTML comes from user input. Use ONLY with fully trusted, server-curated content. Never pass user-provided strings through this method.
import { DomSanitizer } from '@angular/platform-browser';
constructor(private sanitizer: DomSanitizer) {}
getSafeHtml(html: string) {
return this.sanitizer.bypassSecurityTrustHtml(html);
}
Nunca usar eval() ni new Function() en Angular:
eval(`console.log('${userInput}')`);
Content Security Policy en Angular:
{
"production": {
"headers": {
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'"
}
}
}
Checklist A05
A06:2025 — Insecure Design
Riesgo: Architecture flaws — autenticación débil, no rate limiting, ausencia de locks.
Backend
Rate limiting por tenant:
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("PerTenant", context =>
{
var tenant = context.User.FindFirst("tenant_schema")?.Value ?? "anonymous";
return RateLimitPartition.GetFixedWindowLimiter(tenant, _ =>
new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1)
});
});
});
[HttpGet]
[EnableRateLimiting("PerTenant")]
[RequirePermissions("Inventario.Leer")]
public async Task<IActionResult> GetAlerts(...) { ... }
Account lockout:
services.Configure<IdentityOptions>(options =>
{
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
});
Missing auth on new endpoint — fail-safe:
Checklist A06
A07:2025 — Authentication Failures
Riesgo: Sesiones robadas, tokens sin expiración, credenciales en URL.
Backend
JWT — expiración obligatoria:
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
new Claim("tenant_schema", tenantSchema),
new Claim("tenant_id", tenantId.ToString())
}),
Expires = DateTime.UtcNow.AddMinutes(
double.Parse(_configuration["Jwt__ExpiryMinutes"] ?? "60")),
Issuer = _configuration["Jwt__Issuer"],
Audience = _configuration["Jwt__Audience"],
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
};
Refresh tokens:
var refreshToken = new RefreshToken
{
Value = GenerateSecureToken(),
TenantId = tenantId,
UserId = userId,
ExpiresAt = DateTime.UtcNow.AddDays(7),
IsRevoked = false
};
Logout — invalidar token:
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
return Ok();
}
2FA (para admins):
[RequirePermissions("Admin.GestionUsuarios", Require2FA = true)]
public async Task<IActionResult> DeleteUser(Guid userId) { ... }
Frontend
Token storage — NUNCA en localStorage para XSS:
localStorage.setItem('accessToken', token);
HttpOnly cookies MUST be set by the backend via Set-Cookie response header, not from JavaScript.
Set-Cookie: accessToken=...; HttpOnly; Secure; SameSite=Strict
document.cookie = 'accessToken=...; Secure; SameSite=Strict';
Si se usa storage (para ZENTICALAB multi-tenant con JWT):
- Usar
memory signal store (en vez de localStorage) para tokens
- El
TenantAuthStore guarda el JWT en una signal, no en localStorage
- Solo el refresh token (si existe) en httpOnly cookie
export const authStore = {
token: signal<string | null>(null),
tenantSchema: signal<string | null>(null),
};
Logout limpio:
logout() {
this.authStore.token.set(null);
this.authStore.tenantSchema.set(null);
document.cookie = 'refreshToken=; Max-Age=0; Secure';
}
Checklist A07
A08:2025 — Software or Data Integrity Failures
Riesgo: Pipelines de CI/CD comprometidos, dependencias sin verificar.
Backend
GitHub Actions — no confiar en artifacts de PRs sin verificar:
- run: dotnet build
env:
JWT_KEY: ${{ secrets.JWT_KEY }}
- name: Build
if: github.event.pull_request.merged == true
run: dotnet build --configuration Release
Verificar integridad de deps:
dotnet restore --locked-mode
Migraciones de DB firmadas (para producción futura):
- Migrations son código que modifica el schema — tratarlas como código de producción
Checklist A08
A09:2025 — Security Logging and Alerting Failures
Riesgo: Ataques pasan desapercibidos porque no hay logs.
Backend
Log de seguridad — siempre incluir:
_logger.LogWarning(
"AUTH_FAILURE: Tenant={TenantSchema} User={UserEmail} IP={IpAddress} " +
"Attempt={AttemptNumber} Reason={Reason}",
tenantSchema, email, ipAddress, attemptCount, failureReason);
Loguear SIN exponer datos sensibles:
_logger.LogInformation("User {Password} login failed", password);
_logger.LogInformation("User login failed for email={Email}", email[..3] + "***");
Events de seguridad a registrar:
| Evento | Nivel | Datos |
|---|
| Login exitoso | Info | Tenant, user, IP, timestamp |
| Login fallido | Warning | Tenant, email, IP, razón, intento # |
| Logout | Info | Tenant, user |
| Token refresh | Debug | Tenant, user |
| Acceso denegado (403) | Warning | Tenant, user, recurso, IP |
| Cambio de rol | Warning | Admin que lo hizo, target user, rol nuevo |
| Delete de tenant | Error | Admin, tenant schema, timestamp |
Alertas — SIEMPRE notificar:
-
5 login fallidos desde la misma IP en 10 min → alerta al admin
- Acceso denegado a recurso desde cuenta con permisos previos
- Queries SQL que fallen por timeout (posible enumeración)
Checklist A09
A10:2025 — Mishandling of Exceptional Conditions
Riesgo: Excepciones no manejadas revelan información interna; denegación de servicio por errores no manejados.
Backend
Global exception handler:
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
var error = context.Features.Get<IExceptionHandlerFeature>();
var exception = error?.Error;
_logger.LogError(exception,
"Unhandled exception. TraceId={TraceId}",
context.TraceIdentifier);
await context.Response.WriteAsJsonAsync(new
{
error = "An error occurred processing your request.",
traceId = context.TraceIdentifier
});
});
});
Nunca propagar mensajes de excepción interna al cliente:
catch (SqlException ex)
{
return BadRequest($"Database error: {ex.Message}");
}
catch (SqlException ex)
{
_logger.LogError(ex, "DB error in GetSupplyItems for tenant {Schema}", schema);
return StatusCode(503, "Service temporarily unavailable.");
}
Null-check riguroso:
var supplier = context.Suppliers.FirstOrDefault(s => s.Id == dto.SupplierId);
return Ok(new { supplier.RazonSocial });
var supplier = context.Suppliers.FirstOrDefault(s => s.Id == dto.SupplierId)
?? throw new InvalidOperationException($"Supplier {dto.SupplierId} not found in tenant {schema}");
Timeout en queries:
await _context.SupplyItems
.Where(i => i.TenantId == tenantId)
.Take(1000)
.ToListAsync(cancellationToken);
Frontend
Manejo de errores en services:
return this.http.get('/api/items').pipe(
map(items => items)
);
return this.http.get<Item[]>(`${environment.apiUrl}/items`).pipe(
map(items => items),
catchError(err => {
if (err.status === 401) {
this.authStore.token.set(null);
this.router.navigate(['/login']);
}
return throwError(() => err);
})
);
Checklist A10
Checklist de seguridad — Pre-PR
Copiar y marcar antes de cada PR:
Backend
Frontend
Referencias