Ir al contenido

Checklist de fortificación

Ajustes accionables para un despliegue en producción. Cada punto dice qué hacer, por qué y cómo verificarlo. Lee antes el modelo de amenazas para saber qué puntos son barreras reales y cuáles son higiene.

import { wu } from 'wu-framework';
await wu.init({
sandbox: 'strict',
strictFallback: false, // el valor por defecto; sé explícito
overrides: { enabled: false }, // sin redirección por cookie en producción
apps: [
{ name: 'cart', url: 'https://cart.example.com' },
{ name: 'billing', url: 'https://billing.example.com', roles: ['admin'] },
],
});
wu.eventBus.enableStrictMode();
wu.eventBus.registerApp('cart', { permissions: ['cart:*'] });
wu.eventBus.registerApp('billing', { permissions: ['billing:*'] });
wu.setPrincipal({ role: currentUser.role, permissions: currentUser.permissions });
await wu.mount('cart', '#cart');
// Falla de forma ruidosa si el aislamiento no es el que pedimos.
const info = wu.getSandboxInfo('cart');
if (info?.actualMode !== 'strict') {
throw new Error(`cart is running as ${info?.actualMode}, not strict`);
}

  • Todo por TLS. El shell, cada URL de app, cada manifiesto. El contenido mixto bloquea el import del iframe en modo strict y hace fallar el mount.
  • Sirve el JavaScript con un content type de JavaScript. La sonda de rutas de Wu rechaza text/html precisamente para detectar fallbacks de SPA disfrazados de bundles; un host que sirve .js como text/html rompe la resolución.
  • Publica wu.json en producción, con un entry que use un prefijo de carpeta conocido (src/, dist/, public/, build/, assets/, lib/, es/). Esto evita la sonda de 8 peticiones y el críptico error de MIME.
  • Limita CORS al origen del shell, no a *.
Access-Control-Allow-Origin: https://shell.example.com
Vary: Origin

Wu no necesita unsafe-eval desde v2.7. Si lo añadiste por wu, quítalo.

Content-Security-Policy:
default-src 'self';
script-src 'self' https://cart.example.com https://billing.example.com;
style-src 'self' 'unsafe-inline';
connect-src 'self' https://cart.example.com https://billing.example.com;
frame-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';

Notas:

  • Fija los orígenes de las apps explícitamente en script-src y connect-src. Un comodín ahí anula casi todo el valor.
  • Distingue CSR de SSR en style-src. El montaje cliente inyecta elementos <style> en runtime y todavía requiere 'unsafe-inline'. En SSR, los estilos declarativos sí aceptan el nonce generado por wu-framework/server, por lo que esa respuesta no necesita la excepción.
  • script-src 'unsafe-inline' solo hace falta si una app en modo eval sirve bloques <script> inline. Los scripts externos no necesitan excepción.
  • frame-src 'self' cubre los iframes de sandbox ocultos del mismo origen de wu. Añade tu host de sandbox de origen cruzado si usas uno.
  • No añadas 'unsafe-eval'. Si algo lo exige, no es wu.
import {
createNonce, applySecurityHeaders,
renderApps, renderStateScript,
} from 'wu-framework/server';
const nonce = createNonce(); // uno nuevo por respuesta
const { html, state } = await renderApps(apps, { nonce });
applySecurityHeaders(res, { nonce });
res.end(template
.replace('<!--apps-->', html)
.replace('<!--state-->', renderStateScript(state, { nonce }))
);

El mismo nonce debe llegar a la CSP, los <style> de los shadow roots y el <script> de estado. Los valores malformados lanzan antes de interpolarse. No reutilices el nonce ni caches el HTML que lo contiene.


  • Deja strictFallback en false. Es el valor por defecto desde v2.7. Activarlo deja que un fallo de CORS degrade una app que aislaste deliberadamente.
  • Verifica actualMode después de cada mount de cualquier cosa que no controles del todo, en producción, no solo en los tests.
  • No trates strict como una frontera contra código hostil. Para código no confiable usa un iframe de origen cruzado con sandbox="allow-scripts" y sin allow-same-origin, más un puente postMessage validado. Mira Apps de terceros.
const REQUIRED = { cart: 'strict', billing: 'strict', legacy: 'eval' };
for (const [name, expected] of Object.entries(REQUIRED)) {
const info = wu.getSandboxInfo(name);
if (!info) continue; // aún no montada
if (info.actualMode !== expected) {
report(`${name}: expected ${expected}, got ${info.actualMode}`);
}
}

  • Activa strictMode. Es automático cuando process.env.NODE_ENV === 'production', una constante de tiempo de build. Si tu bundler no la reemplaza, llama a wu.eventBus.enableStrictMode() explícitamente.
  • Restringe los permisos por app. registerApp usa ['*'] por defecto, lo que deja a cualquier app registrada emitir user:logout.
wu.eventBus.registerApp('cart', { permissions: ['cart:*'] });
  • Comprueba el valor de retorno de emit() donde importe. Una emisión rechazada devuelve false; no lanza error.
  • No pongas secretos en los payloads de los eventos. Cualquier app de la página puede suscribirse con wu.on('*'), y el historial guarda los últimos 100 eventos.
  • Recuerda que los tokens son etiquetas, no capacidades. Cualquier código en tu ventana puede leerlos.

  • No pongas credenciales, tokens de sesión ni datos personales en el store compartido. Es legible por cada app, y store.sync() los difundirá tan contento entre pestañas o por un WebSocket.
  • Valida antes de escribir rutas construidas con entrada del usuario. La guarda contra contaminación de prototipos lanza error ante __proto__, constructor y prototype, pero no evitará que set('a.' + userInput, v) pisotee estado no relacionado.
  • Si usas store.sync(), trata el transporte como hostil. Las operaciones remotas pasan por la guarda de contaminación, pero todo lo demás que llegue se convierte en estado. Termina la sincronización por WebSocket con autenticación en tu servidor.

  • Declara roles en las apps privilegiadas, en la configuración de wu.init() o en el wu.json de la app.
  • Establece un principal al iniciar sesión y límpialo al cerrarla.
  • Aplica en el servidor. El RBAC del cliente decide qué renderizar. Que el servidor decida qué servir es el control de verdad.
wu.setPrincipal({ role: 'admin', permissions: ['mount:*'] });
wu.setPrincipal(null); // al cerrar sesión
if (wu.can('billing')) await wu.mount('billing', '#billing');

Gestiona la denegación para la UX:

window.addEventListener('wu:access:denied', (e) => {
showAccessDenied(e.detail.appName, e.detail.required);
});

  • Desactívalos explícitamente en producción: overrides: { enabled: false }. La autodetección ya trata cualquier hostname no local como producción, pero un false explícito no lo puede deshacer un cambio de configuración que no notaste.
  • Nunca publiques un allowedDomains vacío con los overrides activados. Vacío significa permitir todo, lo que convierte una cookie en un cargador de scripts arbitrario.
  • Nunca desactives showIndicator. El banner es el control anti-phishing.
  • Considera exigir además una sesión de staff, para que una cookie por sí sola no baste:
overrides: {
enabled: currentUser?.isStaff === true,
allowedDomains: ['*.preview.example.com'],
showIndicator: true,
}

  • Genera wu.json desde el build, para que entry siempre coincida con el bundle con hash. Los hashes mantenidos a mano se desincronizan.
  • Trata el compromiso del CDN de una app como un compromiso de tu página. Wu no verifica SRI ni hashes.
  • Fija los orígenes de las apps en la CSP para que un secuestro de DNS no pueda sustituir el host.
  • Mantén los manifiestos pequeños y aburridos. normalize() descarta los campos extra de todos modos.

  • Los hooks son fail-closed desde v2.7 (fallan cerrando: cancelan en vez de continuar): un middleware que lanza antes de next() cancela la operación. Eso es lo que quieres en un guardián.
  • Marca los hooks de instrumentación con { failOpen: true } para que un fallo de métricas no bloquee un mount.
  • Audita los plugins. Un plugin corre en el shell, con acceso completo. Es código del shell, con el modelo de amenazas del shell.
wu.hooks.use('beforeMount', async (ctx, next) => {
if (!(await serverSaysYes(ctx.appName))) return; // sin next() = cancelar
await next();
}, { priority: 100 });
wu.hooks.use('afterMount', reportMetrics, { failOpen: true });

Si usas wu.ai:

  • Nunca pongas una clave de API en el navegador. Apunta el proveedor a tu propio endpoint proxy, que guarda la credencial en el servidor.
  • Mantén activada la confirmación de herramientas para cualquier cosa con efectos secundarios.
  • Trata la salida del modelo como entrada no confiable. Una llamada a una herramienta es una petición, no una autorización.

Mira Capas de seguridad de IA.


Convierte la checklist en una aserción que corra en cada despliegue:

e2e/security.spec.js
test('production hardening', async ({ page }) => {
await page.goto('/');
const state = await page.evaluate(() => ({
apps: wu.inspect().apps.map((a) => ({ name: a.name, sandbox: a.sandbox })),
overrides: wu.overrides.getStats(),
eventStats: wu.eventBus.getStats(),
}));
for (const app of state.apps) {
expect(app.sandbox.actualMode).toBe(app.sandbox.requestedMode);
}
expect(state.overrides.enabled).toBe(false);
});

Y comprueba las cabeceras en sí:

test('CSP has no unsafe-eval', async ({ request }) => {
const res = await request.get('/');
const csp = res.headers()['content-security-policy'] ?? '';
expect(csp).not.toContain('unsafe-eval');
expect(csp).toContain("object-src 'none'");
});

Si solo vas a hacer cinco cosas:

  1. strictFallback: false (el valor por defecto) y verifica getSandboxInfo() después del mount.
  2. Sin unsafe-eval en tu CSP, y fija los orígenes de las apps.
  3. eventBus.strictMode activado, con permisos estrechos por app.
  4. overrides: { enabled: false } en producción.
  5. Aplica la autorización en el servidor. Todo lo del cliente, RBAC incluido, es una decisión de renderizado.

Y acepta la restricción que da forma a todo lo demás: para código genuinamente no confiable, ningún modo de sandbox de wu es una frontera. Usa un iframe de origen cruzado.