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:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user