security: comprehensive security audit and remediation (20 fixes)

CRITICAL fixes:
- Restrict X-View-Tenant impersonation to global admin only (was any admin)
- Add authorization to subscription endpoints (was open to any user)
- Make webhook signature verification mandatory (was skippable)
- Remove databaseName from JWT payload (resolve server-side with cache)
- Reduce body size limit from 1GB to 10MB (50MB for bulk CFDI)
- Restrict .env file permissions to 600

HIGH fixes:
- Add authorization to SAT cron endpoints (global admin only)
- Add Content-Security-Policy and Permissions-Policy headers
- Centralize isGlobalAdmin() utility with caching
- Add rate limiting on auth endpoints (express-rate-limit)
- Require authentication on logout endpoint

MEDIUM fixes:
- Replace Math.random() with crypto.randomBytes for temp passwords
- Remove console.log of temporary passwords in production
- Remove DB credentials from admin notification email
- Add escapeHtml() to email templates (prevent HTML injection)
- Add file size validation on FIEL upload (50KB max)
- Require TLS for SMTP connections
- Normalize email to lowercase before uniqueness check
- Remove hardcoded default for FIEL_ENCRYPTION_KEY

Also includes:
- Complete production deployment documentation
- API reference documentation
- Security audit report with remediation details
- Updated README with v0.5.0 changelog
- New client admin email template
- Utility scripts (create-carlos, test-emails)
- PM2 ecosystem config updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-03-18 22:32:04 +00:00
parent 38626bd3e6
commit 351b14a78c
31 changed files with 1287 additions and 103 deletions

View File

@@ -16,6 +16,18 @@ export async function upload(req: Request, res: Response): Promise<void> {
return;
}
// Validate file sizes (typical .cer/.key files are under 10KB, base64 ~33% larger)
const MAX_FILE_SIZE = 50_000; // 50KB base64 ≈ ~37KB binary
if (cerFile.length > MAX_FILE_SIZE || keyFile.length > MAX_FILE_SIZE) {
res.status(400).json({ error: 'Los archivos FIEL son demasiado grandes (máx 50KB)' });
return;
}
if (password.length > 256) {
res.status(400).json({ error: 'Contraseña FIEL demasiado larga' });
return;
}
const result = await uploadFiel(tenantId, cerFile, keyFile, password);
if (!result.success) {

View File

@@ -7,6 +7,7 @@ import {
} from '../services/sat/sat.service.js';
import { getJobInfo, runSatSyncJobManually } from '../jobs/sat-sync.job.js';
import type { StartSyncRequest } from '@horux/shared';
import { isGlobalAdmin } from '../utils/global-admin.js';
/**
* Inicia una sincronización manual
@@ -121,10 +122,14 @@ export async function retry(req: Request, res: Response): Promise<void> {
}
/**
* Obtiene información del job programado (solo admin)
* Obtiene información del job programado (solo admin global)
*/
export async function cronInfo(req: Request, res: Response): Promise<void> {
try {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
res.status(403).json({ error: 'Solo el administrador global puede ver info del cron' });
return;
}
const info = getJobInfo();
res.json(info);
} catch (error: any) {
@@ -134,10 +139,14 @@ export async function cronInfo(req: Request, res: Response): Promise<void> {
}
/**
* Ejecuta el job de sincronización manualmente (solo admin)
* Ejecuta el job de sincronización manualmente (solo admin global)
*/
export async function runCron(req: Request, res: Response): Promise<void> {
try {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
res.status(403).json({ error: 'Solo el administrador global puede ejecutar el cron' });
return;
}
// Ejecutar en background
runSatSyncJobManually().catch(err =>
console.error('[SAT Controller] Error ejecutando cron manual:', err)

View File

@@ -1,8 +1,19 @@
import type { Request, Response, NextFunction } from 'express';
import * as subscriptionService from '../services/payment/subscription.service.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
if (!isAdmin) {
res.status(403).json({ message: 'Solo el administrador global puede gestionar suscripciones' });
}
return isAdmin;
}
export async function getSubscription(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const tenantId = String(req.params.tenantId);
const subscription = await subscriptionService.getActiveSubscription(tenantId);
if (!subscription) {
@@ -16,6 +27,8 @@ export async function getSubscription(req: Request, res: Response, next: NextFun
export async function generatePaymentLink(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const tenantId = String(req.params.tenantId);
const result = await subscriptionService.generatePaymentLink(tenantId);
res.json(result);
@@ -26,6 +39,8 @@ export async function generatePaymentLink(req: Request, res: Response, next: Nex
export async function markAsPaid(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const tenantId = String(req.params.tenantId);
const { amount } = req.body;
@@ -42,6 +57,8 @@ export async function markAsPaid(req: Request, res: Response, next: NextFunction
export async function getPayments(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const tenantId = String(req.params.tenantId);
const payments = await subscriptionService.getPaymentHistory(tenantId);
res.json(payments);

View File

@@ -1,20 +1,10 @@
import { Request, Response, NextFunction } from 'express';
import * as usuariosService from '../services/usuarios.service.js';
import { AppError } from '../utils/errors.js';
import { prisma } from '../config/database.js';
// RFC del tenant administrador global
const ADMIN_TENANT_RFC = 'CAS2408138W2';
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
async function isGlobalAdmin(req: Request): Promise<boolean> {
if (req.user!.role !== 'admin') return false;
const tenant = await prisma.tenant.findUnique({
where: { id: req.user!.tenantId },
select: { rfc: true },
});
return tenant?.rfc === ADMIN_TENANT_RFC;
return checkGlobalAdmin(req.user!.tenantId, req.user!.role);
}
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {

View File

@@ -9,13 +9,16 @@ export async function handleMercadoPagoWebhook(req: Request, res: Response, next
const xSignature = req.headers['x-signature'] as string;
const xRequestId = req.headers['x-request-id'] as string;
// Verify webhook signature
if (xSignature && xRequestId && data?.id) {
const isValid = mpService.verifyWebhookSignature(xSignature, xRequestId, String(data.id));
if (!isValid) {
console.warn('[WEBHOOK] Invalid MercadoPago signature');
return res.status(401).json({ message: 'Invalid signature' });
}
// Verify webhook signature (mandatory)
if (!xSignature || !xRequestId || !data?.id) {
console.warn('[WEBHOOK] Missing signature headers');
return res.status(401).json({ message: 'Missing signature headers' });
}
const isValid = mpService.verifyWebhookSignature(xSignature, xRequestId, String(data.id));
if (!isValid) {
console.warn('[WEBHOOK] Invalid MercadoPago signature');
return res.status(401).json({ message: 'Invalid signature' });
}
if (type === 'payment') {