Téléversements AJAX sécurisés avec sessions et contrôles CSRF
Un téléversement AJAX n’est accepté que lorsque le serveur a vérifié la session, les droits d’accès, la requête et le contenu du fichier. Ce guide pas à pas vous fournit un formulaire de navigateur exécutable et un serveur Node.js qui appliquent ces contrôles, indiquent la progression du téléversement et permettent à chaque utilisateur de télécharger uniquement ses propres fichiers acceptés.
Définir ce que le serveur accepte
L’exemple téléverse des pièces jointes de notes JSON, et non des images ou des documents
arbitraires. Chaque fichier doit contenir un objet JSON UTF-8 comportant exactement une propriété,
message, dont la valeur est une chaîne non vide.
Enregistrez ceci sous note.json pour essayer le formulaire :
{"message":"Hello from an AJAX upload."}
Le serveur accepte exactement un champ multipart nommé file, aucun champ supplémentaire, et un
fichier de 64 KiB au maximum. Il plafonne aussi l’ensemble du corps multipart à 80 KiB, délimiteurs
et en-têtes compris. Les deux limites s’appliquent aux octets reçus. Renommer un PNG en
note.json n’en fait pas un JSON valide.
À l’inverse, un contenu de note valide nommé note.png, ou envoyé avec un type MIME incorrect, est
accepté : cet exemple ignore délibérément ces deux métadonnées client et sert chaque fichier accepté
en téléchargement sous le nom note.json avec application/json.
Il s’agit d’une politique de contenu propre à une application précise. Analyser du JSON ne prouve pas qu’un fichier est exempt de logiciels malveillants, et une chaîne contenant du HTML reste une donnée non fiable. L’exemple n’affiche jamais cette chaîne. Pour des types de fichiers plus variés, définissez des règles de validation et de traitement distinctes ; une liste d’autorisation MIME ou une signature de fichier seule ne suffit pas. Consultez les recommandations de l’OWASP sur les téléversements.
Configurer la démo locale
Utilisez Node.js 26 et un navigateur récent. L’exemple ci-dessous a été testé sous Linux avec Node.js 26.8.1 et Chromium 152. Il n’a aucune dépendance de paquet. Créez un nouveau répertoire depuis un shell POSIX :
mkdir ajax-upload-demo && cd ajax-upload-demo
Si cette commande échoue, arrêtez-vous et choisissez un nouveau répertoire ; n’écrasez pas un projet
existant. Enregistrez les trois fichiers suivants à l’intérieur : server.ts, index.html et
client.js.
Le serveur écoute uniquement sur 127.0.0.1, sur un port disponible, et affiche cette URL ainsi que de
nouveaux mots de passe pour alice et bob. Ce sont des identités locales jetables. Toute
personne disposant du mot de passe affiché peut agir en tant que cet utilisateur. Chaque connexion
remplace la session précédente de cet utilisateur, et les sessions expirent au bout de 15 minutes.
Les téléversements restent en mémoire jusqu’à l’arrêt du processus, avec un maximum de dix fichiers
par utilisateur. Des téléversements répétés créent des entrées distinctes ; ils ne remplacent jamais
une pièce jointe antérieure.
Appliquer les règles côté serveur
Enregistrez ceci sous server.ts. Les seuls fichiers publics sont les deux ressources client servies
explicitement. L’authentification et le jeton CSRF de la session sont vérifiés avant la lecture du
corps d’un téléversement. Les téléchargements recherchent l’ID dans la table propre à l’utilisateur
authentifié ; un autre utilisateur reçoit donc la même réponse 404 que pour un ID inconnu.
import { randomBytes, randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
interface DemoUser {
name: string
password: string
session: string
csrf: string
expires: number
files: Map<string, Buffer>
}
const token = (): string => randomBytes(32).toString('hex')
const users: DemoUser[] = ['alice', 'bob'].map((name) => ({
name, password: token(), session: '', csrf: '', expires: 0, files: new Map(),
}))
const html = await readFile(new URL('./index.html', import.meta.url))
const script = await readFile(new URL('./client.js', import.meta.url))
let origin = ''
let cookieName = ''
class Rejection extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
function json(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(body))
}
async function readBody(req: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = []
let size = 0
// Keep the socket writable so an oversized request can receive a 413 response.
for await (const chunk of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(chunk)) throw new Error('Expected request bytes')
size += chunk.length
if (size > 80 * 1024) throw new Rejection(413, 'Request exceeds 80 KiB.')
chunks.push(chunk)
}
return Buffer.concat(chunks)
}
async function noteBytes(req: IncomingMessage): Promise<Buffer> {
const body = await readBody(req)
const contentType = req.headers['content-type'] ?? ''
if (!/^multipart\/form-data\s*;/i.test(contentType)) {
throw new Rejection(415, 'Use multipart/form-data.')
}
let form: FormData
try {
form = await new Response(new Uint8Array(body), {
headers: { 'Content-Type': contentType },
}).formData()
} catch {
throw new Rejection(400, 'Malformed multipart body.')
}
const entries = [...form.entries()]
const file = form.get('file')
if (entries.length !== 1 || !(file instanceof File)) {
throw new Rejection(400, 'Send exactly one file field and no other fields.')
}
if (file.size > 64 * 1024) throw new Rejection(413, 'File exceeds 64 KiB.')
const bytes = Buffer.from(await file.arrayBuffer())
let value: unknown
try {
value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
} catch {
throw new Rejection(422, 'File must contain a UTF-8 JSON note.')
}
if (
typeof value !== 'object' || value === null || Array.isArray(value) ||
Object.keys(value).length !== 1 || !('message' in value) ||
typeof value.message !== 'string' || value.message.trim().length === 0
) {
throw new Rejection(422, 'Use an object with one nonblank message string.')
}
return bytes
}
function sessionData(user: DemoUser): unknown {
return {
user: user.name,
csrf: user.csrf,
files: [...user.files].map(([id, bytes]) => ({ id, bytes: bytes.length })),
}
}
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.headers.host !== new URL(origin).host) {
throw new Rejection(403, 'Origin not allowed.')
}
if (req.method === 'GET' && (req.url === '/' || req.url === '/client.js')) {
res.setHeader('Content-Type', req.url === '/' ? 'text/html; charset=utf-8' : 'text/javascript')
res.end(req.url === '/' ? html : script)
return
}
if (req.method === 'POST' && req.headers.origin !== origin) {
throw new Rejection(403, 'Origin not allowed.')
}
if (req.method === 'POST' && req.url?.startsWith('/login/')) {
const user = users.find((entry) => `/login/${entry.name}` === req.url)
if (!user || req.headers['x-demo-password'] !== user.password) {
throw new Rejection(401, 'Invalid demo credentials.')
}
user.session = token()
user.csrf = token()
user.expires = Date.now() + 15 * 60 * 1000
res.setHeader('Set-Cookie',
`${cookieName}=${user.session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=900`)
json(res, 200, sessionData(user))
return
}
const session = req.headers.cookie?.split(';').map((part) => part.trim())
.find((part) => part.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1)
const user = users.find((entry) => entry.session === session && entry.expires > Date.now())
if (!user) throw new Rejection(401, 'Log in again.')
if (req.method === 'GET' && req.url === '/session') {
json(res, 200, sessionData(user))
return
}
if (req.method === 'GET' && req.url?.startsWith('/files/')) {
const bytes = user.files.get(req.url.slice('/files/'.length))
if (!bytes) throw new Rejection(404, 'File not found.')
res.writeHead(200, {
'Content-Type': 'application/json',
'Content-Disposition': 'attachment; filename="note.json"',
})
res.end(bytes)
return
}
if (req.method !== 'POST' || req.url !== '/upload') {
throw new Rejection(404, 'Route not found.')
}
if (req.headers['x-csrf-token'] !== user.csrf) {
throw new Rejection(403, 'Refresh your session before uploading.')
}
const bytes = await noteBytes(req)
// A second login or session expiry during transfer must invalidate this request too.
if (user.session !== session || user.expires <= Date.now()) {
throw new Rejection(401, 'Log in again.')
}
if (user.files.size >= 10) throw new Rejection(409, 'Demo storage is full. Restart to clear it.')
const id = randomUUID()
user.files.set(id, bytes)
json(res, 201, { id, bytes: bytes.length })
}
const server = createServer({ requestTimeout: 30_000, headersTimeout: 10_000 }, (req, res) => {
res.setHeader('Cache-Control', 'no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('Content-Security-Policy',
"default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'")
void handle(req, res).catch((error: unknown) => {
const status = error instanceof Rejection ? error.status : 500
const message = error instanceof Rejection ? error.message : 'Unable to handle the request.'
if (status === 500) console.error('Request failed unexpectedly.')
res.setHeader('Connection', 'close')
json(res, status, { error: message })
req.resume()
})
})
server.maxConnections = 16
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing TCP address')
origin = `http://127.0.0.1:${address.port}`
cookieName = `ajax_demo_${address.port}`
console.log(`Open ${origin}`)
for (const user of users) console.log(`${user.name} password: ${user.password}`)
})
La limite de corps est appliquée avant que l’analyseur multipart de Node ne mette les différents
champs en mémoire tampon. L’option destroyOnReturn: false permet
à un rejet précoce pour cause de taille d’envoyer sa réponse HTTP avant la fermeture de la connexion.
Rien n’est inséré dans le stockage tant que toutes les validations n’ont pas réussi ; les corps
rejetés ne laissent aucune entrée conservée ni aucun fichier sur disque.
Le jeton renvoyé par /session est distinct du cookie de session HttpOnly. Le navigateur l’envoie
dans X-CSRF-Token, et le serveur le compare au jeton associé à cette session. Une vérification exacte
de Origin protège aussi les requêtes POST, y compris la connexion. Aucun accès CORS n’est
accordé. Ces choix suivent le modèle du jeton synchroniseur ;
SameSite ajoute une couche supplémentaire mais ne remplace pas la vérification du jeton.
Ajouter le formulaire du navigateur
Enregistrez ceci sous index.html. Utilisez le sélecteur de fichiers et les boutons d’envoi natifs
pour que le formulaire fonctionne au clavier. Cet exemple sélectionne volontairement un fichier à la
fois ; l’ajout du glisser-déposer ou d’une file de traitement par lots devrait conserver le même
contrat serveur.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AJAX note upload</title>
<script type="module" src="/client.js"></script>
</head>
<body>
<h1>Upload a private JSON note</h1>
<p>Files live in server memory until restart. Select one JSON note, up to 64 KiB.</p>
<fieldset id="controls">
<legend>Demo session and upload</legend>
<form id="login">
<label for="user">Demo user</label>
<select id="user"><option>alice</option><option>bob</option></select>
<label for="password">Password from the server terminal</label>
<input id="password" type="password" autocomplete="current-password" required />
<button>Log in</button>
</form>
<form id="upload">
<label for="file">JSON note</label>
<input id="file" type="file" accept=".json,application/json" required />
<button>Upload</button>
</form>
<button id="refresh" type="button">Refresh accepted files</button>
</fieldset>
<p id="identity">Not logged in.</p>
<label id="progress-label" for="progress">Request bytes transferred</label>
<progress id="progress" aria-labelledby="progress-label" max="100" value="0"></progress>
<p id="status" role="status" aria-live="polite"></p>
<h2>Your accepted files</h2>
<ul id="files"></ul>
</body>
</html>
Envoyer le fichier et attendre son acceptation
Enregistrez ceci sous client.js. Fetch gère les requêtes de session ; XMLHttpRequest gère le
téléversement, car il expose la progression du téléversement de la requête.
Une barre de progression pleine signifie que le corps de la requête a été envoyé, et non que le
serveur a accepté le fichier. Seule une réponse HTTP 201 de /upload crée un lien de
téléchargement.
const controls = document.getElementById('controls')
const login = document.getElementById('login')
const upload = document.getElementById('upload')
const user = document.getElementById('user')
const password = document.getElementById('password')
const fileInput = document.getElementById('file')
const progress = document.getElementById('progress')
const status = document.getElementById('status')
const identity = document.getElementById('identity')
const files = document.getElementById('files')
let csrf = ''
let busy = false
function failure(code) {
const messages = {
400: 'Send exactly one file and no extra fields.',
401: 'Log in with the password from the server terminal.',
403: 'Session check failed. Refresh accepted files or log in again.',
409: 'Demo storage is full. Restart the server to clear it.',
413: 'Upload exceeds a size limit. Choose a smaller file.',
415: 'The server requires multipart form data.',
422: 'Choose a UTF-8 JSON object with one nonblank message string.',
}
return new Error(messages[code] ?? 'Acceptance is unconfirmed. Refresh accepted files before retrying.')
}
async function run(action) {
if (busy) return
busy = true
controls.disabled = true
try {
await action()
} catch (error) {
status.textContent = error instanceof Error ? error.message : 'Request failed.'
} finally {
busy = false
controls.disabled = false
}
}
function addFile(file) {
const item = document.createElement('li')
const link = document.createElement('a')
link.href = `/files/${encodeURIComponent(file.id)}`
link.textContent = `Download ${file.id} (${file.bytes.toLocaleString()} bytes)`
item.append(link)
files.append(item)
}
async function loadSession(response) {
if (!response.ok) {
if (response.status === 401) {
csrf = ''
identity.textContent = 'Not logged in.'
files.replaceChildren()
}
throw failure(response.status)
}
const data = await response.json()
csrf = data.csrf
identity.textContent = `Logged in as ${data.user}.`
files.replaceChildren()
for (const file of data.files) addFile(file)
}
login.addEventListener('submit', (event) => {
event.preventDefault()
void run(async () => {
const response = await fetch(`/login/${encodeURIComponent(user.value)}`, {
method: 'POST',
headers: { 'X-Demo-Password': password.value },
credentials: 'same-origin',
})
password.value = ''
await loadSession(response)
status.textContent = 'Logged in. Choose a note to upload.'
})
})
function sendFile(file) {
return new Promise((resolve, reject) => {
const body = new FormData()
body.append('file', file)
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) progress.value = event.loaded / event.total * 100
}
xhr.upload.onload = () => { status.textContent = 'Transferred. Waiting for server acceptance…' }
xhr.open('POST', '/upload')
xhr.setRequestHeader('X-CSRF-Token', csrf)
xhr.responseType = 'json'
xhr.timeout = 45_000
xhr.onload = () => {
if (xhr.status === 201 && typeof xhr.response?.id === 'string') resolve(xhr.response)
else reject(failure(xhr.status))
}
xhr.onerror = xhr.ontimeout = xhr.onabort = () => reject(failure(0))
xhr.send(body)
})
}
upload.addEventListener('submit', (event) => {
event.preventDefault()
void run(async () => {
const file = fileInput.files[0]
if (!csrf) throw failure(401)
if (!file) throw new Error('Choose a file first.')
if (file.size > 64 * 1024) throw failure(413)
progress.value = 0
status.textContent = 'Uploading…'
const accepted = await sendFile(file)
addFile(accepted)
status.textContent = 'Accepted into private memory. Use the download link to check the bytes.'
})
})
async function refresh() {
await loadSession(await fetch('/session', { credentials: 'same-origin' }))
status.textContent = 'Accepted file list refreshed.'
}
document.getElementById('refresh').addEventListener('click', () => { void run(refresh) })
void run(refresh)
Laissez le navigateur définir Content-Type lors de l’envoi de FormData : il doit inclure le
délimiteur multipart généré.
MDN explique pourquoi le définir manuellement fait échouer la requête.
La valeur accept du sélecteur et la vérification de taille côté client fournissent un retour
précoce ; aucune des deux n’est un contrôle de sécurité côté serveur. Tant qu’une requête est en
attente, l’élément fieldset est désactivé et une garde busy ignore les envois répétés. Le
File sélectionné est capturé avant le début du téléversement.
Tester l’acceptation et le rejet
Depuis le répertoire contenant les trois fichiers, démarrez le serveur :
node server.ts
Ouvrez l’URL exacte affichée, connectez-vous en tant que alice, sélectionnez note.json et activez
Upload. Après « Accepted into private memory », utilisez le lien de téléchargement. Il renvoie
les octets d’origine, espaces compris, sous forme de pièce jointe. Votre navigateur décide s’il faut
demander une destination ou choisir un nouveau nom de fichier lorsque note.json existe déjà ; cliquer
sur le lien ne suffit pas à confirmer qu’un fichier a été enregistré.
Essayez un fichier contenant {"message":42} : la barre peut se remplir, mais le serveur renvoie
422, la page explique le contenu requis et la liste des fichiers acceptés ne gagne aucune
entrée. Dans une fenêtre de navigation privée distincte, connectez-vous en tant que bob et
ouvrez l’URL de téléchargement d’Alice. Elle renvoie 404. Sans session, elle renvoie
401. Les erreurs applicatives du serveur contiennent des messages fixes plutôt que des détails
de l’analyseur, des chemins ou le contenu soumis.
| Réponse | Signification et action suivante |
|---|---|
201 | Accepté et conservé dans ce processus ; disponible pour son propriétaire. |
400 / 415 | Corrigez la requête multipart ou ses champs. |
401 / 403 | Rétablissez la session ou la preuve CSRF avant un nouveau téléversement. |
413 | Une limite de taille fixe a été dépassée. Choisissez un fichier plus petit. |
422 | Corrigez le contenu du fichier. |
409 | Dix fichiers sont déjà stockés pour cet utilisateur. Un redémarrage efface toutes les données de démo. |
| Erreur réseau, délai dépassé ou autre statut | L’acceptation n’est pas confirmée. Actualisez la liste des fichiers acceptés avant de décider quoi faire. |
Il n’y a aucune nouvelle tentative automatique, y compris pour 413 ou une réponse perdue. Si
le serveur a stocké le fichier mais que la réponse a été perdue, une actualisation révèle la nouvelle
entrée ; téléchargez-la pour identifier ses octets. Le téléverser de nouveau manuellement crée un
second ID. En production, les nouvelles tentatives nécessitent un contrat de déduplication persistant
et propre à chaque utilisateur avant de pouvoir répéter un téléversement en toute sécurité. Ce
serveur n’annonce ni défaillances transitoires ni Retry-After.
Relier l’exemple à votre application
Arrêtez la démo avec Ctrl+C ; les fichiers acceptés, les mots de passe et les sessions sont
supprimés. Pour un déploiement, remplacez les identités locales et les tables en mémoire par
l’authentification, l’autorisation, le middleware CSRF et le stockage durable privé de votre
application. Utilisez HTTPS et un cookie de session Secure. Le cookie HTTP de la démo est réservé
à cet exercice en boucle locale ; les attributs de cookie ont des rôles distincts.
Définissez des limites de débit et de concurrence propres à votre déploiement, ainsi que des quotas
de stockage. Les plafonds de taille de requête et de connexions de la démo ne remplacent pas ces
contrôles.
Les fichiers plus volumineux nécessitent un analyseur en streaming et un chemin de stockage adapté, au lieu de cet analyseur en mémoire borné. Si vous avez besoin de téléversements reprenables, utilisez un serveur tus et un client compatible. Découper un fichier en morceaux ne suffit pas à mettre en œuvre l’autorisation, le réassemblage, le nettoyage ou la répétition sûre des requêtes ; cet exemple n’a volontairement aucun point de terminaison pour les morceaux.
