In this article
TypeScript suffit-il à assurer la sécurité des applications ?
TypeScript, un surensemble de JavaScript, introduit le typage statique, une fonctionnalité puissante qui améliore la maintenabilité du code. Mais renforce-t-il aussi nos attentes en matière de sécurité ?
Dans cet article, je souhaite partager mon point de vue sur les idées reçues concernant la sécurité de TypeScript et expliquer comment des pièges surprenants peuvent décevoir les attentes des développeurs.
Quelles sont les promesses de sécurité de TypeScript ? Commençons par remettre le problème en question.
Supposons que vous comptiez sur TypeScript pour la sécurité des types. Voici une question rapide : cet exemple montre-t-il comment TypeScript contrôle tout et n’autorise que ce qui est explicitement permis ?
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);
}Plus important encore, j’aimerais vous demander : l’adoption de TypeScript vous semble-t-elle utile pour vérifier les types pendant le développement, ou pensez-vous qu’elle offre aussi une véritable sécurité à l’exécution ?
Si vous avez répondu « sécurité à l’exécution », embarquez avec nous pour découvrir les contrôles et les idées reçues en matière de sécurité avec TypeScript.
Introduction à TypeScript
TypeScript permet aux développeurs de détecter les erreurs potentielles dès le début du cycle de développement en définissant explicitement les types de données des variables, des fonctions et des objets. En adoptant TypeScript, cette approche proactive et complémentaire pour développer des applications au-dessus de JavaScript réduit considérablement le risque d’erreurs à l’exécution, de vulnérabilités et de comportements inattendus.
Cette présentation de TypeScript met l’accent sur la réduction des erreurs de type à l’exécution. TypeScript est, par définition et dans la pratique, un outil de développement. Les développeurs écrivent leur code en TypeScript, puis le compilent en JavaScript. L’écosystème d’outils TypeScript permet d’assurer la sûreté des types grâce à l’intelligence des IDE et de vérifier les types du code à l’aide d’un processus de build et d’intégration continue.
Voici un exemple de définition de gestionnaire de route d’API dans une application Express développée avec 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 et sécurité
Un problème de sécurité lié aux types en programmation est appelé coercition de type. Elle se produit lorsque JavaScript tente implicitement de convertir les valeurs d’un type en celles d’un autre. Vous avez probablement déjà rencontré ce phénomène dans JavaScript sous la forme de différentes énigmes liées à ses « bizarreries ».
Par exemple :
// prints true
let x = 1;
console.log(x == "1")Le code ci-dessus affiche « true » en raison du mécanisme de coercition de type. JavaScript convertit d’abord les deux opérandes vers le même type, puis les compare. Ainsi, « 1 » est analysé comme le nombre 1, puis comparé, ce qui donne un résultat évalué comme vrai.
En définissant les types attendus des variables, des paramètres de fonction et des valeurs de retour, TypeScript peut détecter de nombreuses erreurs liées aux types pendant le développement, avant qu’elles ne puissent être exploitées en production. Mais pouvez-vous vous y fier ? La coercition de type peut entraîner des vulnérabilités, car les types ne sont pas strictement vérifiés à l’exécution, ce qui permet de contourner le type pour lequel ils ont été définis.
Une application Express avec TypeScript
Pour poser le cadre des exemples suivants, nous utiliserons une API backend Node.js reposant sur le framework web Express et intégrant TypeScript.
Plus précisément, l’application d’API serveur Node.js est structurée comme suit :
src/
api/
user/
userController.ts
userRouter.ts
userModel.ts
userService.ts
userRepository.ts
server.tsLe code du serveur Express charge la définition des routes, les intergiciels et les autres configurations comme suit :
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);Nous nous appuierons sur l’exemple userRouter tout au long des exemples de sécurité TypeScript présentés dans cet article.
Contournement de sécurité TypeScript n° 1
Nous avons un point de terminaison /users qui sert d’endpoint d’API REST. Il permet aux utilisateurs de rechercher leur propre profil et, plus précisément, de transmettre une chaîne de filtre pour trouver leur nom.
Voici le modèle de dépôt qui montre comment ce filtrage est effectué : il expose une méthode findAllAsync qui reçoit un objet doté d’une propriété filter.
export class UserRepository {
async findAllAsync({ filter }: { filter?: string } = {}): Promise<User[]> {
if (filter) {
return users.filter((user) => user.name.startsWith(filter));
}
return users;
}
}Pour interroger les utilisateurs, une requête HTTP GET est envoyée :
$ curl -X 'GET' -H 'accept: application/json' "http://localhost:8080/users?filter=Al"| jq
Comment la chaîne de requête filter passe-t-elle de la définition de route à la couche du dépôt ? Explorons ce modèle et voyons comment utiliser TypeScript.
Voici la définition de la route HTTP : le code du contrôleur pour la route /, au préfixe /users, correspond à toutes les requêtes HTTP utilisant la méthode GET :
import { userController } from "./userController";
export const userRouter: Router = express.Router();
userRouter.get("/", userController.getUsers);Voici le code du contrôleur Express qui gère les requêtes HTTP GET pour cette route :
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);
};
}Comme userRepository, la définition du service que le contrôleur appelle ici, userService, a également été mise à jour pour accepter, en premier argument de fonction, un objet doté d’une propriété filter et conforme au type filterQuery.
La réponse HTTP à notre requête curl listera tous les utilisateurs dont le nom commence par la lettre « Al ». Nous obtenons donc :
$ 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
}Jusqu’ici, rien de surprenant. Mais que se passerait-il si la charge utile de la requête GET transmettait la chaîne de requête filter de façon à ce qu’elle soit interprétée comme un tableau plutôt que comme une chaîne ?
Voici la charge utile d’exploitation qui provoquerait un problème de sécurité lié à la coercition de type dans notre application web Node.js. Il suffit de remplacer le champ de chaîne de requête filter= par filter[]= pour obtenir la même réponse :
$ 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
}Cela fonctionne comme précédemment, car JavaScript convertit le tableau selon sa définition toString, ce qui renvoie le premier élément du tableau, la chaîne Al. Le comportement reste le même, même si la variable filter est désormais traitée en interne comme une chaîne. Est-ce problématique ? Nous le verrons.
Vous avez peut-être remarqué à ce stade la définition TypeScript désastreuse dans le code du contrôleur :
const filterQuery: any = _req.query.filter || '';
const serviceResponse = await userService.findAll({ filter: filterQuery });Utiliser la magie noire de any en TypeScript est une pratique non conventionnelle et déconseillée, peut-être juste après l’utilisation d’eval en JavaScript. Il n’est donc pas étonnant que la coercition de type existe. Nous avons autorisé la chaîne de requête filter à correspondre à n’importe quel type, puis nous l’avons utilisée dans le service utilisateur et le dépôt.
Faisons mieux.
Contournement de sécurité TypeScript n° 2
Nous allons éviter le type générique spécial any de TypeScript et définir plutôt explicitement et strictement la variable filter comme étant de type chaîne.
Modifions l’implémentation de la route en indiquant le type chaîne 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);
};Pour vérifier que tous les types sont correctement définis et qu’il n’y a aucune erreur de définition, nous exécutons le compilateur tsc et confirmons qu’il n’y a effectivement aucune erreur :
$ npx tscQue se passerait-il si nous envoyions la même charge utile HTTP provoquant une coercition de type, avec filter défini comme un tableau ? Voyons cela :
$ 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
}Même si nous avons explicitement défini la chaîne de requête filter comme étant de type chaîne et que le compilateur TypeScript s’est exécuté sans erreur, nous pouvons toujours lancer une attaque par coercition de type. Bien entendu, cette attaque ne fait que démontrer les possibilités et n’a rien de malveillant.
Contournement de sécurité TypeScript n° 3
Essayons un autre mécanisme TypeScript pour déclarer les types.
Nous allons adopter une stratégie de typage plus conventionnelle, plutôt que de forcer TypeScript avec une déclaration de type any ou as string. Nous définirons l’interface correspondant au schéma attendu de la chaîne de requête et l’appliquerons au type d’objet Request.
Ce point de terminaison GET servira d’alternative simplifiée aux composants serveur React. Il recevra une chaîne de requête contenant le nom de l’utilisateur et renverra une réponse HTTP incluant le code HTML du composant qui sera affiché dans le DOM.
Voici l’implémentation du composant côté serveur :
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);
}
}Comme vous pouvez le voir, nous typions la partie chaîne de requête de l’objet de type Request, comme prévu et indiqué par ReqQuery :
(alias) interface Request<P = core.ParamsDictionary, ResBody = any, ReqBody = any, ReqQuery = qs.ParsedQs, Locals extends Record<string, any> = Record<string, any>>
import RequestCompte tenu du grave problème de sécurité lié aux scripts intersites (XSS), nous allons également assainir les entrées utilisateur, d’où l’appel à une fonction sanitizeXSS(). En voici l’implémentation :
function sanitizeXSS(name: string): boolean {
const disallowList = ["<", ">", "&", '"', "'", "/", "="];
return !disallowList.some((badInput) => name.includes(badInput));
}Nous avons donc mis en place des garde-fous de sécurité à l’aide des mécanismes suivants :
Notre interface associe explicitement le type générique
Request<T>. Le quatrième paramètre,ReqQuery, est défini surUserComponentQueryString, ce qui garantit que la chaîne de requête respecte la structure définie dans notre interface. Cela signifie que_req.querysera un objet doté d’une propriété name facultative de type chaîne.Nous nous attendons à ce que des utilisateurs détournent cette logique de création de composant et fournissent un champ de chaîne de requête
nameavec une valeur telle que<img src=x onError=alert(1) />, exemple simplifié de la manière dont ils pourraient exécuter des opérations JavaScript malveillantes côté client. Pour éviter cela, nous avons créé une fonctionsanitizeXSSqui détecte les chaînes dangereuses utilisées pour la propriéténameet empêche leur utilisation dans le composant HTML généré.
Essayons d’envoyer une requête contenant une telle charge utile malveillante :
$ curl -G -X 'GET' -H 'accept: application/json' "http://localhost:8080/users/component" --data-urlencode "name=<img liran"
Bad input detected!Comme vous pouvez le constater, nous avons reçu la réponse attendue, conformément à notre logique d’assainissement des caractères malveillants dans les entrées utilisateur.
Cependant, que se passe-t-il si nous appliquons le problème de sécurité lié à la coercition de type que nous avons découvert et remplaçons le champ name par name[] ? Sera-t-il transmis sous forme de tableau ?
$ 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>Nous avons contourné nos garde-fous de sécurité ! Comment cela s’est-il produit ?
Le tableau name[] est transmis à la fonction sanitizeXSS(), qui tente d’exécuter la fonction includes() sur le nom reçu, à l’aide du prédicat de correspondance du tableau disallowList. Cependant, name n’est pas une chaîne, mais un tableau. Le prédicat ne renvoie donc jamais la valeur booléenne true et la logique d’assainissement contre les scripts intersites échoue.
Observations et enseignements sur les bonnes pratiques TypeScript
N’utilisez pas le type générique
anyde TypeScript.Forcer les types, par exemple avec
_req.query.filter as string, est considéré comme une mauvaise pratique, parfois appelée « mentir à TypeScript ».Vous pouvez adopter la pratique du rétrécissement de type TypeScript, souvent appelée « garde de type », qui effectue des vérifications à l’exécution pour confirmer les types attendus. Par exemple :
if (typeof padding === "number") { … }. Toutefois, cette pratique risque de se répéter dans toute la base de code et de nuire à la lisibilité et à la maintenabilité.
TypeScript est-il bon pour la sécurité ? Oui, mais ce n’est pas sa vocation.
À mon avis, TypeScript sert à effectuer des vérifications pendant le développement. Même si vous pouvez mettre en œuvre des gardes de type, vous pouvez aussi le faire avec du JavaScript standard. Pour vérifier efficacement les types, la structure et les données attendus au fil de leur circulation dans le code, TypeScript doit être complété par un outil d’exécution de validation de schéma, comme Zod et d’autres bibliothèques.
Apprécié par les développeurs. Les équipes de sécurité lui font confiance.
Les outils Snyk, conçus pour les développeurs, offrent une sécurité intégrée et automatisée qui répond à vos exigences de gouvernance et de conformité.