v-html="comment.body" renderiza HTML arbitrario del servidor sin ninguna sanitización. Cualquier payload <script> o evento inline que llegue en comment.body se ejecutará en el navegador del usuario.dangerouslySetInnerHTML de React. Un atacante con acceso a la API (SSRF, inyección en BBDD, comentario malicioso) puede robar tokens de sesión, redirigir a usuarios o instalar keyloggers persistentes.dompurify y sanitizar en el mismo call-site. O, si no se necesita HTML, usar {{ comment.body }} (interpolación segura).- <span v-html="comment.body"></span> + <span v-html="sanitize(comment.body)"></span> // en script setup: // import DOMPurify from 'dompurify' // const sanitize = (html: string) => DOMPurify.sanitize(html, { ALLOWED_TAGS: ['b','i','em','strong'] })
:src="comment.authorAvatar" pasa el valor directamente al atributo sin validar el esquema. Una URL del tipo javascript:alert(1) o data:text/html,<script>... puede ejecutar código.:href, :src y :action son vectores clásicos de XSS cuando el contenido proviene de usuarios externos. Afecta especialmente a plataformas multi-tenant como CultivaDash.https:// o usar un proxy de imágenes propio. Nunca confiar en URLs externas sin allowlist de dominio.- <img :src="comment.authorAvatar" /> + <img :src="safeAvatarUrl(comment.authorAvatar)" alt="avatar" /> // const safeAvatarUrl = (url: string) => // url?.startsWith('https://') ? url : '/img/default-avatar.png'
v-if="isLoading" y v-for="item in 3" coexisten en el mismo <div>. Vue evalúa v-if una vez y no por iteración, causando comportamiento impredecible.<template v-if> exterior y poner el v-for en el hijo, o usar un <template v-for> con v-if en el elemento.- <div v-if="isLoading" v-for="item in 3" :key="item"> + <template v-if="isLoading"> + <div v-for="item in 3" :key="item"> + <div class="skeleton"></div> + </div> + </template>
:key="index" en el listado de comentarios. Si se elimina o inserta un comentario intermedio, Vue reutilizará nodos DOM erróneos causando contenido o animaciones en la posición equivocada.:key="comment.id".- <div v-for="(comment, index) in comments" :key="index"> + <div v-for="comment in comments" :key="comment.id">
const { campaignId, userId } = props captura una copia del valor en el momento de ejecución. Si el padre cambia campaignId, el componente no reacciona.campaignId vía prop, mantendrá los comentarios de la campaña anterior.toRefs(props) o acceder siempre como props.campaignId. Migrar a <script setup> donde Vue 3.5 reactiva el destructuring automáticamente.- const { campaignId, userId } = props + const { campaignId, userId } = toRefs(props) + // ahora campaignId.value es reactivo
const comments = ref([]) y el setInterval están a nivel de módulo. Cada instancia del componente comparte el mismo estado y el intervalo nunca se limpia, acumulándose con cada montaje.comments y el intervalo dentro de la función del composable, y limpiar con onUnmounted.- const comments = ref([]) // módulo global - let pollInterval = setInterval(..., 5000) // nunca limpiado + export function useCommentsFeed() { + const comments = ref([]) // por instancia + const pollInterval = setInterval(() => { ... }, 5000) + onUnmounted(() => clearInterval(pollInterval)) + return { comments, ... } + }
loadComments hace await fetch(...) sin try/catch. Un 500 del servidor lanza una excepción no capturada que deja el store en estado parcial y crashea el componente silenciosamente.await en la página, por lo que los errores no burbujean al nivel correcto.await desde el caller o usar useAsyncData de Nuxt.+ const error = ref<string | null>(null)
+
async function loadComments(campaignId: string) {
+ error.value = null
+ try {
const res = await fetch(`/api/campaigns/${campaignId}/comments`)
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
comments.value = data
+ } catch (e) {
+ error.value = (e as Error).message
+ }
}
export default { ... setup() {} } (Options API híbrida) en lugar de <script setup>. El resto del proyecto (páginas, composables) ya usa Composition API.<script setup>.<script setup lang="ts">. Los props pasan a defineProps, el nombre a defineOptions o convención de fichero.document.querySelector('.comments-scroll') busca por clase CSS en el DOM global. Si hay dos instancias del componente (modal + página), se actualizará la primera que encuentre.scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight.- document.querySelector('.comments-scroll').scrollTop = 9999 + if (scrollContainer.value) { + scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight + }
timestamp: new Date() almacena un objeto Date en el estado de Pinia. No sobrevive a JSON.stringify/parse, lo que rompe la hidratación SSR de Nuxt y el time-travel de devtools.timestamp: new Date().toISOString(). Convertir a Date solo en la capa de presentación.<KeepAlive> sin prop :max mantiene vivas todas las instancias de activeTab visitadas. En CultivaDash, donde se puede navegar por docenas de campañas, el footprint de memoria crece sin límite.<KeepAlive :max="5">.- <KeepAlive> + <KeepAlive :max="5">
| Severidad | Conteo | Estado | Detalles |
|---|---|---|---|
| CRITICAL | 2 | BLOCK | v-html sin sanitizar, :src con URL no validada |
| HIGH | 5 | BLOCK | v-if+v-for, :key=index, props destructuring, composable leak, store sin error boundary |
| MEDIUM | 4 | WARN | Options API, querySelector, Date en Pinia, KeepAlive sin max |
7 issues de severidad CRITICAL/HIGH impiden el merge. Las dos issues CRITICAL son vulnerabilidades de seguridad activas (XSS vía v-html y URL injection) que en producción permitirían ejecución de código en el contexto de cualquier usuario de CultivaDash. Las 5 issues HIGH incluyen un memory leak por composable mal construido, reactivity bugs que causarán datos obsoletos en la UI, y estado de Pinia inconsistente ante fallos de red. Corregir todas antes de solicitar re-review.