Skip to content
GitHub

Security Hardening

Guía de prácticas de seguridad para infraestructura, aplicaciones y operaciones del ecosistema Nostromo.

Objetivo: Proteger datos sensibles de clientes (payroll, contratos, información personal) según mejores prácticas de seguridad.


UFW (Ubuntu Firewall):

Terminal window
# Estado actual
sudo ufw status verbose
# Política por defecto: denegar todo entrante
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Permitir SSH (solo desde IPs conocidas)
sudo ufw allow from 203.0.113.0/24 to any port 22
# Permitir HTTP/HTTPS
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Denegar acceso directo a PostgreSQL (solo localhost)
sudo ufw deny 5432/tcp
# Denegar acceso directo a Orchestrator (solo via Nginx)
sudo ufw deny 8000/tcp
# Habilitar firewall
sudo ufw enable

Config recomendada (/etc/ssh/sshd_config):

# Deshabilitar login como root
PermitRootLogin no
# Solo autenticación por clave
PasswordAuthentication no
PubkeyAuthentication yes
# Limitar usuarios permitidos
AllowUsers deployer admin
# Cambiar puerto por defecto (opcional, security through obscurity)
# Port 2222
# Limitar intentos de conexión
MaxAuthTries 3
MaxSessions 2
# Timeout de sesiones inactivas
ClientAliveInterval 300
ClientAliveCountMax 2

Aplicar cambios:

Terminal window
sudo sshd -t # Validar sintaxis
sudo systemctl restart sshd

Certbot (Let’s Encrypt):

Terminal window
# Instalar certbot
sudo apt install certbot python3-certbot-nginx
# Obtener certificado
sudo certbot --nginx -d app.nostromo.cl -d api.nostromo.cl
# Verificar renovación automática
sudo certbot renew --dry-run
# Cron de renovación (automático con certbot)
cat /etc/cron.d/certbot

Nginx SSL Config (/etc/nginx/conf.d/ssl.conf):

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_stapling on;
ssl_stapling_verify on;
# Headers de seguridad
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;

JWT Configuration:

// ✅ Buenas prácticas
const JWT_CONFIG = {
secret: process.env.JWT_SECRET, // Min 32 caracteres, random
expiresIn: "24h", // Expiración razonable
algorithm: "HS256", // O RS256 para mayor seguridad
};
// Cookie settings (HTTP-only para prevenir XSS)
const COOKIE_CONFIG = {
httpOnly: true, // No accesible via JavaScript
secure: true, // Solo HTTPS
sameSite: "strict", // Previene CSRF
maxAge: 24 * 60 * 60 * 1000, // 24 horas
};

Referencia: ADR-005: Auth Strategy


Validación de contraseñas:

const PASSWORD_REQUIREMENTS = {
minLength: 12,
requireUppercase: true,
requireLowercase: true,
requireNumbers: true,
requireSpecialChars: true,
};
// Hash con bcrypt (cost factor 12)
import bcrypt from "bcrypt";
const SALT_ROUNDS = 12;
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}

Sanitización de inputs:

// ✅ Usar prepared statements (previene SQL injection)
const result = await client.query(
"SELECT * FROM employees WHERE rut = $1",
[rut], // Parámetro sanitizado automáticamente
);
// ❌ NUNCA concatenar directamente
// const result = await client.query(`SELECT * FROM employees WHERE rut = '${rut}'`);
// Validación con Zod
import { z } from "zod";
const EmployeeSchema = z.object({
rut: z.string().regex(/^\d{1,2}\.\d{3}\.\d{3}-[\dkK]$/),
email: z.string().email(),
name: z.string().min(2).max(100),
});
// Sanitizar HTML (si aplica)
import DOMPurify from "dompurify";
const cleanHtml = DOMPurify.sanitize(userInput);

Express middleware:

import rateLimit from "express-rate-limit";
// Limitar requests por IP
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutos
max: 100, // 100 requests por ventana
message: "Too many requests, please try again later.",
standardHeaders: true,
legacyHeaders: false,
});
app.use("/api/", limiter);
// Rate limit más estricto para login
const loginLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hora
max: 5, // 5 intentos fallidos
message: "Too many login attempts.",
});
app.use("/api/command/auth/login", loginLimiter);

import cors from "cors";
const corsOptions = {
origin: [
"https://app.nostromo.cl",
"https://sevastopol.nostromo.cl",
// NO incluir localhost en producción
],
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "Authorization", "X-Tenant-ID"],
maxAge: 86400, // Cache preflight por 24h
};
app.use(cors(corsOptions));

pg_hba.conf:

# Solo conexiones locales
local all postgres peer
host nostromo nostromo_user 127.0.0.1/32 scram-sha-256
host nostromo nostromo_user ::1/128 scram-sha-256
# Rechazar todo lo demás
host all all 0.0.0.0/0 reject

Roles con mínimo privilegio:

-- Rol de aplicación (solo lo necesario)
CREATE ROLE nostromo_app WITH LOGIN PASSWORD '<STRONG>';
GRANT CONNECT ON DATABASE nostromo TO nostromo_app;
GRANT USAGE ON SCHEMA central, parametros TO nostromo_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA central TO nostromo_app;
-- NO dar SUPERUSER ni CREATEDB
-- REVOKE ALL ON DATABASE postgres FROM nostromo_app;

Datos sensibles en columnas:

-- Usar pgcrypto para datos sensibles
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Encriptar RUT (ejemplo)
UPDATE employees
SET rut_encrypted = pgp_sym_encrypt(rut, 'encryption_key')
WHERE rut_encrypted IS NULL;

Archivo .env.production (NO commitear a Git):

# Database
DB_HOST=localhost
DB_PORT=5432
DB_USER=nostromo_user
DB_PASSWORD=<STRONG_RANDOM_PASSWORD>
# JWT
JWT_SECRET=<RANDOM_STRING_MIN_32_CHARS>
# Encryption
ENCRYPTION_KEY=<RANDOM_STRING_32_CHARS>
# External APIs
SII_API_KEY=<API_KEY>

Permisos de archivo:

Terminal window
chmod 600 .env.production
chown deployer:deployer .env.production

Procedimiento de rotación:

  1. Generar nuevo secret
  2. Actualizar en producción
  3. Reiniciar servicios
  4. Revocar secret anterior
  5. Documentar en audit log
Terminal window
# Generar secret random
openssl rand -base64 32
# Actualizar y reiniciar
nano .env.production
pm2 restart orchestrator

Eventos a registrar:

EventoCriticidadRetención
Login successInfo30 días
Login failureWarning90 días
Password changeHigh1 año
Admin actionsHigh1 año
Data exportHigh1 año
Tenant switchInfo30 días

Estructura de log:

interface AuditLogEntry {
timestamp: Date;
userId: string;
tenantId: string;
action: string;
resource: string;
ipAddress: string;
userAgent: string;
status: "success" | "failure";
details?: Record<string, unknown>;
}

No loguear datos sensibles:

// ❌ MAL - Expone password
logger.info(`Login attempt for ${email} with password ${password}`);
// ✅ BIEN - Solo info necesaria
logger.info(`Login attempt for ${email}`, { ip: req.ip });

Rotation de logs:

/etc/logrotate.d/nostromo
/var/log/nostromo/*.log {
daily
rotate 30
compress
delaycompress
notifempty
create 640 deployer deployer
}

  • Variables de entorno configuradas
  • JWT_SECRET es random y >= 32 chars
  • Contraseñas de DB son fuertes
  • SSL/TLS certificados válidos
  • Firewall configurado
  • Backups funcionando
  • Revisar logs de autenticación fallida
  • Verificar certificados SSL no expiran pronto
  • Verificar backups recientes existen
  • Revisar usuarios activos y permisos
  • Actualizar dependencias (npm audit)
  • Revisar CVEs de PostgreSQL/Node.js
  • Test de restore de backup
  • Revisar audit logs de acciones admin

  1. Rotar inmediatamente el secret afectado
  2. Revocar sesiones activas (invalidar JWTs)
  3. Notificar usuarios afectados
  4. Investigar logs para alcance del breach
  5. Documentar en post-mortem
  1. Bloquear IP del atacante
  2. Revisar logs de queries ejecutadas
  3. Verificar integridad de datos
  4. Parchar código vulnerable
  5. Escanear otras posibles vulnerabilidades


FechaVersionCambios
2026-01-181.0Guía inicial creada