In this article
O TypeScript é tudo o que precisamos para a segurança de aplicações?
O TypeScript, um superset do JavaScript, introduz tipagem estática — um recurso poderoso que facilita a manutenção do código. Mas será que ele também corresponde às nossas expectativas de segurança?
Neste artigo, quero compartilhar minhas próprias observações sobre falsas premissas relacionadas à segurança no TypeScript e como armadilhas inesperadas podem frustrar as expectativas dos desenvolvedores.
Quais são as promessas de segurança do TypeScript? Vamos questionar isso logo de cara.
Suponha que você confie na segurança de tipos do TypeScript. Aqui vai uma pergunta rápida: este exemplo ajuda a mostrar como o TypeScript mantém tudo sob controle, permitindo apenas o que foi explicitamente 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);
}Mais importante: você vê a adoção do TypeScript como uma forma útil de verificar tipos durante o desenvolvimento ou acredita que ele também oferece segurança significativa em tempo de execução?
Se você respondeu segurança em tempo de execução, vamos explorar os controles e as falsas premissas sobre segurança no TypeScript.
Introdução ao TypeScript
O TypeScript permite que os desenvolvedores detectem possíveis erros no início do ciclo de desenvolvimento ao definir explicitamente os tipos de dados de variáveis, funções e objetos. Ao adotar o TypeScript, essa abordagem proativa e incremental para criar aplicações sobre o JavaScript reduz significativamente o risco de erros em tempo de execução, vulnerabilidades e comportamentos inesperados.
Esta introdução ao TypeScript destaca como ele ajuda a minimizar erros de tipo em tempo de execução. Por definição e uso prático, o TypeScript é uma ferramenta de desenvolvimento. Os desenvolvedores escrevem código na linguagem TypeScript e o compilam para JavaScript. O ecossistema de ferramentas do TypeScript permite garantir a segurança de tipos com a inteligência das IDEs e verificar os tipos do código em um processo de build e em um pipeline de integração contínua.
Veja a seguir um exemplo de definição de um manipulador de rota de API em uma aplicação Express criada com 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 e segurança
Um problema de segurança relacionado a tipos na programação é conhecido como coerção de tipos. Isso acontece quando o JavaScript tenta converter implicitamente valores de um tipo para outro. Você provavelmente já se deparou com isso em diferentes desafios sobre as “peculiaridades” do JavaScript.
Por exemplo:
// prints true
let x = 1;
console.log(x == "1")O código acima imprime “true” por causa do processo de coerção de tipos. Primeiro, o JavaScript converte os dois operandos para o mesmo tipo e, em seguida, os compara. Assim, “1” é interpretado como o número 1 e comparado, o que resulta em um valor verdadeiro.
Ao definir os tipos esperados para variáveis, parâmetros de funções e valores de retorno, o TypeScript pode detectar muitos erros relacionados a tipos durante o desenvolvimento, antes que sejam explorados em produção. Mas será que você pode confiar nele? A coerção de tipos pode levar a vulnerabilidades de segurança, pois os tipos não são estritos em tempo de execução, permitindo contornar as verificações de tipo definidas.
Uma aplicação Express com TypeScript
Para contextualizar os exemplos a seguir, vamos usar uma API de back-end Node.js com o framework web Express e integração com TypeScript.
Em particular, a aplicação de API do servidor Node.js está estruturada da seguinte forma:
src/
api/
user/
userController.ts
userRouter.ts
userModel.ts
userService.ts
userRepository.ts
server.tsO código do servidor Express carrega a definição de rotas, o middleware e outras configurações da seguinte maneira:
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);Vamos usar o exemplo de userRouter ao longo dos exemplos de segurança do TypeScript discutidos neste artigo.
Bypass de segurança do TypeScript nº 1
Temos um endpoint /users que funciona como um endpoint de API REST. Ele permite que os usuários pesquisem por si mesmos e, em particular, passem uma string de filtro para encontrar usuários pelo nome.
O padrão de repositório que demonstra como essa filtragem é feita funciona assim: ele expõe um método findAllAsync que recebe um objeto com uma propriedade 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 os usuários, enviamos uma solicitação HTTP GET:
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter=Al"| jq
Como a string de consulta filter passa da definição de rota para a camada do repositório? Vamos explorar esse padrão e ver como podemos usar o TypeScript nele.
A definição da rota HTTP é a seguinte: definimos o código do controlador para a rota / com o prefixo /users, que corresponde a todas as solicitações HTTP com o verbo GET:
import { userController } from "./userController";
export const userRouter: Router = express.Router();
userRouter.get("/", userController.getUsers);Veja o código do controlador Express, que processa as solicitações HTTP GET para essa rota:
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);
};
}Assim como userRepository, a definição do serviço que o controlador chama aqui, userService, também foi atualizada para aceitar, no primeiro argumento da função, um objeto com uma propriedade filter e seguir o tipo filterQuery.
A resposta HTTP à nossa solicitação curl listará todos os usuários cujos nomes começam com a letra “Al”. O resultado é:
$ 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
}Até aqui, nenhuma surpresa. Mas e se o payload da solicitação GET enviasse a string de consulta filter de um jeito que fosse interpretada como um array, e não como uma string?
Este é o payload de exploração que criaria um problema de segurança relacionado à coerção de tipos na nossa aplicação web Node.js. Basta alterar o campo da string de consulta de filter= para filter[]=, e a resposta será a mesma:
$ 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
}Isso funciona como antes porque o JavaScript converte o array usando sua definição toString, que resulta no primeiro elemento do array, a string Al. O comportamento continua igual, embora a variável filter agora seja tratada internamente como uma string. Isso é um problema? Vamos descobrir.
Talvez você já tenha notado a definição horrível do TypeScript no código do controlador:
const filterQuery: any = _req.query.filter || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });Usar a magia negra de any no TypeScript é uma prática incomum e malvista, talvez só perdendo para o uso de eval no JavaScript. Então, não é de surpreender que exista coerção de tipos. Permitimos que a string de consulta filter aceitasse qualquer tipo e, depois, a usamos no serviço e no repositório de usuários.
Vamos fazer melhor.
Bypass de segurança do TypeScript nº 2
Vamos evitar o curinga especial any do TypeScript para tipos e, em vez disso, definir a variável filter de forma estrita e explícita como uma string.
Vamos alterar a implementação da rota para usar o tipo string do 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 garantir que todos os tipos estejam definidos corretamente e que não haja erros nas definições de tipo, executamos o compilador tsc e confirmamos que não há erros:
$ npx tscO que aconteceria se enviássemos o mesmo payload HTTP com coerção de tipos, especificando filter como um array? Vamos conferir:
$ 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
}Mesmo tendo definido explicitamente o tipo da string de consulta filter como string e executado o compilador do TypeScript sem erros, ainda foi possível realizar um ataque de coerção de tipos. É claro que esse ataque é apenas uma demonstração de possibilidades, sem intenção de causar danos.
Bypass de segurança do TypeScript nº 3
Vamos tentar outro mecanismo do TypeScript para declarar tipos.
Vamos adotar uma estratégia de tipagem mais convencional, em vez de forçar o TypeScript com uma declaração de tipo any ou as string. Vamos definir uma interface para o esquema esperado da string de consulta e aplicá-la ao tipo do objeto Request.
Esse endpoint GET funcionará como uma alternativa simplificada aos React Server Components. Ele receberá uma string de consulta com o nome do usuário e retornará uma resposta HTTP com o conteúdo HTML do componente que será renderizado no DOM.
Veja a implementação do componente no 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 você pode ver, estamos tipando a parte da string de consulta do objeto de tipo Request, conforme esperado e indicado por ReqQuery:
(alias) interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = qs.ParsedQs, Locals extends Record<string, any> = Record<string, any>>
import RequestPor causa do grave problema de segurança relacionado a Cross-Site Scripting (XSS), também vamos sanitizar a entrada do usuário. Por isso, chamamos uma função sanitizeXSS(). Veja a implementação:
function sanitizeXSS(name: string): boolean {
const disallowList = ["<", ">", "&", '"', "'", "/", "="];
return !disallowList.some((badInput) => name.includes(badInput));
}Agora, estabelecemos barreiras de segurança usando os seguintes mecanismos:
Nossa interface mapeia explicitamente o genérico
Request<T>. O quarto parâmetro,ReqQuery, é definido comoUserComponentQueryString, garantindo que a string de consulta siga a estrutura definida na nossa interface. Isso significa que_req.queryserá um objeto com uma propriedade name opcional do tipo string.Esperamos que os usuários possam abusar dessa lógica de criação de componentes e fornecer no campo de string de consulta
nameum valor como<img src=x onError=alert(1) />. Esse é um exemplo simplificado de como poderiam executar operações JavaScript maliciosas no lado do cliente. Para evitar isso, criamos uma funçãosanitizeXSSque detecta strings perigosas usadas na propriedadenamee impede que sejam incluídas no componente HTML gerado.
Vamos tentar enviar uma solicitação com esse payload malicioso:
$ curl -G -X 'GET' -H 'accept: application/json' "http://localhost:8080/users/component" --data-urlencode "name=<img liran"
Bad input detected!Como você pode ver, recebemos a resposta esperada de acordo com a nossa lógica de sanitização de caracteres maliciosos na entrada do usuário.
Mas o que acontece se aplicarmos o problema de segurança de coerção de tipos que aprendemos e mudarmos o campo name para name[]? Ele será recebido como um array?
$ 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>Contornamos nossas proteções de segurança! Como isso aconteceu?
O array name[] é passado para a função sanitizeXSS(), que tenta executar a função includes() no nome recebido, usando um predicado de correspondência com o array disallowList. No entanto, name não é uma string, mas um array. Por isso, o predicado nunca retorna o valor booleano true, e a lógica de sanitização contra Cross-Site Scripting falha.
Observações e aprendizados sobre boas práticas com TypeScript
Não use o curinga de tipo
anydo TypeScript.Forçar tipos, por exemplo com
_req.query.filter as string, é considerado uma má prática, que alguns chamam de “mentir para o TypeScript”.Você pode adotar a prática de narrowing de tipos no TypeScript, muitas vezes chamada de “type guard”, que verifica em tempo de execução se os tipos esperados estão presentes. Por exemplo:
if (typeof padding === "number") { … }. No entanto, isso pode se tornar uma prática recorrente em toda a base de código e prejudicar a legibilidade e a facilidade de manutenção.
O TypeScript é bom para a segurança? Sim, mas não foi criado para isso.
Na minha opinião, o TypeScript verifica os tipos durante o desenvolvimento. Embora seja possível implementar verificações de tipo, você também pode fazer isso com JavaScript puro. Para impor com eficácia os tipos e a estrutura esperados e validar os dados à medida que percorrem o código, o TypeScript precisa de um complemento em tempo de execução, na forma de validação de esquemas, como Zod e outras bibliotecas.
Adorado por desenvolvedores. Confiável para a segurança.
As ferramentas da Snyk, pensadas para desenvolvedores, oferecem segurança integrada e automatizada para atender às suas necessidades de governança e conformidade.