Aller au contenu

Express : sécurité et production

Passage en production d’une application Express derrière un reverse proxy. Noms génériques : application monapi, utilisateur monapi, dossier /srv/monapi, port local 3000, domaine exemple.com.

Fenêtre de terminal
npm install helmet express-rate-limit
import express from 'express';
import helmet from 'helmet';
import { rateLimit } from 'express-rate-limit';
const app = express();
app.disable('x-powered-by'); // Helmet le fait aussi
app.set('trust proxy', 1); // un proxy devant l'application
app.use(helmet());
app.use(
rateLimit({
windowMs: 15 * 60 * 1000,
limit: 300,
standardHeaders: 'draft-7',
legacyHeaders: false,
}),
);
app.use(express.json({ limit: '100kb' }));
Mesure Rôle
helmet() En-têtes de sécurité (Content-Security-Policy, X-Content-Type-Options, …)
rateLimit Limite le nombre de requêtes par adresse (force brute, abus)
trust proxy Fait lire l’adresse du client dans X-Forwarded-For (indispensable derrière un proxy pour req.ip et la limitation de débit)
express.json({ limit }) Limite la taille des corps
app.disable('x-powered-by') Ne pas annoncer Express

Limiter plus strictement les routes sensibles :

const limiteConnexion = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10 });
app.post('/api/connexion', limiteConnexion, (req, res) => {
/* ... */
});
  • Valider toutes les entrées (voir API JSON).
  • Requêtes SQL paramétrées uniquement.
  • Secrets dans l’environnement, jamais dans le code ni le dépôt.
  • npm audit régulier, npm ci en production, dépendances à jour.
  • Ne pas renvoyer les traces d’erreurs au client.
  • CORS limité aux origines nécessaires.
  • Exécuter sous un utilisateur sans privilèges, en HTTPS derrière le proxy.
Fenêtre de terminal
npm audit
npm audit --omit=dev
npm outdated

/etc/monapi.env :

NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://app@127.0.0.1:5432/boutique
SESSION_SECRET=<valeur-aleatoire-longue>
Fenêtre de terminal
sudo chown root:monapi /etc/monapi.env
sudo chmod 640 /etc/monapi.env
openssl rand -base64 48 # générer un secret

NODE_ENV=production active des optimisations d’Express (cache des vues, messages d’erreur réduits).

Fenêtre de terminal
sudo adduser --system --group --home /srv/monapi monapi
sudo -u monapi git clone https://git.exemple.com/monapi.git /srv/monapi/app
cd /srv/monapi/app
sudo -u monapi npm ci --omit=dev

/etc/systemd/system/monapi.service :

[Unit]
Description=API monapi (Express)
After=network.target
[Service]
User=monapi
Group=monapi
WorkingDirectory=/srv/monapi/app
EnvironmentFile=/etc/monapi.env
ExecStart=/usr/bin/node src/serveur.js
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/monapi
[Install]
WantedBy=multi-user.target
Fenêtre de terminal
sudo systemctl daemon-reload
sudo systemctl enable --now monapi
curl -I http://127.0.0.1:3000/sante
sudo journalctl -u monapi -f

Le chemin de node se lit avec which node (avec nvm, utiliser le chemin complet de la version installée). Voir systemd : durcissement d’un service et Node.js : mise en production.

Pour exploiter tous les cœurs et recharger sans coupure : PM2. Les sessions et caches doivent alors être hors de la mémoire du processus (base de données, Redis).

exemple.com {
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000"
-Server
}
reverse_proxy 127.0.0.1:3000
}

Caddy gère HTTPS et la compression : ne pas ajouter le middleware compression dans l’application. Les fichiers statiques peuvent être servis directement :

exemple.com {
handle /assets/* {
root * /srv/monapi/app/public
file_server
}
reverse_proxy 127.0.0.1:3000
}

Voir Caddy : exemples réutilisables. Avec Nginx : Nginx : reverse proxy.

L’application écoute sur 127.0.0.1 : seul le reverse proxy (80 et 443) est exposé.

Fenêtre de terminal
sudo ufw allow 80,443/tcp
sudo ss -tlnp | grep 3000 # doit afficher 127.0.0.1:3000

Voir Exemple : VPS avec Caddy.

import morgan from 'morgan';
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));

Écrire sur la sortie standard : journald collecte (journalctl -u monapi). Ne pas journaliser de secrets, de mots de passe ni de données personnelles superflues.

const serveur = app.listen(port, '127.0.0.1');
function arreter() {
serveur.close(() => process.exit(0));
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on('SIGTERM', arreter);
process.on('SIGINT', arreter);

systemd envoie SIGTERM à l’arrêt : le serveur termine les requêtes en cours avant de s’arrêter.

Ajouter une route de santé et la tester depuis l’extérieur :

app.get('/sante', (req, res) =>
res.json({ statut: 'ok', uptime: process.uptime() }),
);
Fenêtre de terminal
curl -fsS https://exemple.com/sante
Symptôme Piste
502 Bad Gateway Service arrêté ou mauvais port : systemctl status monapi, curl http://127.0.0.1:3000/sante
Le service redémarre en boucle journalctl -u monapi -n 50 : variable manquante, port occupé, import raté
Limitation de débit appliquée à tout le monde trust proxy absent
req.protocol vaut http derrière HTTPS trust proxy et en-tête X-Forwarded-Proto (Caddy le transmet)
Cookie secure jamais enregistré HTTPS non terminé correctement ou trust proxy manquant
ReferenceError/Cannot find module en production npm ci --omit=dev : une dépendance utilisée est déclarée en devDependencies
Erreur d’écriture dans uploads/ ReadWritePaths et droits du dossier

Ubuntu 24.04 et 26.04 : différences à connaître

Section intitulée « Ubuntu 24.04 et 26.04 : différences à connaître »

La procédure est identique. Le chemin de node et sa version dépendent de l’installation (voir Node.js : installation). Sur 26.04, sudo est fourni par sudo-rs : les commandes ci-dessus fonctionnent sans changement.