CORS avec Node.js

Pour faire face à la montée des menaces sur leurs utilisateurs, les navigateurs se sont au fil du temps enrichis de mécanismes qui visent à contrôler le contenu d'une page Web. En particulier, il faut évoquer :
CORS avec Node.js
Du fait qu'il ne fallait pas casser le Web en instaurant ces mécanismes, ces derniers ne changent pas grand-chose pour le développeur d'une application Web standard, au sens d'un ensemble de pages servies par un même serveur, qui n'affichent jamais que du contenu qui en provient.
C'est tout particulièrement vrai pour CSP, qui ne vient pas limiter des pratiques telles comme par exemple du script inline dans une balise <script>, tant que le développeur ne met pas lui-même en place CSP en accompagnant ses pages d'un header Content-Security-Policy.
Il est va autrement pour SOP et CORS, qui d'emblée limitent des pratiques. En particulier, il est impossible depuis le script d'une page Web de formuler une requête via l'API Fetch pour récupérer du contenu dont l'origine, entendue comme la composition du nom de domaine et du numéro de port, n'est pas celle de la page en question.
Or cela peut être nécessaire. Illustration avec une application Web où l'utilisateur accède à une page servie par un serveur Node.js (module http) à l'écoute sur le port 3000 de localhost, dans laquelle un script récupère du contenu en appelant un service exposé par un autre serveur Node.js (module http) à l'écoute sur le port 3001 du même localhost.
Cliquez ici pour récupérer tout le code présenté ci-après.
Noter que le code des serveurs s'appuie sur la syntaxe des modules d'ECMAScript et non de CommonJS. En effet, ECMAScript constitue la norme désormais ; il faut donc oublier CommonJS chaque fois que c'est possible. Pour cette raison, les fichiers portent l'extension .mjs. Par ailleurs, les codes commencent par définir require (), qu'ils utilisent pour importer les modules.

Création de la configuration

Ceux qui n'ont jamais utilisé Node.js peuvent se référer à cet article, publié il y a déjà quelques temps sur ce blog, pour comprendre en quoi la technologie, quand elle est mise en oeuvre pour créer un serveur HTTP, innove sur celle des serveurs HTTP classiques tels qu'Apache.
L'installation de Node.js est triviale. Pour rester dans le simple, le serveur HTTP utilisé ici s'appuie sur le module http qui est built-in, donc installé par défaut. Il est aussi possible d'utiliser le module express, mais il faut alors l'installer.

Un serveur HTTP avec le module http de Node.js

Pour cet exemple, le code du serveur HTTP qui sert la page index.html est simpliste. Il se contente de réagir à une requête GET qui cible http://localhost:3000/index.html et de renvoyer le contenu du fichier index.html :
import { createRequire } from "module";
const require = createRequire (import.meta.url);
const fs = require ("fs");
const http = require ("http");

function onServerRequest (req, res) {
	switch (req.method) {
		case "GET":
			let url = new URL (`http://${process.env.HOST ?? 'localhost'}${req.url}`);
			fs.readFile ("." + url.pathname, "utf8", (error, data) => {
				if (error) {
					console.log (`Error reading file ${url}`);
					return;
				}
				res.setHeader ("Content-Type", "text/html");
				res.end (data);
			});
			break;
	}
}

let ws = http.createServer (onServerRequest);
ws.listen (3000);

La page servie par http://localhost:3000

Le code de la page index.html est le suivant :
<!DOCTYPE HTML>
<html>
<head>
	<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
</head>
<body>

<input type="button" value="Test CORS" onclick="test ()"/>

<script>
function test () {
	let request = { question: "Who am I?" };
	let options = {
			method: "POST",
			mode: "cors",
			headers: {
				"Content-Type": "application/json"
			},
			body: JSON.stringify (request)
		};
	fetch ("http://localhost:3001", options).then (response => {
		if (response.ok)
			response.json ().then (json => console.log (json.answer));
		else
			console.log (`ERROR: ${response.statusText}`);
	}).catch (reason => {
		console.log (`ERROR: ${reason}`);
	});
}
</script>

</body>
</html>
Comme il est possible de le constater, la page contient un bouton qui permet à l'utilisateur d'exécuter la fonction test (). Cette fonction forme une requête POST dont le contenu est le JSON d'un objet trivial puisqu'il ne contient qu'une question. En retour, la fonction attend un objet tout aussi trivial qui contient une réponse.
Le serveur auquel test () adresse la requête n'est pas celui de la page. Pour cette raison, il est nécessaire de préciser mode: "cors" dans les options fournies à fetch ().
Pourquoi ne pas spécifier le mode no-cors puisqu'il existe ? Parce que dans ce cas, comme le précise ici la documentation de MDN, les restrictions appliquées pour préserver la sécurité sont draconiennes. En particulier, le navigateur ne donne pas accès au corps de la réponse au code de la page ! En fait, ce mode sert à envoyer des requêtes sans attente de réponse.

Le service exposé par http://localhost:3001

Le code du serveur HTTP qui reçoit la requête au format JSON est un peu plus compliqué que celui du serveur HTTP qui sert la page index.html. En effet, ce second serveur doit pouvoir dialoguer avec le navigateur qui, dans le cas d'une requête CORS, envoie d'abord une requête OPTIONS pour savoir si le serveur accepterait de répondre à une requête à suivre qui présenterait certaines caractéristiques décrites dans des headers CORS de la requête OPTIONS.
En l'espèce, les headers CORS sont :
access-control-request-method: POST
access-control-request-headers: content-type
Le serveur doit répondre en précisant de quelle origine il accepterait quel(s) type(s) de contenu, via quelle(s) méthode(s). Pour cela, il accompagne sa réponse de headers CORS :
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type
Sur cette base, le navigateur vérifie que le serveur accepterait sa requête. Dans l'affirmative, il l'envoie.
Le serveur doit alors répondre à cette requête, sans omettre cet header CORS :
Access-Control-Allow-Origin: *
En fait, plutôt que *, il est recommandé d'être plus restrictif, et pour cela d'utiliser l'origine de la page d'où provient la requête, c'est-à-dire http://localhost:3000.
Tout cela est transparent au niveau du code du client, qui constate simplement que la requête échoue si jamais le serveur n'a pas répondu favorablement. C'est le mécanisme des requêtes preflight.
import { createRequire } from "module";
const require = createRequire (import.meta.url);
const http = require ("http");

function onServerRequest (req, res) {
	switch (req.method) {
		case "OPTIONS":
			res.setHeader ("Access-Control-Allow-Origin", "*");
			res.setHeader ("Access-Control-Allow-Methods", "POST");
			res.setHeader ("Access-Control-Allow-Headers", "Content-Type");
			res.end ();
			break;
		case "POST":
			let body = "";
			res.setHeader ("Access-Control-Allow-Origin", "*");
			req.on ("data", chunk => body += chunk);
			req.on ("end", () => {
				let request = JSON.parse (body);
				console.log (`${request.question}`);
				let response = { answer: "You are client!" };
				res.setHeader ("Content-Type", "application/json");
				res.end (JSON.stringify (response));
			});
			break;
	}
}

let ws = http.createServer (onServerRequest);
ws.listen (3001);
CORS avec Node.js