wandres.dev
REDUX DEVTOOLS · time travel real

El coste y la seguridad: no dejarlas abiertas en producción

Toda la observabilidad de este nivel tiene una factura en memoria, en tiempo de ejecución y sobre todo en exposición. Un historial completo son cientos de fotografías del estado retenidas mientras la pestaña viva, y cada una puede contener el token de sesión, el correo del usuario o el detalle de una factura. Esta lección mide primero el coste real de dejar la instrumentación encendida, después describe con precisión qué queda expuesto y ante quién, y termina construyendo la práctica correcta: apagar de verdad en producción, redactar los campos sensibles en el origen antes de que salgan del dispositivo, y decidir por política qué clase de datos merecen vivir en el store y cuáles no deberían haber entrado nunca.

⏱ 18 min

Las cuatro lecciones anteriores han sido un elogio razonado de la observabilidad: el diff, el historial, el salto en el tiempo, la sesión reproducible. Esta es la contrapartida, y sería deshonesto cerrar el nivel sin ella. Todo lo que hace valiosa esta herramienta —que conserve el pasado en lugar de destruirlo, que serialice cada intención con sus datos, que permita exportar una sesión entera a un fichero— es exactamente lo que la vuelve peligrosa cuando se queda encendida donde no debe. Un historial es un archivo, y un archivo de una aplicación real contiene, casi siempre, más información personal de la que su equipo recuerda haber puesto ahí.

🎯 Al terminar esta lección sabrás
  • Medir el coste real de la instrumentación en memoria y en tiempo de ejecución.
  • Describir con precisión qué queda expuesto y ante quién cuando el historial vive en el cliente.
  • Apagar la integración de verdad en producción y verificar que no queda superficie residual.
  • Redactar los campos sensibles en el origen y establecer una política sobre qué entra en el store.

El coste antes que el riesgo

Empecemos por lo mensurable. Guardar el historial significa retener en memoria el estado inicial y una entrada por cada acción despachada, con su payload y, según la configuración, una fotografía del estado resultante. En una aplicación pequeña eso es irrelevante; en un panel con listados grandes y sesiones de horas, el crecimiento es lineal y se nota. El síntoma habitual no es que la aplicación se caiga, sino que la propia extensión se vuelve lenta y el navegador dedica una fracción visible de su tiempo a serializar objetos que nadie va a mirar.

El coste en tiempo de ejecución tiene tres fuentes. La primera es la serialización de cada acción y cada estado para enviarlos a la extensión, proporcional al tamaño del árbol. La segunda es el cálculo del diff, que compara dos árboles completos. La tercera, y con diferencia la más cara, es la captura de la pila de llamadas cuando trace está activo, porque generar una traza en cada dispatch cuesta órdenes de magnitud más que despacharla. Ninguna de las tres es problema en desarrollo; las tres serían inaceptables en producción, y esa es la primera razón, la menos grave, para no dejarlas encendidas.

Hay un cuarto coste, indirecto y curiosamente positivo, que conviene nombrar: la instrumentación castiga de forma proporcional al tamaño del estado y al número de acciones, de modo que una aplicación cuyo store va lento con las DevTools abiertas te está informando de algo aunque no lo hayas preguntado. Casi siempre significa que estás guardando en el estado más de lo que deberías o despachando con más frecuencia de la necesaria. La extensión funciona así como un detector accidental de un mal diseño que en producción no se notaría hasta mucho más tarde.

💡
Acota el historial en desarrollo, no solo en producción

Si tu extensión va lenta, la respuesta correcta rara vez es apagarla. Baja maxAge a unas decenas de acciones, excluye con actionsDenylist el ruido de alta frecuencia y activa trace únicamente durante la sesión en que lo necesites. Un historial de treinta acciones bien elegidas es más útil para diagnosticar que uno de tres mil, porque el problema real del monitor nunca fue la falta de datos sino su exceso. Ajustar estos tres parámetros convierte una herramienta que estorba en una que responde al instante.

Qué queda expuesto y ante quién

Ahora el riesgo, que es de otra categoría. Conviene ser preciso sobre el modelo de amenaza, porque el error opuesto —tratar esto como una catástrofe inminente— lleva a conclusiones igual de malas. El estado de una aplicación cliente vive, por definición, en la máquina del usuario, y quien controla esa máquina siempre ha podido examinarlo con las herramientas del navegador. La integración de las DevTools no crea ese acceso: lo hace cómodo, completo e histórico, y son esos tres adjetivos los que cambian el cálculo. Que algo fuera técnicamente accesible no significa que fuera práctico, y la diferencia entre lo accesible y lo práctico es, en seguridad, casi toda la diferencia.

flowchart TD
S[estado y acciones] --> H[historial retenido en memoria]
H --> A[cualquiera con acceso al dispositivo]
H --> B[capturas de pantalla y grabaciones de soporte]
H --> C[exportacion a fichero JSON]
C --> D[el fichero viaja en un ticket o un chat]
D --> E[queda almacenado fuera de tu control]
style E fill:#f38ba8,color:#11111b
style A fill:#fab387,color:#11111b

Hay además una asimetría temporal que agrava el problema. Una petición de red expone un dato durante el instante en que viaja; el store lo expone mientras la pestaña viva; el historial lo expone durante toda la sesión aunque la pantalla que lo mostraba se cerrara hace una hora. Un token que se usó al arrancar y que ya nadie necesita sigue estando en la entrada número tres del monitor, y seguirá estando en cualquier exportación que se haga al final del día. La observabilidad convierte lo efímero en permanente, y esa conversión es precisamente lo que había que evaluar.

El riesgo grande no es el atacante remoto: es el flujo cotidiano por el que ese historial acaba en sitios que nadie eligió. Una persona de soporte pide una captura del monitor y en ella aparece el token de sesión. Un usuario exporta la sesión para adjuntarla a un ticket y ese fichero, con su correo, su dirección y el detalle de sus pedidos, queda archivado en un sistema de incidencias con permisos amplios. Un desarrollador reproduce ese fichero en su portátil y la copia se queda ahí durante meses. Ninguno de esos pasos es un ataque; todos son personas haciendo su trabajo, y el resultado es información personal circulando fuera del perímetro donde estaba protegida.

🔑

Credenciales y tokens

Lo primero que hay que sacar del estado. Un token en el store aparece en el monitor, en las capturas y en cada exportación.

🧍

Datos personales

Correos, direcciones, teléfonos, identificadores fiscales. Suelen entrar sin querer con la respuesta completa del servidor.

💳

Datos financieros o de salud

Sujetos a normativa. Su presencia en un fichero exportado convierte un ticket de soporte en un incidente notificable.

📤

Ficheros exportados

El vector más olvidado: el historial deja tu perímetro y sobrevive indefinidamente en sistemas que no controlas.

Apagar de verdad y redactar en el origen

La práctica correcta tiene dos mitades, y solo la primera es la que todo el mundo conoce. La primera es apagar la integración en producción, que en Redux Toolkit ocurre por defecto pero conviene verificar en lugar de suponer, sobre todo si alguien tocó el parámetro devTools o si la variable de entorno no es la que crees en el proceso de compilación. La verificación es directa: abre la aplicación desplegada con la extensión instalada y comprueba que no aparece ninguna instancia.

Conviene añadir a esa verificación manual una automática, porque los apagados se rompen en silencio. Los tres puntos donde falla en la práctica son siempre los mismos: una variable de entorno que en el empaquetador no vale lo que se supone, un enhancer remoto añadido para depurar un dispositivo y nunca retirado, y un entorno de preproducción que comparte configuración con desarrollo pero base de datos con producción. El tercero es el más peligroso de los tres porque combina instrumentación encendida con datos reales, y es también el que ninguna revisión de código detecta, porque el código es correcto y lo que está mal es dónde corre.

La segunda mitad, que casi nadie hace, es asumir que el apagado no cubre todos los casos y redactar los campos sensibles en el origen. Hay razones sólidas: los entornos de preproducción con datos reales suelen tener la instrumentación encendida; el registro casero de la lección tercera envía acciones aunque la extensión esté apagada; y las capturas de pantalla que se piden en soporte se toman en cualquier entorno. Las DevTools admiten funciones de saneado que se aplican antes de enviar cada acción y cada estado.

const REDACTADO = '[redactado]'

const sanearAccion = (accion) =>
  accion.type === 'sesion/entrar'
    ? { ...accion, payload: { ...accion.payload, token: REDACTADO } }
    : accion

const sanearEstado = (estado) => ({
  ...estado,
  sesion: { ...estado.sesion, token: REDACTADO },
  perfil: { ...estado.perfil, email: REDACTADO, telefono: REDACTADO },
})

export const store = configureStore({
  reducer: raiz,
  devTools: import.meta.env.DEV && {
    name: 'panel',
    actionSanitizer: sanearAccion,
    stateSanitizer: sanearEstado,
  },
})

Ese saneado tiene una limitación importante que hay que enunciar: solo afecta a lo que se envía a la extensión, no a lo que hay en memoria. El token sigue en el store y sigue siendo accesible por otras vías. Por eso el saneado es una mitigación, no una solución, y la solución de fondo es la política de la sección siguiente: los secretos no deberían estar en el estado.

Conviene además fijar el criterio de qué se redacta y cómo, porque redactar de más inutiliza la herramienta. El principio correcto es sustituir el valor conservando la forma: un correo se convierte en una marca fija pero no desaparece, de modo que el diff sigue mostrando que ese campo cambió y el diagnóstico no se pierde. Lo que nunca hay que hacer es eliminar la clave entera, porque entonces el historial miente por omisión y acabas depurando una representación que no se corresponde con el estado real.

⚠️
El fichero exportado no está saneado por costumbre

Es el fallo más frecuente de esta práctica. Un equipo configura los sanitizadores, comprueba que el monitor muestra el token redactado y da el asunto por cerrado. Pero si el saneado se aplica solo a la vista y no a lo que se serializa, o si existe un registro propio que exporta el anillo de acciones sin pasar por esa función, el fichero que el usuario adjunta al ticket sale con todo dentro. Verifica siempre abriendo un fichero exportado real en un editor de texto y buscando en él una credencial y un correo conocidos. Es una comprobación de treinta segundos y es la única que de verdad prueba algo.

📝
Lo que no debería haber entrado en el store

La higiene más eficaz es anterior a toda esta configuración y consiste en decidir qué clase de dato merece vivir en el estado. Un token de sesión rara vez lo merece: su sitio natural es una cookie que el navegador gestiona y que el código no lee. Una respuesta completa del servidor tampoco: guardar solo los campos que la interfaz usa reduce a la vez el peso del historial y la superficie expuesta. Y los datos regulados que solo se muestran en una pantalla concreta no necesitan permanecer en un store global durante toda la sesión. Cada campo que no entra es un campo que no hay que redactar, que no infla el diff y que no aparece en ninguna captura.

Convertir la política en una prueba

Una política que solo vive en la cabeza del equipo se erosiona con el primer campo nuevo que alguien añade a una respuesta del servidor. La forma de que aguante es convertirla en algo que falle solo, y aquí la estructura del estado juega a favor: como el árbol es un dato plano y serializable, se puede recorrer y comprobar en una prueba.

const PROHIBIDAS = ['token', 'password', 'iban', 'dni', 'email']

function clavesSensibles(nodo: unknown, ruta = ''): string[] {
  if (nodo === null || typeof nodo !== 'object') return []
  return Object.entries(nodo).flatMap(([k, v]) =>
    PROHIBIDAS.includes(k.toLowerCase())
      ? [ruta + '.' + k]
      : clavesSensibles(v, ruta + '.' + k),
  )
}

test('el estado no contiene campos prohibidos', () => {
  expect(clavesSensibles(store.getState())).toEqual([])
})

Esa prueba es tosca a propósito y por eso funciona. No pretende ser un análisis de privacidad completo, sino una alarma barata que salta el día en que alguien guarde en el store la respuesta entera de un extremo nuevo con un campo email dentro. Aplicada sobre el estado que resulta de replegar una sesión de prueba realista, cubre más superficie de la que parece, y su verdadero valor no es técnico sino social: obliga a que cada excepción se discuta y se anote en vez de colarse en silencio.

💡
La misma comprobación sobre lo que sale, no solo sobre lo que hay

Ejecuta esa función también sobre un fichero exportado y sobre el anillo de acciones que tu informador de errores adjunta, no solo sobre el estado en memoria. Son tres superficies distintas y es habitual que una esté saneada y las otras dos no, porque cada una pasa por un camino diferente. Si tienes que elegir una sola, elige la que sale del dispositivo: el estado en memoria pertenece al usuario y el fichero exportado acaba perteneciendo a tu organización, que es donde la responsabilidad cambia de dueño.

La observabilidad es un archivo, y todo archivo es una responsabilidad

Este nivel entero se ha construido sobre una idea que ahora conviene mirar por su otra cara: Redux es valioso porque no destruye el pasado. Mutar es sobrescribir, sobrescribir es olvidar, y la decisión de conservar cada intención con sus datos es lo que hizo posible el diff, el salto en el tiempo y la sesión reproducible. Pero conservar el pasado es exactamente lo que hace un archivo, y no existe archivo sin custodia. La misma propiedad que te permite reconstruir lo que hizo el usuario permite a cualquiera que alcance ese historial saber lo que hizo el usuario, con su nombre, sus datos y su orden temporal, y con una comodidad que ninguna otra arquitectura ofrece por accidente. Quien entiende esto deja de tratar la seguridad de las DevTools como una casilla de despliegue —apagadas en producción, listo— y empieza a tratarla como lo que es, una consecuencia directa del modelo que eligió: si tu sistema fue diseñado para recordar, tienes que diseñar también qué se recuerda y quién puede leerlo. De ahí salen las tres decisiones que de verdad importan, y ninguna es una opción de configuración. Primero, qué entra en el estado, porque lo que no entró no hay que protegerlo. Segundo, qué sale del dispositivo, porque el fichero exportado es el único punto donde el historial abandona el perímetro del usuario y sobrevive fuera de tu control. Y tercero, cuánto se retiene, porque un historial acotado es a la vez más legible y menos comprometedor. La madurez profesional en este dominio no consiste en saber configurar la extensión, sino en haber interiorizado que la capacidad de reconstruir el pasado de una persona es un poder que se ejerce sobre alguien, y que aceptarlo sin diseñar sus límites es exactamente la clase de descuido que las auditorías descubren después.

⚔️ Audita la exposición real de tu historial
  1. Abre tu aplicación desplegada en producción con la extensión instalada y confirma que no aparece ninguna instancia. Si aparece, localiza qué variable de entorno falló en la compilación.
  2. En desarrollo, recorre el estado completo y haz un inventario de todo campo que sea credencial, dato personal o dato regulado. Anota cuántos de ellos recordabas haber puesto ahí.
  3. Para cada campo del inventario, decide si debe salir del store o solo redactarse. Saca del estado todo lo que pueda vivir en una cookie o cargarse solo donde se muestra.
  4. Configura actionSanitizer y stateSanitizer para lo que quede y comprueba en el monitor que los valores aparecen redactados.
  5. Exporta un historial real, ábrelo en un editor de texto y busca dentro un token y un correo conocidos. Si los encuentras, tu saneado solo afecta a la vista y hay que aplicarlo también a la serialización.
  6. Mide el coste: con y sin trace, y con maxAge alto y bajo, compara el consumo de memoria de la pestaña tras una sesión larga. Fija los valores que hagan la herramienta útil sin volverla un lastre.