Fecha: 2026-08-24 Estado: Seguridad crítica remediada (no production-ready completo, pero sin 🔴 CRITICAL abiertos)
appsettings.json contenía JwtSettings:SecretKey = SuperSecretKey123456789101112131415 y ConnectionStrings:DefaultConnection con Trusted_Connection=True. Ambos committeados en 27c8715 y por tanto comprometidos para siempre en el historial de git.
SplitIt.API/SplitIt.API/appsettings.json:1ahora vacío ("") — solo placeholders.- Nuevos templates:
appsettings.Development.json.exampleyappsettings.Production.json.example. .env.exampleen raíz +split-it-ui/.env.example.split-it-ui/src/environments/environment.prod.ts.exampleañadido..gitignore:22corregido: ya no ignorapackage-lock.json(builds reproducibles), añade.envexclusions con!.env.example.SplitIt.API/Program.cs:17ahora falla en Producción siSecretKey<32 chars o vacío, con mensaje explícito.SplitIt.API/DependencyInjection.cs:9warn siConnectionStrings:DefaultConnectionvacío.Cors:AllowedOriginsahora vía envCors__AllowedOrigins.
Se asumen comprometidos. El secret antiguo debe rotarse y NO reutilizarse.
Pasos recomendados:
- Generar nuevo secret:
openssl rand -base64 64(Linux) o en PowerShell:[Convert]::ToBase64String((1..64 | % {Get-Random -Max 256})) - Setear en servidor/VPS vía env var
JwtSettings__SecretKeyy local viadotnet user-secrets set "JwtSettings:SecretKey" "<NEW>"oappsettings.Development.json(no trackeado). - Limpiar historial solo si repo es privado y se coordina con colaboradores: usar
git filter-repooBFG Repo-Cleanerpara reescribirappsettings.jsonhistórico, luegogit push --forcey notificar a todos de re-clonar. Verhttps://docs.github.com/en/authentication/keeping-your-account-and-data-secure/removing-sensitive-data-from-a-repository. - Activar GitHub Secret Scanning:
Settings → Code security → Secret scanning+Push protection. - Añadir
gitleakspre-commit:npm: gitleaksopre-commit hook: gitleaks protect --staged. CI escanea congitleaks detect --source . --redact.
- CI debe correr
gitleaks detect --source . --no-git --redacty fail enexit !=0. - Docker scan (Trivy) también detecta secrets en images.
SplitIt.Infrastructure/Services/AuthService.cs:49 usaba SHA256(password) → Base64 sin salt, sin work factor. Rápido, rainbow tables triviales (CWE-327).
-
Paquete
Microsoft.AspNetCore.Identity.EntityFrameworkCore 8.0.15añadido aSplitIt.Infrastructure.csproj:10. -
AuthServiceusaIPasswordHasher<User>(PasswordHasher<User>por defecto):- Algoritmo: PBKDF2-HMAC-SHA256, 128-bit salt, 100.000+ iteraciones, formato Identity V3 (incluye versionado).
- Nuevos usuarios:
HashPasswordvíaIPasswordHasher. - Login:
VerifyHashedPasswordcon soporteSuccessRehashNeeded.
-
Migración legacy:
Login → VerifyHashedPassword → if fail → check Legacy SHA256 (44-char Base64) → if match → rehash con PasswordHasher → SaveChanges → successHelpers:
IsLegacySha256Hash,VerifyLegacySha256enAuthService.cs:62. -
Tests:
AuthServicePasswordHashingTests(verSplitIt.Tests) cubren registro, verificación PBKDF2 y migración legacy.
Evaluar Argon2id (libsodium) si se requiere mayor resistencia GPU; PasswordHasher es aceptado como estándar OWASP y suficiente para 2026. Si se migra a Argon2, usar wrapper IPasswordHasher custom.
- Unificado a
Encoding.UTF8.GetBytes(antes ASCII vs UTF8 dispar). SecretKeyvalidado: min 32 chars, 64+ recomendado, distinto por ambiente, vía env var.TokenValidationParameters:ValidateIssuerSigningKey=true,ValidateIssuer=true,ValidateAudience=true,ValidateLifetime=true,RequireSignedTokens=true,RequireExpirationTime=trueClockSkew = TimeSpan.Zero(antes default 5m → ventana replay)ValidAlgorithms = [HmacSha256]+ eventoOnTokenValidatedrechazaalg != HS256(mitigaalg:none).ValidIssuer/ValidAudiencedesde config, obligatorios en Prod.
RequireHttpsMetadata = !IsDevelopment()(antes siempre false).- Claims:
sub,NameIdentifier,Email,Jti,Role(int RoleId).expviaExpirationInMinutesparseado con TryParse. - Tests: valid token, expired, tampered signature, wrong issuer/audience, missing token (ver
JwtValidationTests).
ExpirationInMinutes=60 (1h). Sin refresh token aún — logout es client-side. Fase futura debe añadir refresh rotation HttpOnly.
| Criterio | Option A: localStorage (actual) | Option B: HttpOnly Secure SameSite cookie |
|---|---|---|
| XSS risk | Alto si hay XSS (JS puede leer) | Bajo (JS no lee) |
| CSRF risk | Nulo (Auth header manual) | Alto si no hay CSRF token (cookie enviada auto) |
| Angular compat | Simple, interceptor añade header | Requiere withCredentials:true, backend Set-Cookie, CORS AllowCredentials, CSRF double-submit |
| SSR | Funciona | Más complejo |
| Logout | Client-side remove | Server-side invalidate |
Mantener Option A (localStorage) + endurecer XSS, por:
- Angular SPA sin SSR activo, sin backend cookie infra aún.
- CSRF con cookies añadiría complejidad y riesgo si se implementa mal antes de tener
Antiforgery. - La amenaza principal es XSS vía
Title/Note/Description— se mitiga con CSP + sanitización + validation.
Mitigaciones implementadas:
AuthGuardcorr. + interceptor limpia 401.- DTO validation
[StringLength(500)]enNote/Descriptionreduce payload XSS pero no sustituye output encoding. - Fase 11 (Nginx) añadirá
Content-Security-Policy: default-src 'self'y Angular sanitiza por defecto (DomSanitizer). - Logs nunca incluyen token.
Roadmap: Migrar a HttpOnly Secure SameSite=Strict + RefreshToken + /api/auth/refresh + CSRF XSRF-TOKEN en Fase 8 si se alarga sesión.
Ningún endpoint verificaba membership: GET /groups/{id}/members, /details, /userrole, GET /expenses/{groupId}/expenses, POST /expenses/add, GET /debt-summary, POST /settle permitían acceso cross-group.
- Nuevo método
GroupService.IsUserMemberAsync(groupId, userId):bool(GroupService.cs:100). - Todos los controllers que reciben
groupIdverifican:Afectados:if (!await _groupService.IsUserMemberAsync(groupId, userId)) return Forbid();
GroupsController.cs:67,81,95→GetGroupMembers,getGroupDetails,GetUserGroupRoleAsyncExpensesController.cs:45,59,75→GetGroupExpenses,GetFullDebtSummary,SettleExpenseWithUser
ExpensesService.AddExpenseAsyncvalidaCreatedByIdmember,PaidByIdmember,Participantssubset deGroupMembers,sum(AmountOwed)==Amount.ExpensesService.SettleExpenseWithUseryRegisterPaymentahora recibengroupIdy filtranes.Expense.GroupId == groupId(fix cross-group).- Tests:
BolaTestscon 2 users, 2 groups — userA no puede leer grupo de userB.
Bug: ExpensesService.cs:165 settle ignoraba groupId, liquidaba deudas entre dos users en todos los grupos.
Fix: Firma cambiada a SettleExpenseWithUser(payerUserId, receiverUserId, groupId) + RegisterPayment valida groupId + query scoped es.Expense.GroupId == groupId. ExpensesController.cs:86 ahora pasa dto.GroupId.
Test: SettlementCrossGroupTests crea GroupA (A owes B $100) y GroupB (A owes B $50), settle GroupA → GroupB permanece intacto.
- Antes:
AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()enProgram.cs:93. - Ahora:
Program.cs:98leeCors:AllowedOrigins(oCors__AllowedOrigins) comma-separated.- Si configurado →
WithOrigins(allowedOrigins).AllowCredentials(). - Si vacío + Development → solo
http://localhost:4200+https://localhost:4200. - Si vacío + Production → deny all (fail closed) — debe configurarse.
- Si configurado →
UseCors("AppCors")antes deUseAuthentication.
- DTOs ahora explícitos con
[Required]/[Range]/[StringLength]— no bind a Entity directa. RegisterRequestDtono exponeRoleId(siempreRoleId=3hardcoded server-side).CreateGroupDtoignoraRole—AddGroupMemberssetea"creator"solo simemberId == creatorId.CreateExpenseDtono permite cliente setearCreatedById— se deriva de JWTNameIdentifier.- Tests:
MassAssignmentTestsintentan enviarRoleIdextra — ignorado.
Todos los DTOs con DataAnnotations (SplitIt.Application/DTOs/*.cs:1):
RegisterRequestDto:Name 2..100,EmailAddress,Password 8..100CreateGroupDto:Name 2..200,Description 1..500,CurrencyId RangeCreateExpenseDto:Title 1..100,Note 0..500,Amount 0.01..1M,Date required,PaidById Range,Participants MinLength 1RegisterPaymentDto:PayerUserId Range,Amount 0.01..1M- Backend valida también en services:
sum == total ±0.02,participants member,max 50 members/participants,dates UTC.
- Nuevo
SplitIt.API/Middleware/GlobalExceptionHandler.cs:1(IExceptionHandler.NET 8). - Registrado
AddExceptionHandler<GlobalExceptionHandler>()+AddProblemDetails()+UseExceptionHandler()enProgram.cs:45. - Prod nunca devuelve stack traces, SQL errors, paths — solo
ProblemDetails {status, title, detail, traceId}.traceIdcorrelaciona con logs. - Dev sí incluye
exception.Messagepara debugging; logs siempre conLogError(exception, TraceId).
AddRateLimiterenProgram.cs:52:- Policy
"auth":FixedWindow 5/min/IP(partition porRemoteIpoHost) paraPOST /api/auth/loginy/register([EnableRateLimiting("auth")]enAuthController.cs:27,44). - Policy
"fixed":100/mingeneral (no aplicado global aún, listo para[EnableRateLimiting("fixed")]en endpoints sensibles).
- Policy
OnRejecteddevuelve429 {message: "Too many requests..."}JSON.
- Logs no incluyen
Password,JWT,ConnectionString. GlobalExceptionHandlerusaILoggerconTraceId+Path.- HSTS en prod (
UseHsts()).
- XSS residual: localStorage sigue vulnerable si hay XSS almacenado. Mitigado pero no eliminado hasta CSP + HttpOnly futuro.
- No refresh token rotation / revocation: stolen JWT válido 60m.
- No pagination:
GetGroupMembersyGetUserssin paginación — DoS vía large groups (limit 50 mitiga pero no paginación). - DB backup/restore no implementado — fuera de alcance Phase 0.5.
- Container/Docker hardening no implementado — Fase 9.
-
appsettings.jsonsin secretos -
SHA256eliminado,PasswordHasher+ legacy rehash -
authGuardcorr. - BOLA checks en todos
groupIdendpoints - Settlement scoped por
groupId - CORS sin
AllowAnyOriginen prod - JWT
ClockSkew=0,UTF8,RequireHttpsMetadata, alg validation - JWT storage decisión documentada
- DTO validation
- Global exception handler sin leak
- Rate limiting auth 5/min