resetea.net/api/routes/erase.js
hacklab fa4a38bb9a fix(security): CRLF injection en campos opcionales + XSS en rtbfUrl — VULN: Header injection
Vulnerabilidades corregidas:
- CRLF injection: los campos nickname/phone/address/extra aceptaban \r\n
  que podían manipular el cuerpo del email o, en implementaciones futuras,
  filtrar hacia cabeceras MIME. sanitizeField() elimina todos los chars
  de control (\r \n \t \x00-\x1F) sustituyéndolos por espacio.
- XSS reflejado (latente): rtbfUrl se interpolaba en innerHTML sin
  escapar con esc(). Aunque rtbfLink() devuelve URLs hardcodeadas,
  cualquier refactor futuro que usase datos del usuario habría sido
  explotable. Ahora siempre pasa por esc().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:25:12 +02:00

72 lines
2.5 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const { sendErasureMail, PROVIDER_DATA } = require('../services/mailer');
const ALLOWED_PROVIDERS = new Set(Object.keys(PROVIDER_DATA));
/* Elimina CRLF y chars de control — previene header injection en el cuerpo del email */
function sanitizeField(raw, maxLen) {
return String(raw || '')
.replace(/[\r\n\t\x00-\x1F\x7F]/g, ' ') // CRLF → espacio, no rompe el texto
.replace(/\s{2,}/g, ' ') // colapsa espacios múltiples
.trim()
.slice(0, maxLen);
}
module.exports = async (req, res) => {
try {
const { provider, email,
nickname: rawNick, phone: rawPhone,
address: rawAddr, extra: rawExtra } = req.body;
// Validación mínima
if (!provider || !email) {
return res.status(400).json({ error: 'provider y email son obligatorios' });
}
// Validar proveedor conocido (previene abusos de relay)
if (!ALLOWED_PROVIDERS.has(provider)) {
return res.status(400).json({ error: 'Proveedor no soportado. Usa el formulario oficial.' });
}
// Validación básica de email
if (typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({ error: 'Email inválido' });
}
// Sanitizar campos opcionales: longitud + strip CRLF (header injection)
const nickname = sanitizeField(rawNick, 100);
const phone = sanitizeField(rawPhone, 30);
const address = sanitizeField(rawAddr, 300);
const extra = sanitizeField(rawExtra, 500);
// Hash irreversible para referencia (auditoría sin almacenar PII)
const hash = crypto
.createHash('sha256')
.update(email + (process.env.SALT || 'resetea-default-salt'))
.digest('hex');
const result = await sendErasureMail({ provider, email, nickname, phone, address, extra });
if (result.skipped) {
return res.json({
status: 'use_form',
message: 'Este proveedor no acepta solicitudes por email. Usa su formulario oficial.',
formUrl: result.formUrl,
reference: hash.substring(0, 12),
});
}
// PII fuera de scope aquí — solo el hash queda
res.json({
status: 'ok',
message: 'Solicitud enviada. Guarda el código de referencia.',
reference: hash.substring(0, 12),
});
} catch (e) {
console.error('erase route error:', e.message);
res.status(500).json({ error: 'Error interno. Inténtalo de nuevo o usa el formulario oficial del proveedor.' });
}
};