In this article
¿Es TypeScript todo lo que necesitamos para la seguridad de las aplicaciones?
TypeScript, un superconjunto de JavaScript, incorpora tipado estático: una potente función que mejora la mantenibilidad del código. Pero ¿también eleva nuestras expectativas de seguridad?
En este artículo, quiero compartir mis propias observaciones sobre las falacias de seguridad de TypeScript y cómo algunos inconvenientes inesperados pueden hacer añicos las expectativas de los desarrolladores.
¿Qué promesas de seguridad ofrece TypeScript? Empecemos por cuestionar el problema.
Supongamos que confías en TypeScript para la seguridad de tipos. Una pregunta rápida: ¿este ejemplo ayuda a mostrar cómo TypeScript mantiene todo bajo control y solo permite lo que está explícitamente autorizado?
class UserController {
public getUserHelloComponent: RequestHandler = async (
_req: Request<{},{},{}, {name?: string} >,
res: Response) => {
const userName = _req.query.name || "World";
if (!sanitizeXSS(userName)) {
return res.status(400).send("Bad input detected!");
}
const helloComponent = `<h1>Hello, ${userName}!</h1>`;
return res.send(helloComponent);
}Más importante aún, quisiera preguntarte: ¿consideras que adoptar TypeScript sirve para verificar tipos durante el desarrollo o también crees que brinda una seguridad significativa en tiempo de ejecución?
Si respondiste que brinda seguridad en tiempo de ejecución, emprendamos un recorrido para conocer los controles y las falacias de seguridad de TypeScript.
Introducción a TypeScript
TypeScript permite detectar posibles errores en las primeras etapas del ciclo de desarrollo al definir explícitamente los tipos de datos de variables, funciones y objetos. Adoptar TypeScript para crear aplicaciones sobre JavaScript reduce significativamente el riesgo de errores en tiempo de ejecución, vulnerabilidades y comportamientos inesperados gracias a este enfoque proactivo y complementario.
Esta introducción a TypeScript se centra en minimizar los errores de tipo en tiempo de ejecución. Por definición y en la práctica, TypeScript es una herramienta para usar durante el desarrollo. Los desarrolladores escriben código en TypeScript y lo compilan a JavaScript. El ecosistema de herramientas de TypeScript permite garantizar la seguridad de tipos mediante las funciones inteligentes de los IDE y verificar los tipos durante el proceso de compilación y en un pipeline de integración continua.
A continuación, se muestra la definición de un controlador de ruta de API en una aplicación de Express creada con TypeScript:
class UserController {
public getUsers: RequestHandler = async (_req: Request, res: Response) => {
const filterQuery: string = _req.query.filter as string || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });
return handleServiceResponse(serviceResponse, res);
};
}TypeScript y la seguridad
Un problema de seguridad relacionado con los tipos en programación se conoce como coerción de tipos. Ocurre cuando JavaScript intenta convertir implícitamente los valores de un tipo a otro. Probablemente ya lo hayas visto en JavaScript en distintos acertijos relacionados con sus «peculiaridades».
Por ejemplo:
// prints true
let x = 1;
console.log(x == "1")El código anterior imprime «true» debido al proceso de coerción de tipos. JavaScript primero convierte los dos operandos al mismo tipo y luego los compara. Como resultado, «1» se interpreta como el número 1 y, al compararlos, se obtiene un resultado verdadero.
Al definir los tipos esperados de variables, parámetros de funciones y valores de retorno, TypeScript puede detectar muchos errores relacionados con los tipos durante el desarrollo, antes de que puedan explotarse en producción. Pero ¿puedes confiar en él? La coerción de tipos puede causar vulnerabilidades de seguridad porque los tipos no son estrictos en tiempo de ejecución, lo que permite eludir la verificación de tipos para la que se definieron.
Una aplicación de Express y TypeScript
Para contextualizar los demás ejemplos, usaremos una API de backend de Node.js basada en el framework web Express e integrada con TypeScript.
En concreto, la aplicación de API del servidor de Node.js tiene la siguiente estructura:
src/
api/
user/
userController.ts
userRouter.ts
userModel.ts
userService.ts
userRepository.ts
server.tsEl código del servidor de Express carga la definición de rutas, el middleware y otras configuraciones de la siguiente manera:
import { userRouter } from "@/api/user/userRouter";
// Middlewares
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cors({ origin: env.CORS_ORIGIN, credentials: true }));
app.use(helmet());
app.use(rateLimiter);
// Request logging
app.use(requestLogger);
// Routes
app.use("/health-check", healthCheckRouter);
app.use("/users", userRouter);Usaremos el ejemplo de userRouter a lo largo de los ejemplos de seguridad de TypeScript que veremos en este artículo.
Elusión de seguridad de TypeScript n.º 1
Tenemos un endpoint /users que funciona como endpoint de API REST. Permite a los usuarios buscarse a sí mismos y, en particular, pasar una cadena de filtro para encontrar coincidencias con su nombre.
El patrón de repositorio que muestra cómo se realiza este filtrado es el siguiente: expone un método findAllAsync que recibe un objeto con una propiedad filter.
export class UserRepository {
async findAllAsync({ filter }: { filter?: string } = {}): Promise<User[]> {
if (filter) {
return users.filter((user) => user.name.startsWith(filter));
}
return users;
}
}Para consultar los usuarios, se envía una solicitud HTTP GET:
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter=Al"| jq
¿Cómo pasa la cadena de consulta filter desde la definición de la ruta hasta la capa del repositorio? Exploremos este patrón y cómo podemos usar TypeScript en él.
La definición de la ruta HTTP es la siguiente: define el código del controlador para la ruta / con el prefijo /users y coincide con todas las solicitudes HTTP del método GET:
import { userController } from "./userController";
export const userRouter: Router = express.Router();
userRouter.get("/", userController.getUsers);Este es el código del controlador de Express, que maneja las solicitudes HTTP GET para esta ruta:
import type { Request, RequestHandler, Response } from "express";
import { userService } from "@/api/user/userService";
import { handleServiceResponse } from "@/common/utils/httpHandlers";
class UserController {
public getUsers: RequestHandler = async (_req: Request, res: Response) => {
const filterQuery: any = _req.query.filter || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });
return handleServiceResponse(serviceResponse, res);
};
}Al igual que userRepository, la definición del servicio userService que invoca el controlador también se actualizó para aceptar, como primer argumento de la función, un objeto con una propiedad filter y ajustarse al tipo filterQuery.
La respuesta HTTP a nuestra solicitud curl mostrará todos los usuarios cuyos nombres empiezan con la letra «Al». El resultado es:
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter=Al"| jq
{
"success": true,
"message": "Users found",
"responseObject": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"age": 42,
"createdAt": "2025-01-13T10:51:37.118Z",
"updatedAt": "2025-01-18T10:51:37.118Z"
}
],
"statusCode": 200
}Hasta aquí, nada sorprendente. Pero ¿qué pasa si el contenido de la solicitud GET envía la cadena de consulta filter de una forma que se interpreta como un arreglo y no como una cadena?
Esta es la carga útil de explotación que provocaría un problema de seguridad por coerción de tipos en nuestra aplicación web de Node.js. Basta con cambiar el campo de la cadena de consulta de filter= a filter[]= para recibir la misma respuesta:
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter[]=Al"| jq
{
"success": true,
"message": "Users found",
"responseObject": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"age": 42,
"createdAt": "2025-01-13T10:51:37.118Z",
"updatedAt": "2025-01-18T10:51:37.118Z"
}
],
"statusCode": 200
}Esto funciona igual que antes porque JavaScript convierte el arreglo según su definición de toString, lo que devuelve el texto del primer elemento del arreglo, la cadena Al. Se mantiene el mismo comportamiento de antes, aunque internamente la variable filter ahora se trata como una cadena. ¿Es esto algo malo? Ya lo veremos.
Quizá ya notaste que la definición de TypeScript en el código del controlador es terrible:
const filterQuery: any = _req.query.filter || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });Usar la magia oscura de any en TypeScript es una práctica poco convencional y mal vista, quizá solo superada por el uso de eval en JavaScript. Así que no sorprende que exista la coerción de tipos. Permitimos que la cadena de consulta filter coincidiera con cualquier tipo y luego la usamos en el servicio y el repositorio de usuarios.
Hagámoslo mejor.
Elusión de seguridad de TypeScript n.º 2
Evitaremos usar el comodín especial de tipos any de TypeScript y, en su lugar, definiremos de forma estricta y explícita que la variable filter es de tipo cadena.
Cambiemos la implementación de la ruta para usar el tipo cadena de TypeScript:
class UserController {
public getUsers: RequestHandler = async (_req: Request, res: Response) => {
// we now define query as string
const filterQuery: string = _req.query.filter as string || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });
return handleServiceResponse(serviceResponse, res);
};Para asegurarnos de que todos los tipos estén configurados correctamente y no haya errores en sus definiciones, ejecutamos el compilador tsc y confirmamos que no hay errores:
$ npx tsc¿Qué pasaría si enviáramos el mismo contenido HTTP con coerción de tipos, indicando que filter es un arreglo? Veamos:
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter[]=Al"| jq
{
"success": true,
"message": "Users found",
"responseObject": [
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"age": 42,
"createdAt": "2025-01-13T10:51:37.118Z",
"updatedAt": "2025-01-18T10:51:37.118Z"
}
],
"statusCode": 200
}Aunque definimos explícitamente que la cadena de consulta filter es de tipo cadena y el compilador de TypeScript se ejecutó sin errores, aun así pudimos realizar un ataque de coerción de tipos. Por supuesto, este ataque solo demuestra lo que es posible hacer y no es dañino.
Elusión de seguridad de TypeScript n.º 3
Probemos otro mecanismo de TypeScript para declarar tipos.
Seguiremos una estrategia de tipado más convencional en lugar de forzar a TypeScript con una declaración de tipo any o as string. Definiremos una interfaz para el esquema esperado de la cadena de consulta y la aplicaremos al tipo del objeto Request.
Este endpoint GET servirá como una alternativa simplificada a React Server Components. Recibirá una cadena de consulta con el nombre del usuario y devolverá una respuesta HTTP que incluye el contenido HTML del componente que se representará en el DOM.
A continuación, se muestra la implementación del componente del lado del servidor:
interface UserComponentQueryString {
name?: string;
}
class UserController {
public getUserHelloComponent: RequestHandler = async (
_req: Request<{}, {}, {}, UserComponentQueryString>,
res: Response) => {
const userName = _req.query.name || "World";
if (!sanitizeXSS(userName)) {
return res.status(400).send("Bad input detected!");
}
const helloComponent = `<h1>Hello, ${userName}!</h1>`;
return res.send(helloComponent);
}
}Como puedes ver, tipamos la parte de la cadena de consulta del objeto de tipo Request, tal como se espera y se indica mediante ReqQuery:
(alias) interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = qs.ParsedQs, Locals extends Record<string, any> = Record<string, any>>
import RequestDebido al evidente problema de seguridad relacionado con el Cross-Site Scripting (XSS), también limpiaremos la entrada del usuario. Por eso, llamamos a la función sanitizeXSS(). Esta es su implementación:
function sanitizeXSS(name: string): boolean {
const disallowList = ["<", ">", "&", '"', "'", "/", "="];
return !disallowList.some((badInput) => name.includes(badInput));
}Así, establecimos medidas de seguridad mediante los siguientes mecanismos:
Nuestra interfaz asigna explícitamente el genérico
Request<T>. El cuarto parámetro,ReqQuery, se establece comoUserComponentQueryString, lo que garantiza que la cadena de consulta se ajuste a la estructura definida en nuestra interfaz. Esto significa que_req.queryserá un objeto con una propiedad name opcional de tipo cadena.Esperamos que los usuarios puedan abusar de esta lógica de creación de componentes y proporcionar un campo de cadena de consulta
namecon un valor como<img src=x onError=alert(1) />, un ejemplo simplificado de cómo podrían ejecutar operaciones JavaScript dañinas en el lado del cliente. Para evitarlo, creamos la funciónsanitizeXSS, que detecta si se usan cadenas peligrosas en la propiedadnamey evita que se incluyan en el componente HTML generado.
Intentemos enviar una solicitud con una carga útil maliciosa de este tipo:
$ curl -G -X 'GET' -H 'accept: application/json' "http://localhost:8080/users/component" --data-urlencode "name=<img liran"
Bad input detected!Como puedes ver, recibimos la respuesta esperada según nuestra lógica para limpiar los caracteres maliciosos de la entrada del usuario.
Sin embargo, ¿qué pasa si aplicamos el problema de seguridad por coerción de tipos que aprendimos y cambiamos el nombre del campo name a name[]? ¿Se recibirá como un tipo arreglo?
$ curl -G -X 'GET' -H 'accept: application/json' "http://localhost:8080/users/component" --data-urlencode "name[]=<img src=x onError=alert(1) />"
<h1>Hello, <img src=x onError=alert(1) />!</h1>¡Eludimos nuestras medidas de seguridad! ¿Cómo ocurrió?
El arreglo name[] se pasa a la función sanitizeXSS(), que intenta ejecutar la función includes() sobre el nombre recibido mediante el predicado de coincidencia del arreglo disallowList. Sin embargo, name no es una cadena, sino un arreglo, así que el predicado nunca devuelve el valor booleano true y falla la lógica de limpieza contra Cross-Site Scripting.
Observaciones y aprendizajes sobre las prácticas de TypeScript
No uses el comodín de tipo
anyde TypeScript.Forzar tipos con expresiones como
_req.query.filter as stringse considera una mala práctica; algunos lo llaman «mentirle a TypeScript».Puedes seguir la práctica de refinamiento de tipos en TypeScript, a menudo conocida como «protección de tipos», que realiza verificaciones en tiempo de ejecución para comprobar los tipos esperados. Por ejemplo:
if (typeof padding === "number") { … }. Sin embargo, esto puede convertirse en una práctica habitual en todo el código y afectar su legibilidad y mantenibilidad.
¿TypeScript es bueno para la seguridad? Sí, pero no fue creado para eso.
En mi opinión, TypeScript sirve para hacer verificaciones durante el desarrollo. Aunque puedes implementar protecciones de tipos, también puedes hacerlo con JavaScript puro. Para hacer cumplir de forma eficaz los tipos, la estructura y la validación esperados de los datos a medida que recorren el código, TypeScript necesita un complemento en tiempo de ejecución, como Zod y otras bibliotecas de validación de esquemas.
A los desarrolladores les encanta. Los equipos de seguridad confían en él.
Las herramientas de Snyk, diseñadas primero para desarrolladores, ofrecen seguridad integrada y automatizada que satisface tus necesidades de gobernanza y cumplimiento.