Alex HerreraDesarrollador de software · Enfoque en ciberseguridad
EN
← Volver al blog

Seguridad web

Ocho cosas que revisar en el código que genera la IA

Ocho fallos de seguridad frecuentes en el código generado por IA, con su corrección y la orden que la comprueba.

Publicado
Actualizado
Lectura
19 min de lectura

El código que genera un modelo de lenguaje, desde un chat o desde un agente, compila, pasa las pruebas y cumple el enunciado que recibió. Ese enunciado no suele incluir los requisitos de seguridad, como quién puede ver o cambiar cada dato. Por eso el resultado tampoco los cumple.

Veracode probó más de cien modelos con tareas de programación en cuatro lenguajes. El 45 % de las muestras de código generadas falló las pruebas de seguridad. Los modelos más nuevos y más grandes no obtuvieron mejores resultados que los pequeños1.

En su actualización de marzo de 2026, con más de 150 modelos, la tasa de código seguro seguía en torno al 55 %. El XSS quedó entre los dos fallos que menos evitaron: solo el 15 % de las muestras lo evitaba, frente al 13 % de la inyección en los registros2. Cambiar de modelo no basta, así que la revisión tiene que buscar esos fallos en el código.

Los ocho fallos que siguen son frecuentes en el código generado. Cada uno pertenece a una capa de Cómo proteger una página web. Esa guía explica cómo aplicar cada capa y cómo comprobarla. Aquí aparece el mismo fallo visto en el código, con su corrección y la orden que la comprueba.

Punto Capa de la guía
1 y 2 Permisos
3, 5 y 8 Entrada y salida
4 Secretos
6 Dependencias
7 Errores y registros

Todos los ejemplos están reconstruidos para el artículo y no salen de ningún sistema en producción. Las rutas de los puntos 1, 2, 5 y 8 se ejecutaron el 27 de septiembre de 2026 sobre una base de datos simulada en memoria. Las versiones fueron Next.js 16.3.6, React 19.3.0, Zod 4.6.5 e isomorphic-dompurify 4.4.0.

1. Cómo comprobar el permiso en el servidor

El componente oculta el botón según el rol, y el endpoint borra la factura sin comprobar nada.

{user.role === 'admin' && <button onClick={borrar}>Borrar factura</button>}
// app/api/facturas/[id]/route.ts
export async function DELETE(req: Request, ctx: RouteContext<'/api/facturas/[id]'>) {
  const { id } = await ctx.params;
  await db.invoice.delete({ where: { id } });
  return Response.json({ ok: true });
}

Desde Next.js 15, params es una promesa y hay que esperarla con await. Next.js 16 retiró el acceso síncrono34. RouteContext es un tipo que Next.js genera para cada ruta y que no hace falta importar.

Ocultar el botón es una decisión de presentación, no un permiso. El endpoint sigue aceptando la petición de cualquiera que conozca la dirección, y esa dirección está en el código que el navegador ya descargó. El API Security Top 10 de OWASP recoge este fallo como API5:2023, Broken Function Level Authorization. Consiste en que una función del servidor no comprueba el permiso de quien la llama. OWASP señala además que la autorización se gestiona en la configuración o en el código5.

Repita la comprobación en el servidor, que es el único sitio que el usuario no puede modificar:

export async function DELETE(req: Request, ctx: RouteContext<'/api/facturas/[id]'>) {
  const { id } = await ctx.params;
  const session = await auth();
  if (session?.user.role !== 'admin') return new Response(null, { status: 403 });

  await db.invoice.delete({ where: { id } });
  return Response.json({ ok: true });
}

En una plataforma multiempresa, adopte desde el primer día una versión más estricta de esta regla. Toda la autorización se decide en el código de la aplicación. La cuenta de base de datos de la aplicación da acceso al proceso, nunca a la persona que hizo la petición.

Cómo comprobarlo. Salte la interfaz y envíe la petición con la sesión de un usuario que no sea administrador:

curl -i -X DELETE http://localhost:3000/api/facturas/42 \
  -H "Cookie: session=$SESION_DE_USUARIO_NORMAL"

Sin la corrección, la respuesta es 200 OK y la factura desaparece. Con la corrección, la respuesta es 403 y la factura sigue ahí.

2. Cómo comprobar de quién es un recurso que se carga por id

La consulta busca la factura por el identificador que llega en la URL y por nada más.

export async function GET(req: Request, ctx: RouteContext<'/api/facturas/[id]'>) {
  const { id } = await ctx.params;
  const invoice = await db.invoice.findUnique({ where: { id } });
  return Response.json(invoice);
}

Este endpoint sí exige una sesión, así que parece correcto. Sin embargo, cualquier usuario con sesión puede leer las facturas de otra empresa cambiando el número de la dirección. La misma lista de OWASP pone este fallo en el primer puesto, como API1:2023. Lo describe como la manipulación del identificador que se envía dentro de la petición6.

Para corregir este caso y todos los de su tipo, incluya al dueño en la condición de la consulta. Una comprobación posterior se puede olvidar en el siguiente endpoint. Una condición dentro del where se aplica cada vez que se ejecuta la consulta.

export async function GET(req: Request, ctx: RouteContext<'/api/facturas/[id]'>) {
  const { id } = await ctx.params;
  const session = await auth();
  if (!session) return new Response(null, { status: 401 });

  const invoice = await db.invoice.findFirst({
    where: { id, companyId: session.user.companyId }
  });
  if (!invoice) return new Response(null, { status: 404 });
  return Response.json(invoice);
}

La corrección responde 404 y no 403 a propósito. Un 403 confirma que la factura existe, y esa confirmación ya es información sobre otra empresa. La sesión se comprueba antes de la consulta. Sin esa línea, una petición sin sesión provoca un error al leer session.user y el servidor responde 500.

Cómo comprobarlo. Use dos sesiones. Inicie sesión como la empresa A, pida un id que pertenezca a la empresa B y compruebe que la respuesta es 404. En la medición, sin la corrección, la misma petición devolvió 200 con la factura de la otra empresa. Repita la petición sin sesión: la respuesta tiene que ser 401.

3. Cómo construir una consulta sin concatenar texto

El filtro llega desde un formulario de búsqueda y entra en la sentencia SQL como texto.

const client = new URL(req.url).searchParams.get('client');
const rows = await db.$queryRawUnsafe(
  `SELECT * FROM invoices WHERE client = '${client}'`
);

El valor queda dentro de las comillas y puede cerrarlas. Con x' OR '1'='1, la condición siempre se cumple y la consulta devuelve la tabla entera. OWASP recomienda no pasar el valor al intérprete de SQL como texto, sino usar una interfaz con parámetros o un ORM7.

La versión corregida usa $queryRaw con una plantilla etiquetada, que es una cadena con ${} que se pasa a una función. La función recibe el valor aparte y lo envía como parámetro, no como parte de la sentencia:

const client = new URL(req.url).searchParams.get('client') ?? '';
const rows = await db.$queryRaw`SELECT * FROM invoices WHERE client = ${client}`;

Queda un caso que el parámetro no cubre: el nombre de la columna por la que se ordena. Un parámetro sustituye un valor, no el nombre de una columna, así que ahí la única defensa es una lista cerrada:

const COLUMNS = { fecha: 'issued_at', monto: 'total' } as const;
const column = COLUMNS[sort as keyof typeof COLUMNS] ?? 'issued_at';

Cómo comprobarlo. Envíe el valor con la comilla:

curl "http://localhost:3000/api/facturas?client=x%27%20OR%20%271%27%3D%271"

Sin la corrección, la respuesta trae todas las facturas. Con la corrección, trae una lista vacía, que es la respuesta correcta para un cliente que no existe.

4. Cómo evitar que un secreto llegue al navegador

La variable tiene que estar disponible dentro del componente, así que el modelo le pone el prefijo que la hace pública.

const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!);

Según la documentación de Next.js, el valor de una variable con el prefijo NEXT_PUBLIC_ se incrusta en el bundle durante la compilación. El bundle es el JavaScript que descarga el navegador, y en él cada referencia a la variable se sustituye por su valor8. Lo mismo ocurre con VITE_ y con PUBLIC_ en Astro.

La corrección es que la clave no salga del servidor. El componente llama a una ruta propia, y esa ruta llama a Stripe con la clave:

// app/api/pagos/route.ts
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const intent = await stripe.paymentIntents.create({ amount: 1500, currency: 'usd' });
  return Response.json({ clientSecret: intent.client_secret });
}

Una protección que depende de recordar el prefijo falla el día que alguien lo olvida. La capa de secretos de la guía explica cómo detectarlos antes del commit y dónde guardarlos.

Cómo comprobarlo. Busque en el resultado de la compilación, no en el código fuente. Next.js deja el bundle en .next/static. Busque el comienzo del valor de la clave y no el nombre de la variable, porque la compilación ya sustituyó uno por otro. Las claves de Stripe empiezan por sk_live_ en modo real y por sk_test_ en modo de prueba:

pnpm build && grep -rE "sk_(live|test)_" .next/static | head

La salida tiene que estar vacía.

5. Cómo validar la entrada en el endpoint

El formulario limita el campo, y el endpoint da por hecho que el valor llegó limitado.

<input type="number" name="cantidad" min={1} max={5} required />
export async function POST(req: Request) {
  const { cantidad } = await req.json();
  const pedido = await db.order.create({ data: { cantidad, total: cantidad * PRECIO } });
  return Response.json(pedido, { status: 201 });
}

Los atributos min, max y required ayudan a la persona que rellena el formulario, pero una petición enviada fuera del formulario no pasa por ellos. Con una cantidad negativa, el total que se guarda sale negativo. El catálogo CWE lo recoge como CWE-602: aplicar en el cliente una protección que corresponde al servidor9.

Valide en cada límite de confianza, el punto donde un dato pasa de algo que el usuario controla a algo que no controla. Un endpoint es uno de esos límites, y también lo son una cola de mensajes y un webhook. La corrección valida el cuerpo con un esquema de Zod antes de crear el pedido:

const Body = z.object({ cantidad: z.number().int().min(1).max(5) });

export async function POST(req: Request) {
  const parsed = Body.safeParse(await req.json());
  if (!parsed.success) return new Response(null, { status: 400 });

  const { cantidad } = parsed.data;
  const pedido = await db.order.create({ data: { cantidad, total: cantidad * PRECIO } });
  return Response.json(pedido, { status: 201 });
}

safeParse devuelve el resultado de la validación en lugar de lanzar una excepción, y el endpoint responde 400 cuando falla. Cada rama del handler devuelve una respuesta. Si una rama termina sin return, Next.js responde 500 y registra «No response is returned from route handler».

Cómo comprobarlo. Envíe un valor que el formulario no deja escribir:

curl -i -X POST http://localhost:3000/api/pedidos \
  -H "Content-Type: application/json" \
  -d '{"cantidad": -3}'

Sin la corrección, la respuesta es 201 y el pedido se guarda con "total":-3000. Con la corrección, la respuesta es 400 y no se crea nada.

6. Cómo comprobar que un paquete existe antes de instalarlo

Este fallo no está en el código, sino en package.json. El modelo propone un paquete con un nombre verosímil que nunca se publicó en el registro de npm, desde donde se instalan los paquetes. El nombre del ejemplo está inventado para el artículo y no existía en el registro el 7 de septiembre de 2026.

"dependencies": {
  "react-input-sanitizer-pro": "^2.1.0"
}

Un paquete alucinado es un nombre de paquete que el modelo inventa. Un estudio publicado en USENIX Security 2025 generó 576 000 muestras de código con 16 modelos y encontró 205 474 nombres alucinados distintos. Al menos un 5,2 % de los paquetes que citaban los modelos comerciales no existía, y un 21,7 % en los modelos abiertos10.

El nombre inventado es predecible, así que cualquiera puede publicarlo en el registro con su propio código. Desde ese momento, ese código se instala en todos los proyectos que siguen la sugerencia. Además, un paquete puede declarar scripts de instalación, que son órdenes que el gestor de paquetes ejecuta al instalarlo. Si el gestor los ejecuta, el código de quien publicó el paquete se ejecuta antes de que nadie lo importe.

Que el gestor los ejecute depende de la versión: yarn 1 los ejecuta sin excepciones, y npm los ejecutó hasta la versión 11. En npm 12 y en pnpm 10 están bloqueados, salvo los de los paquetes que aparecen en una lista declarada aparte. El artículo Diferencia entre npm y pnpm en la seguridad al instalar mide cuatro versiones de los dos gestores.

Cómo comprobarlo. Antes de instalar, consulte el registro:

npm view react-input-sanitizer-pro time.created maintainers repository.url

Tres respuestas desaconsejan instalar:

  1. Un E404 significa que el paquete no existe.
  2. Una fecha de creación de hace pocos días significa que alguien pudo registrarlo para aprovechar el nombre inventado.
  3. Un repositorio ausente, o que no corresponde al nombre, significa que no hay nada que auditar.

7. Cómo devolver un error sin exponer el detalle interno

El manejador devuelve el error entero al cliente, algo cómodo mientras se desarrolla.

catch (error) {
  return Response.json({ error: error.message, stack: error.stack }, { status: 500 });
}

El mensaje de un error de base de datos nombra la tabla, la columna y la restricción que falló. La traza (stack trace), que es la lista de llamadas que llevaron al error, nombra las rutas del servidor y las versiones de las dependencias. Es CWE-209: un mensaje de error que revela información del entorno o de sus datos12. Esa información reduce el trabajo de quien busca una vulnerabilidad.

Guarde el detalle en el log del servidor, que solo lee el equipo, y devuelva un identificador para encontrarlo después:

catch (error) {
  const id = crypto.randomUUID();
  console.error(id, error);
  return Response.json({ error: 'internal_error', id }, { status: 500 });
}

El log tampoco es un sitio privado: se exporta, se envía a un proveedor y lo leen personas que no necesitan verlo todo. Depure los datos personales antes de escribirlos en el log, no cuando alguien pida acceso. La capa de errores de la guía enseña la misma corrección medida en Express.

Cómo comprobarlo. Provoque el fallo: detenga la base de datos, envíe la petición y lea el cuerpo de la respuesta. No puede aparecer ningún nombre de tabla ni ninguna ruta del servidor.

8. Cómo mostrar el HTML que escribe un usuario

El perfil muestra la biografía que escribió el usuario y un enlace a su web. Para conservar el formato de la biografía, el modelo usa dangerouslySetInnerHTML.

// app/perfil/page.tsx
<div dangerouslySetInnerHTML={{ __html: perfil.bio }} />
<a href={perfil.web}>Mi web</a>

React escapa el texto que se escribe entre llaves, pero dangerouslySetInnerHTML desactiva ese escape y el valor llega al navegador como HTML. Con la biografía <img src=x onerror="console.log('XSS ejecutado')">, el HTML que devuelve el servidor contiene la etiqueta tal cual. El navegador no puede cargar la imagen y ejecuta el atributo onerror. Es un XSS almacenado, CWE-7913.

El enlace es el segundo caso. Un href que empieza por javascript: ejecuta código al pulsarlo. React 19 ya lo bloquea: en la medición sustituyó el valor por javascript:throw new Error('React has blocked a javascript: URL as a security precaution.'). El mismo valor en una plantilla de texto, en v-html de Vue o en una versión anterior de React no tiene esa protección.

Si el texto no necesita formato, muéstrelo entre llaves y deje que React lo escape. Si lo necesita, pase el HTML por un sanitizador antes de insertarlo. OWASP recomienda DOMPurify, que quita las etiquetas de script y los atributos de evento14. Para los enlaces, acepte solo http y https:

import DOMPurify from 'isomorphic-dompurify';

const web = /^https?:\/\//i.test(perfil.web) ? perfil.web : undefined;

<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(perfil.bio) }} />
<a href={web}>Mi web</a>

Cómo comprobarlo. Guarde la biografía de prueba en el perfil y pida la página:

curl -s http://localhost:3000/perfil | grep -o '<img[^>]*>'

Sin la corrección, la salida es <img src=x onerror="console.log('XSS ejecutado')">. Con la corrección, es <img src="x">, sin el atributo onerror.

9. Cómo revisar el código generado con un prompt

Los ocho puntos se pueden revisar a mano. También puede hacerlo un agente de programación con un prompt, sobre los cambios recientes o sobre el repositorio entero. Cópielo con el botón «Copiar prompt» del panel «Revisar el código» y péguelo en una sesión abierta en la raíz del proyecto. El agente sigue siete fases:

  1. Pregunta qué revisar: un rango de commits, una rama o todo el repositorio si es pequeño.
  2. Revisa los ocho puntos con su comprobación, y confirma cada fallo ejecutándola cuando puede.
  3. Propone y espera. Presenta los fallos, del más grave al más leve, y la corrección de cada uno, y se detiene hasta que usted confirma.
  4. Corrige con el cambio más pequeño que cierra cada fallo.
  5. Añade una prueba por fallo y comprueba que falla antes de la corrección.
  6. Repite las comprobaciones y pega las salidas de antes y de después.
  7. Entrega un informe con lo que cambió, lo que no pudo comprobar y la capa de la guía a la que pertenece cada fallo.

El prompt se ejecutó una vez, como demostración, con Claude Code 2.1.283 y la versión anterior del prompt, que revisaba siete puntos sin el XSS. El proyecto era una API de facturas en Express preparada con esos siete fallos. Tardó 6 minutos sin contar la espera de la confirmación: 3 hasta la propuesta y 3 para corregir y comprobar.

El agente señaló los siete fallos sembrados. Encontró además uno que no estaba sembrado. Con el token __proto__, la búsqueda de la sesión devolvía el prototipo del objeto y la petición pasaba. Las nueve pruebas que añadió fallaban antes de las correcciones y pasaron después. También avisó de que la clave expuesta seguía en el historial de git.

Una ejecución sobre un proyecto preparado muestra cómo trabaja el prompt, pero no mide cuántos fallos encuentra en un proyecto real. Tampoco mide los fallos que el prompt no nombra. Revise el informe como revisaría el de una persona.

Cómo comprobarlo. git status y git diff muestran solo las correcciones confirmadas y los archivos de prueba nuevos, y las pruebas pasan.

Qué corregir primero

Los ocho fallos no tienen la misma urgencia. Los puntos 1, 2 y 4 se pueden explotar desde fuera sin ningún otro fallo y sin preparar ningún valor, así que van primero. Los puntos 3, 5 y 8 necesitan que alguien envíe un valor preparado a propósito, como una comilla, una cantidad negativa o una etiqueta HTML. Eso cuesta poco, pero es deliberado. El punto 6 se revisa al instalar una dependencia, y el 7 se corrige una sola vez en el manejador de errores común.

Fallar cerrado significa negar la petición cuando una comprobación no puede decidir. Esa regla vale para los ocho casos. Por ejemplo, la petición se rechaza si la sesión no se resuelve, si la validación lanza una excepción o si el permiso no aparece. El código generado suele hacer lo contrario. Un ?? 0, un catch vacío o un if (!user) return next() dejan que la petición siga su curso normal, y el fallo no se nota.

Quedan fuera de la lista una configuración de CORS abierta y la falta de un límite de peticiones. Cómo proteger una página web los trata en sus capas de otros orígenes y de contraseñas e intentos. Esta última incluye el límite de intentos de inicio de sesión. Los ataques web más comunes reúne estos fallos con otros ataques frecuentes, como el SSRF y la inyección de órdenes.

Referencias

  1. Veracode. 2025 GenAI Code Security Reportveracode.com↩
  2. Veracode. Spring 2026 GenAI Code Security Update24 de marzo de 2026; más de 150 modelos, XSS evitado en el 15 % de las muestras.veracode.com↩
  3. Next.js. route.js, Version Historyparams es una promesa desde la 15.0.0-RC; acceso síncrono retirado en la 16.nextjs.org↩
  4. Next.js. How to upgrade to version 16params: acceso síncrono retirado en Next.js 16.nextjs.org↩
  5. OWASP. API Security Top 10 2023, API5:2023 Broken Function Level Authorizationowasp.org↩
  6. OWASP. API Security Top 10 2023, API1:2023 Broken Object Level Authorizationowasp.org↩
  7. OWASP. Top 10 2021, A03:2021 Injectionowasp.org↩
  8. Next.js. How to use environment variables in Next.jsnextjs.org↩
  9. MITRE. CWE-602: Client-Side Enforcement of Server-Side Securitycwe.mitre.org↩
  10. Spracklen, J., Wijewickrama, R., Sakib, A. H. M. N., Maiti, A., Viswanath, B., Jadliwala, M. We Have a Package for You! A Comprehensive Analysis of Package Hallucinations by Code Generating LLMsUSENIX Security 2025.arxiv.org↩
  11. npm. npm-cidocs.npmjs.com↩
  12. MITRE. CWE-209: Generation of Error Message Containing Sensitive Informationcwe.mitre.org↩
  13. MITRE. CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')cwe.mitre.org↩
  14. OWASP. Cross Site Scripting Prevention Cheat Sheetcheatsheetseries.owasp.org↩