feat: version 3.0.0 -- Correcciones de auditoria de seguridad, accesibilidad y redundancia

Permisos (superadmin, viewer, busqueda, tareas recurrentes), bootstrap atomico,
revocacion de JWT por version de sesion (028), control optimista de edicion (029),
logo sin SVG + CSP, ingesta endurecida, correos escapados, borradores, paginacion
de proyectos, accesibilidad de dialogos/controles e i18n.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-23 11:36:02 -05:00
parent 8ed1d63505
commit e65111a7f4
112 changed files with 2285 additions and 651 deletions
+11
View File
@@ -0,0 +1,11 @@
import { HttpError } from './http.js';
export function requireEditVersion(value) {
if (!Number.isSafeInteger(value) || value < 1) throw new HttpError(428, 'Falta la version de edicion');
return value;
}
export function requireUpdated(row) {
if (!row) throw new HttpError(409, 'El registro cambio en el servidor. Recarga antes de guardar');
return row;
}
+20
View File
@@ -0,0 +1,20 @@
export function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
}
export function invitationEmail(url) {
const safeUrl = escapeHtml(url);
return `<p>Te agregaron como miembro de un proyecto en project.linkedcorp.net.</p>
<p>Completa tu registro para acceder (elige tu nombre y una clave):</p>
<p><a href="${safeUrl}">${safeUrl}</a></p><p>Este enlace vence en 7 dias.</p>`;
}
export function verificationEmail(name, url) {
const safeUrl = escapeHtml(url);
return `<p>Hola ${escapeHtml(name)},</p><p>Confirma tu correo para continuar tu registro:</p><p><a href="${safeUrl}">${safeUrl}</a></p><p>Este enlace vence en 24 horas.</p>`;
}
export function recoveryEmail(name, url) {
const safeUrl = escapeHtml(url);
return `<p>Hola ${escapeHtml(name)},</p><p>Para elegir una nueva clave entra a este enlace (vence en 1 hora):</p><p><a href="${safeUrl}">${safeUrl}</a></p><p>Si no pediste esto, ignora el correo.</p>`;
}
+13 -13
View File
@@ -4,24 +4,24 @@ import { getSmtpConfigInternal } from '../modules/settings/service.js';
// Degradacion a modo dev cuando el SMTP no esta configurado (regla del orquestador): en vez de
// fallar, se loguea el contenido/enlace y el flujo que llamo a esto sigue funcionando.
export async function sendMail({ to, subject, html }) {
const config = await getSmtpConfigInternal();
try {
const config = await getSmtpConfigInternal();
if (!config) {
console.log(`[mail:dev] to=${to} subject="${subject}"\n${html}`);
return { sent: false, dev: true };
}
if (!config) {
console.log(`[mail:dev] to=${to} subject="${subject}"\n${html}`);
return { sent: false, dev: true };
}
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: false,
requireTLS: true,
auth: { user: config.user, pass: config.pass },
});
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: false,
requireTLS: true,
auth: { user: config.user, pass: config.pass },
});
// Nunca dejar que un fallo de envio (SMTP mal configurado, host caido) tumbe al que llama —
// critico para forgot-password, que siempre debe responder generico sin importar el resultado.
try {
await transporter.sendMail({ from: config.from, to, subject, html });
return { sent: true, dev: false };
} catch (err) {
+12
View File
@@ -0,0 +1,12 @@
export function redactUrl(url = '') {
const value = String(url);
try {
const parsed = new URL(value, 'http://local.invalid');
for (const key of parsed.searchParams.keys()) {
if (key.toLowerCase() === 'token') parsed.searchParams.set(key, '[REDACTED]');
}
return parsed.pathname + parsed.search + parsed.hash;
} catch {
return value.replace(/([?&]token=)[^&#]*/gi, '$1[REDACTED]');
}
}