Riesgos de seguridad en proyectos Vue.js y cómo cerrarlos
Cinco vulnerabilidades habituales en aplicaciones Vue, con la solución de cada una.
Desplegar una aplicación Vue nueva suele venir acompañado de cierta inquietud. ¿Se me habrá escapado algo que pueda exponer datos de los usuarios o comprometer la aplicación?
Dedicamos horas a la interfaz, al rendimiento y a las funcionalidades, y la seguridad acaba casi siempre al final de la cola. El razonamiento suele ser "es solo el frontend, qué puede pasar". Pues bastante. Vue en sí es seguro, pero cómo lo usamos y qué traemos a su alrededor es de donde salen las vulnerabilidades.
Abajo hay cinco riesgos frecuentes en proyectos Vue y qué hacer con cada uno.
Por qué importa la seguridad en el frontend
Se puede argumentar que la seguridad es sobre todo cosa del backend, ya que el servidor gestiona la autenticación, el acceso a la base de datos y todo lo sensible. Es cierto que la seguridad del backend es crítica. Pero el frontend es la superficie con la que el usuario trata directamente, y cuando se ve comprometido los atacantes pueden robar credenciales mediante phishing o registradores de teclas, manipular datos como los precios de una tienda, provocar acciones que el usuario nunca quiso si tu API valida mal, o directamente desfigurar el sitio.
1. Cross-site scripting
El XSS ocurre cuando un atacante inyecta un script, normalmente JavaScript, que acaba ejecutándose en el navegador de la víctima. Desde ahí puede llevarse cookies y tokens de sesión o reescribir la página.
Suele pasar cuando la aplicación vuelca la entrada del usuario directamente en el DOM sin sanearla. Imagina una sección de comentarios donde alguien envía esto:
<p>This is a great article!</p>
Inofensivo. Ahora esto:
<p>
This is a great article!
<script>
alert("You've been XSSed!");
</script>
</p>
Si tu componente enlaza esa entrada sin sanear con v-html, el script se ejecuta para todo el que vea el comentario.
La regla a recordar: nunca uses v-html con contenido de usuario en el que no confías. Con la interpolación estándar, Vue escapa el HTML por defecto.
<!-- Safe: Vue escapes the HTML -->
<template>
<div>
<p>{{ userComment }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue';
const userComment = ref('<script>alert("XSS attempt!");</script>');
</script>
Eso muestra la etiqueta script como texto plano en lugar de como código ejecutable.
Si de verdad necesitas renderizar HTML dinámico, por ejemplo procedente de un editor de texto enriquecido, sanea la entrada primero en el backend con una biblioteca como DOMPurify antes de guardarla, y vuelve a sanear en el frontend como segunda capa antes de pasar nada a v-html.
<!-- Use v-html ONLY with trusted, sanitized content -->
<template>
<div v-html="sanitizedUserContent"></div>
</template>
<script setup>
import { ref, computed } from 'vue';
// In a real app, you'd get this from a backend API after sanitization
const rawUserContent = ref('<p>Hello <b>World</b>! <script>alert("No!");</script></p>');
// For demonstration, let's pretend this is sanitized
// In a real app, you'd use a proper library like DOMPurify
const sanitizedUserContent = computed(() => {
// This is a VERY simplistic and insecure "sanitization" for example purposes.
// DO NOT use this in production. Use a library like DOMPurify!
return rawUserContent.value.replace(/<script.*?>.*?<\/script>/g, '');
});
</script>
2. Cross-site request forgery
El CSRF engaña a un usuario con la sesión iniciada para que realice una acción que no pretendía. El atacante consigue que el navegador del usuario envíe una petición a tu servidor, aprovechando que ya está autenticado.
Imagina que tienes la sesión abierta en tu banco en una pestaña y abres un sitio malicioso en otra. Ese sitio incluye un formulario oculto o una etiqueta de imagen que apunta al endpoint de transferencias del banco:
<!-- Malicious website's hidden content -->
<img
src="https://yourbank.com/transfer?to=attacker&amount=1000000"
style="display:none;"
/>
Cuando el navegador carga esa imagen envía una petición GET junto con tus cookies de sesión. Si la API no tiene protección CSRF, puede tomarla por legítima.
La defensa habitual es un token CSRF. El servidor genera un token impredecible y lo envía al cliente, en una cookie, una etiqueta meta o la carga inicial. Cada petición que cambia estado desde tu aplicación Vue, es decir POST, PUT y DELETE, incluye ese token en las cabeceras o en el cuerpo. El backend rechaza todo aquello cuyo token no coincida.
En la práctica configuras tu cliente HTTP para que lo añada automáticamente.
// Example using Axios (adjust based on how your backend provides the token)
import axios from "axios";
// 1. Get the CSRF token (e.g., from a meta tag in your HTML, or a cookie)
function getCsrfToken() {
// This is an example, your backend might provide it differently
const tokenElement = document.querySelector('meta[name="csrf-token"]');
return tokenElement ? tokenElement.getAttribute("content") : "";
}
const csrfToken = getCsrfToken();
// 2. Configure Axios to include the token in requests
if (csrfToken) {
axios.defaults.headers.common["X-CSRF-TOKEN"] = csrfToken;
}
// Now, when you make a POST, PUT, or DELETE request, the token will be included
axios.post("/api/transfer-money", {
recipient: "myfriend",
amount: 50,
});
Las peticiones GET normalmente no necesitan token porque se supone que son idempotentes. Si tu API cambia estado en un GET, eso es un problema aparte que conviene arreglar.
3. Dependencias con vulnerabilidades
Tu carpeta node_modules es una cantidad considerable de código ajeno. La mayor parte está bien, y basta un paquete vulnerable para abrir una puerta. O bien un paquete del que dependes, o algo en su propio árbol de dependencias, tiene un fallo conocido; o se compromete la cuenta de quien lo mantiene y llega código malicioso en una actualización.
Audita con regularidad. Tanto npm audit como yarn audit comprueban los paquetes instalados frente a vulnerabilidades conocidas y suelen proponer soluciones.
# npm audit report
lodash <4.17.21
Severity: high
Prototype Pollution - https://npmjs.com/advisories/1526
No fix available
axios <0.21.1
Severity: moderate
Arbitrary File Write via Decompress - https://npmjs.com/advisories/1608
Fix available: `npm install axios@^0.21.1`
```
Antes de añadir una dependencia, mira si tiene mantenimiento activo, cuánta gente la usa, si hay incidencias de seguridad abiertas y cuándo se actualizó por última vez.
Sube al repositorio tu `package-lock.json` o `yarn.lock`. Esos archivos fijan las versiones exactas, de modo que tu equipo y tu pipeline de CI compilan sobre el mismo conjunto probado.
## 4. Secretos que acaban en el bundle
Esto suena evidente y sigue ocurriendo con frecuencia. Las claves de API escritas directamente en componentes o archivos JavaScript acaban en el bundle del cliente, lo que las vuelve públicas. Los mensajes de error detallados filtran información sobre el backend. Variables de entorno que nunca debieron llegar al cliente terminan empaquetadas.
Nunca escribas secretos en el código del frontend. Si el frontend necesita una API que requiere clave, ponte en medio: tu aplicación Vue llama a tu backend y tu backend llama a la API de terceros con la clave. Es decir, en lugar de hacer `axios.get('https://api.thirdparty.com/data?apiKey=YOUR_SECRET_KEY')` desde el navegador, la petición pasa por un servidor que controlas.
Las variables de entorno piden el mismo cuidado. Vue CLI expone al bundle del cliente todo lo que lleve el prefijo `VUE_APP_`, y Vite hace lo mismo con `VITE_`. Ahí van los endpoints de API, no las claves. Mantén los secretos de producción fuera del control de versiones y gestiónalos desde la configuración de tu proveedor de alojamiento.
Trata los errores para que tampoco filtren nada. Un error del backend en crudo puede revelar el esquema de la base de datos o rutas del servidor, así que muestra al usuario algo genérico y registra el detalle en el servidor.
```html
<template>
<div>
<button @click="fetchData">Fetch Data</button>
<p v-if="error">{{ errorMessage }}</p>
<div v-if="data">{{ data }}</div>
</div>
</template>
<script setup>
import { ref } from "vue";
import axios from "axios";
const data = ref(null);
const error = ref(false);
const errorMessage = ref("");
const fetchData = async () => {
try {
const response = await axios.get("/api/sensitive-data");
data.value = response.data;
error.value = false;
} catch (err) {
error.value = true;
// Don't show the raw error to the user!
// console.error(err.response.data); // Bad practice
errorMessage.value =
"Oops! Something went wrong. Please try again later.";
// Log the detailed error to a server-side logging service
}
};
</script>
Da por hecho que todo lo que hay en tu frontend compilado es público, porque lo es.
5. Autenticación y autorización en el cliente
Esto no es exclusivo de Vue, pero aparece constantemente. Aplicar la autorización solo en el frontend es poner un portero en la puerta principal y dejar la trasera abierta.
Se manifiesta al ocultar partes de la interfaz según el rol, al asumir que el backend ya comprobará los permisos, o al guardar una bandera isAdmin en Pinia o Vuex y tratarla como si fuera vinculante.
Todo endpoint que requiera autenticación debe verificar el token o la sesión, y todo endpoint que requiera un permiso debe comprobarlo antes de hacer nada. El frontend está para la experiencia de uso: ocultar un botón que alguien no puede usar es razonable, pero no es seguridad, y cualquiera puede saltarse tu JavaScript.
Guarda los tokens con cuidado. Las cookies HttpOnly que establece el backend mantienen los tokens fuera del alcance del JavaScript del cliente, lo que limita el daño de un fallo de XSS. Si usas localStorage, ten claro que estás confiando por completo en que no haya XSS en ninguna parte.
// BAD: Relying on client-side state for authorization
// In a real app, an attacker could manipulate `isAdmin` in their browser.
const user = {
name: "Alice",
isAdmin: false, // Imagine an attacker changing this to true in the console
};
if (user.isAdmin) {
// Show admin button - this is for UX, not security!
}
// GOOD: Backend always validates permissions
async function deleteUser(userId) {
try {
// Frontend sends the request
await axios.delete(`/api/users/${userId}`);
alert("User deleted!");
} catch (error) {
// Backend rejects if user is not authorized
if (error.response && error.response.status === 403) {
alert("You are not authorized to perform this action.");
} else {
alert("An error occurred.");
}
}
}
En resumen
Vue te da una base segura y el resto queda de nuestro lado. Sanea la entrada del usuario, sobre todo cualquier cosa que se acerque a v-html. Implementa tokens CSRF junto con tu backend. Audita y actualiza las dependencias. Mantén los secretos fuera del bundle. Y valida la autenticación y la autorización en el servidor, siempre.