Skip to main content

Comment créer une passerelle API sécurisée avec Node.js

Écrit par

Florian Rappl

28 décembre 2022

0 minutes de lecture

Les microservices offrent des avantages considérables par rapport aux architectures monolithiques. Ils facilitent la mise à l’échelle du développement et vous donnent un contrôle précis sur celle de l’infrastructure. De plus, la possibilité d’effectuer de nombreuses petites mises à jour et des déploiements progressifs réduit considérablement le délai de mise sur le marché.

Malgré ces avantages, l’architecture en microservices pose un problème : l’impossibilité d’accéder à ses services depuis l’extérieur. Heureusement, une passerelle API permet de résoudre ce problème.

Les passerelles API offrent à vos applications front-end (par exemple, les sites web et les applications natives) un accès clair à toutes les fonctionnalités back-end. Elles peuvent servir d’agrégateurs pour les microservices et de middleware pour gérer des problématiques courantes, comme l’authentification et l’autorisation. Elles facilitent également le regroupement et l’unification des services, en combinant des formats de sortie comme XML et JSON en un seul format.

Les passerelles API sont des outils essentiels pour la sécurité et la fiabilité. Elles assurent la gestion des sessions, le nettoyage des entrées, la protection contre les attaques par déni de service distribué (DDoS), la limitation du débit et la journalisation des transactions.

Dans cet article, nous allons créer de toutes pièces une passerelle API sécurisée en utilisant uniquement Node.js et quelques packages open source. Vous avez simplement besoin de connaître les bases du terminal, de disposer de Node.js version 14 ou ultérieure et de connaître JavaScript. Vous trouverez le code final du projet ici.

C’est parti !

Créer notre passerelle API

Même si nous pourrions écrire un serveur web de toutes pièces avec Node.js, nous allons ici nous appuyer sur un framework existant pour faire le gros du travail. Express est sans doute le choix le plus populaire pour Node.js : léger, relativement rapide et facilement extensible. De plus, vous pouvez intégrer presque tous les packages nécessaires à Express.

Pour commencer, créons un nouveau projet Node.js et installons Express :

npm init -y
npm install express --save

Avec ces deux commandes, nous avons créé un projet Node.js en utilisant les paramètres par défaut. Nous avons également installé le package express. À ce stade, notre répertoire de projet devrait contenir un fichier package.json et un répertoire node_modules.

Nous devons maintenant créer un fichier nommé index.js, que nous placerons à côté de package.json. Il doit contenir le code initial suivant :

const express = require("express");

const app = express();
const port = 3000;

app.get("/", (req, res) => {
  const { name = "user" } = req.query;
  res.send(`Hello ${name}!`);
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

Cette configuration suffit pour exécuter un serveur web simple avec un point de terminaison. Vous pouvez le démarrer en exécutant la commande suivante dans votre terminal :

node index.js

Dans votre navigateur, accédez ensuite à http://localhost:3000. Le message « Hello user! » devrait s’afficher à l’écran. En ajoutant un paramètre à l’URL, par exemple ?name=King, vous devriez voir « Hello King! ».

L’étape suivante consiste à configurer l’authentification. Pour cela, nous utilisons le package express-session. Celui-ci s’appuie sur un cookie pour protéger nos points de terminaison. Les utilisateurs qui ne possèdent pas de cookie de session valide recevront une réponse appropriée avec le code d’état HTTP 401 (Unauthorized) ou 403 (Forbidden).

Nous allons également mieux protéger notre mot de passe en le stockant dans une variable d’environnement. Le package dotenv nous permettra de charger la variable SESSION_SECRET depuis un fichier .env dans process.env.

Commencez par arrêter le processus précédent en saisissant Ctrl + C dans votre console. Installez ensuite express-session et dotenv à l’aide de la commande suivante :

npm install express-session dotenv --save

Pour savoir comment configurer et établir une session sécurisée, consultez la documentation d’express-session sur GitHub. Vous y trouverez notamment comment définir l’indicateur secure du cookie sur true afin qu’il soit transmis uniquement via des connexions HTTPS. Il convient également de mettre en place d’autres contrôles de sécurité pour la gestion des sessions, par exemple régénérer l’identifiant de session lors d’opérations sensibles comme la connexion ou le changement de mot de passe.

Créez ensuite un fichier .env à la racine de votre projet et ajoutez-y le code suivant :

SESSION_SECRET=`<your_secret>`

Par convention, les variables d’environnement s’écrivent en majuscules. Veillez à utiliser un secret unique et aléatoire : un mot de passe robuste fera l’affaire.

Voici à quoi doivent ressembler les ajouts apportés à index.js (nous réutiliserons cet extrait plus loin pour assembler la solution) :

require("dotenv").config();

const session = require("express-session");

const secret = process.env.SESSION_SECRET;
const store = new session.MemoryStore();
const protect = (req, res, next) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    res.sendStatus(401);
  } else {
    next();
  }
};

app.use(
  session({
    secret,
    resave: false,
    saveUninitialized: true,
    store,
  })
);

Cette configuration met en place une gestion des sessions en mémoire, que vous pouvez utiliser pour protéger des points de terminaison. Ajoutons un nouveau point de terminaison et créons des points de terminaison dédiés à la connexion et à la déconnexion. Ajoutez le code suivant à la fin de index.js :

app.get("/login", (req, res) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    req.session.authenticated = true;
    res.send("Successfully authenticated");
  } else {
    res.send("Already authenticated");
  }
});

app.get("/logout", protect, (req, res) => {
  req.session.destroy(() => {
    res.send("Successfully logged out");
  });
});

app.get("/protected", protect, (req, res) => {
  const { name = "user" } = req.query;
  res.send(`Hello ${name}!`);
});

Relancez le code pour le tester en exécutant la commande node index.js.

Comment cela fonctionne-t-il ? Voici un parcours d’utilisation simple :

  • L’accès à / fonctionne.

  • L’accès à /protected renvoie le code d’état HTTP 401 (Unauthorized).

  • L’accès à /login vous connecte automatiquement et affiche « Successfully authenticated. »

  • Un nouvel accès à /login affiche « Already authenticated. »

  • Vous pouvez alors accéder à /protected.

  • L’accès à /logout réinitialise la session et affiche « Successfully logged out. »

  • Le point de terminaison /protected redevient alors inaccessible et affiche le code d’état 401.

  • Un nouvel accès à /logout renvoie également le code d’état 401.

En utilisant le middleware protect avant le gestionnaire de requêtes, nous pouvons protéger un point de terminaison en vérifiant que l’utilisateur est connecté. Nous avons également les points de terminaison /login et /logout pour compléter ces fonctionnalités.

Arrêtons de nouveau le processus avec Ctrl + C. Avant d’aborder le cœur de notre passerelle API, examinons la journalisation et la limitation du débit.

Limitation du débit

La limitation du débit garantit que votre API ne peut être appelée qu’un nombre donné de fois pendant une période définie. Elle la protège ainsi contre l’épuisement de la bande passante dû au trafic normal ou aux attaques par déni de service (DoS). Vous pouvez configurer des limites de débit pour le trafic provenant de sources spécifiques. Il existe par ailleurs de nombreuses façons de calculer et d’appliquer la fenêtre temporelle pendant laquelle les requêtes sont traitées.

Commençons par installer un package de limitation du débit :

npm install express-rate-limit --save

Configurons ensuite le package. Insérez ce code dans le fichier index.js, avant les routes auxquelles vous souhaitez appliquer une limite :

const rateLimit = require("express-rate-limit");

app.use(
  rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 5, // 5 calls
  })
);

Pour tester, redémarrez le serveur avec la commande node index.js et appelez plusieurs fois notre point de terminaison initial. Lorsque vous aurez atteint la limite de cinq requêtes en 15 minutes, le message « Too many requests, please try again later. » s’affichera. Par défaut, express-rate-limit renvoie également le code d’état HTTP 429 (Too Many Requests).

Avant de passer à l’étape suivante, arrêtez le processus pour réinitialiser votre limite.

Journalisation

Pour mettre en place la journalisation, nous pouvons utiliser winston, qui propose également un middleware dédié à Express : express-winston. Nous pouvons aussi journaliser les temps de réponse des points de terminaison pour les examiner de plus près par la suite. Le package response-time est particulièrement utile pour cela. Installons ces packages.

npm install winston express-winston response-time --save

L’intégration de ces packages est simple. Ajoutez le code suivant à index.js, avant tout code que vous souhaitez journaliser. Il est donc préférable de l’insérer avant le code de limitation du débit :

const winston = require("winston");
const expressWinston = require("express-winston");
const responseTime = require("response-time");

app.use(responseTime());

app.use(
  expressWinston.logger({
    transports: [new winston.transports.Console()],
    format: winston.format.json(),
    statusLevels: true,
    meta: false,
    msg: "HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms",
    expressFormat: true,
    ignoreRoute() {
      return false;
    },
  })
);

Cette configuration génère des informations supplémentaires. Au démarrage du serveur, si vous accédez à /, la console devrait afficher ce qui suit :

Server running at http://localhost:3000
{"level":"info","message":"GET / 200 8ms","meta":{}}
{"level":"warn","message":"GET /favicon.ico 404 3ms","meta":{}}

Lorsque vous êtes prêt à continuer, arrêtez le processus avec Ctrl + C.

Partage des ressources entre origines (CORS)

Notre passerelle API servant d’intermédiaire entre les services front-end et back-end, nous allons y gérer le partage des ressources entre origines (CORS). CORS est un mécanisme de sécurité du navigateur qui garantit que le back-end accepte certaines requêtes interorigines (par exemple, des requêtes de www.company.com vers api.company.com).

Pour cela, nous envoyons une requête spéciale avant la requête principale. Cette requête utilise la méthode OPTION HTTP et attend des en-têtes spécifiques dans la réponse, qui autorisent ou refusent les requêtes suivantes.

Pour activer CORS dans notre passerelle, nous pouvons installer le package cors. Nous pouvons également ajouter des en-têtes de sécurité supplémentaires à l’aide d’un package utile appelé helmet. Installez les deux packages avec la commande suivante :

npm install cors helmet --save

Nous pouvons les intégrer comme suit :

const cors = require("cors");
const helmet = require("helmet");

app.use(cors());
app.use(helmet());

Cette configuration permet à tous les domaines d’accéder à l’API. Nous pourrions définir des paramètres plus précis, mais ceux-ci suffisent pour le moment.

Proxy

Une passerelle API transfère principalement les requêtes vers d’autres microservices dédiés, afin de router les requêtes liées à la logique métier et les autres requêtes HTTP. Nous avons donc besoin d’un package pour gérer ce transfert : un proxy. Nous allons utiliser http-proxy-middleware, que vous pouvez installer avec la commande suivante :

npm install http-proxy-middleware --save

Ajoutons-le maintenant, ainsi qu’un nouveau point de terminaison :

const { createProxyMiddleware } = require("http-proxy-middleware");

app.use(
  "/search",
  createProxyMiddleware({
    target: "http://api.duckduckgo.com/",
    changeOrigin: true,
    pathRewrite: {
      [`^/search`]: "",
    },
  })
);

Pour tester, démarrez le serveur et accédez à /search?q=x&format=json. Vous obtiendrez les résultats de la requête transmise à http://api.duckduckgo.com/. Si vous exécutez le code, arrêtez le processus une fois le test terminé pour pouvoir appliquer les dernières modifications.

Configuration

Maintenant que nous avons tous les éléments, configurons-les pour qu’ils fonctionnent ensemble. Pour cela, créons un fichier nommé config.js dans le même répertoire que les autres fichiers :

require("dotenv").config();

exports.serverPort = 3000;
exports.sessionSecret = process.env.SESSION_SECRET;
exports.rate = {
  windowMs: 5 * 60 * 1000,
  max: 100,
};
exports.proxies = {
  "/search": {
    protected: true,
    target: "http://api.duckduckgo.com/",
    changeOrigin: true,
    pathRewrite: {
      [`^/search`]: "",
    },
  },
};

Nous avons défini ici la configuration essentielle de notre passerelle API : son port, sa clé de chiffrement des sessions par cookie et les différents points de terminaison à utiliser comme proxy. Les options de chaque proxy correspondent à celles de la fonction createProxyMiddleware, auxquelles s’ajoute la clé protected.

Modifions également les déclarations de secret et de port, ainsi que l’appel à rateLimit dans index.js, pour qu’ils utilisent les valeurs définies dans notre fichier de configuration :

const secret = config.sessionSecret;
...
const port = config.serverPort;
...
app.use(rateLimit(config.rate));

Nous devons également supprimer le code dont les fonctionnalités ont été déplacées dans config.js, ainsi que le code utilisé pour tester notre point de terminaison initial. Supprimez le code suivant de index.js :

require("dotenv").config();
...
app.use(
 "/search",
 createProxyMiddleware({
   target: "http://api.duckduckgo.com/",
   changeOrigin: true,
   pathRewrite: {
 [`^/search`]: "",
   },
 })
);
...
app.get("/", (req, res) => {
 const { name = "user" } = req.query;
 res.send(`Hello ${name}!`);
});
...
app.get("/protected", protect, (req, res) => {
 const { name = "user" } = req.query;
 res.send(`Hello ${name}!`);
});

Application finale

Passons en revue le contenu du fichier index.js final, qui comporte quelques petits ajouts :

// import all the required packages
const cors = require("cors");
const express = require("express");
const session = require("express-session");
const rateLimit = require("express-rate-limit");
const expressWinston = require("express-winston");
const helmet = require("helmet");
const { createProxyMiddleware } = require("http-proxy-middleware");
const responseTime = require("response-time");
const winston = require("winston");
const config = require("./config");

// configure the application
const app = express();
const port = config.serverPort;
const secret = config.sessionSecret;
const store = new session.MemoryStore();

Ajoutons une logique pour vérifier les valeurs de la propriété protected des proxys répertoriés dans config.js. Si elles sont définies sur false, la fonction alwaysAllow que nous définissons ici transmet la requête au gestionnaire suivant :

const alwaysAllow = (_1, _2, next) => {
  next();
};
const protect = (req, res, next) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    res.sendStatus(401);
  } else {
    next();
  }
};

Certaines technologies de serveur plus anciennes ajoutent également à l’en-tête HTTP des informations descriptives non fonctionnelles sur le serveur. Pour sécuriser notre API, nous allons les supprimer afin de divulguer moins d’informations à d’éventuels acteurs malveillants :

app.disable("x-powered-by");

app.use(helmet());

app.use(responseTime());

app.use(
  expressWinston.logger({
    transports: [new winston.transports.Console()],
    format: winston.format.json(),
    statusLevels: true,
    meta: false,
    level: "debug",
    msg: "HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms",
    expressFormat: true,
    ignoreRoute() {
      return false;
    },
  })
);

app.use(cors());

app.use(rateLimit(config.rate));

app.use(
  session({
    secret,
    resave: false,
    saveUninitialized: true,
    store,
  })
);

app.get("/login", (req, res) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    req.session.authenticated = true;
    res.send("Successfully authenticated");
  } else {
    res.send("Already authenticated");
  }
});

Nous parcourons ensuite les proxys répertoriés dans config.js, vérifions la valeur de leur paramètre protected, appelons la fonction protect ou alwaysAllow définie précédemment, puis ajoutons un nouveau proxy pour chaque entrée configurée :

Object.keys(config.proxies).forEach((path) => {
  const { protected, ...options } = config.proxies[path];
  const check = protected ? protect : alwaysAllow;
  app.use(path, check, createProxyMiddleware(options));
});

app.get("/logout", protect, (req, res) => {
  req.session.destroy(() => {
    res.send("Successfully logged out");
  });
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

Ce code est déjà assez flexible, mais il utilise HTTP standard plutôt que HTTPS. Cela peut suffire dans de nombreux cas. Par exemple, dans Kubernetes, le code peut se trouver derrière une entrée NGINX qui gère TLS et s’occupe du certificat.

Il peut toutefois arriver que nous souhaitions exposer directement le code en cours d’exécution sur Internet. Dans ce cas, nous aurions besoin d’un certificat valide.

Une autorité de certification comme Let’s Encrypt propose plusieurs méthodes pour activer HTTPS. Vous pouvez utiliser un client comme Greenlock Express pour gérer automatiquement vos certificats, ou adopter une approche plus précise avec un client comme Publishlab acme-client.

const { readFileSync } = require('fs');
const { createServer } = require('https');

// assumes that the key and certificate are stored in a "cert" directory
const credentials = {
  key: readFileSync('cert/server.key', 'utf8'),
  cert: readFileSync('cert/server.crt', 'utf8'),
};

// here we use the express "app" to attach to the created server
const httpsServer = createServer(credentials, app);

Notez que cette opération nécessite sudo, mais c’est de toute façon prévu : HTTPS n’est utile ici que si le serveur est directement exposé. Nous avons donc besoin du port 443 plutôt que d’un port public comme 8443.

// use standard HTTPS port
httpsServer.listen(443);

Conclusion

Nous avons terminé la création, à partir de zéro, d’une passerelle API sécurisée avec Node.js. Nous avons implémenté des fonctionnalités telles que la gestion des sessions, la limitation du débit, les proxys, la journalisation et CORS. Nous avons également appris à configurer Node.js pour utiliser TLS et imposer l’accès via HTTPS.

L’utilisation d’une passerelle API présente des avantages considérables pour les infrastructures back-end de grande taille. En plaçant les services back-end derrière une passerelle API, nous pouvons facilement appliquer des protocoles de sécurité communs, ce qui simplifie considérablement nos services et les protège contre les vulnérabilités courantes.

Publié dans:

Lire la suite

Live Stream

Remediation Agent : pourquoi corriger vaut mieux que détecter

Découvrez comment le Remediation Agent de Snyk s’appuie sur des renseignements de sécurité, une analyse des risques de rupture et une validation pour transformer les vulnérabilités en demandes de fusion prêtes à être intégrées.

feature ai ide dark
Blog

Premier aperçu d’Evo Agentic AppSec : remédiation agentique et défense contre le code malveillant

Découvrez les premières capacités d’Agentic AppSec de Snyk : un Remediation Agent autonome qui corrige les vulnérabilités et une défense contre le code malveillant qui bloque les packages à risque avant leur mise en production.

feature insights context
Blog

Dans les coulisses de la compromission de keyv sur npm : malware preinstall, provenance fiable et hooks d’IDE

keyv 6.0.0 et dix versions npm associées ont diffusé un malware à l’installation. Découvrez les versions touchées, les empreintes, les étapes de détection et l’ordre de remédiation recommandé.