Sichere AJAX-Datei-Uploads mit Sessions und CSRF-Prüfungen
Ein AJAX-Upload wird erst angenommen, wenn der Server Session, Berechtigung, Anfrage und Dateiinhalt geprüft hat. Diese Anleitung bietet ein lauffähiges Browserformular und einen Node.js-Server, die diese Prüfungen durchsetzen, den Upload-Fortschritt anzeigen und jedem Benutzer nur den Download seiner eigenen angenommenen Dateien erlauben.
Festlegen, was der Server annimmt
Das Beispiel lädt JSON-Notizanhänge hoch, keine beliebigen Bilder oder Dokumente. Jede Datei
muss ein JSON-Objekt in UTF-8 mit genau einer Eigenschaft enthalten,
message, deren Wert eine Zeichenfolge mit mindestens einem Zeichen außer
Leerraum ist. Speichern Sie Folgendes als note.json, um das Formular zu testen:
{"message":"Hello from an AJAX upload."}
Der Server akzeptiert genau ein Multipart-Feld namens file, keine
zusätzlichen Felder und eine Datei mit höchstens 64 KiB. Außerdem begrenzt er den gesamten
Multipart-Body auf 80 KiB, einschließlich Trennmarkierungen und Headern. Beide Grenzen gelten
für die empfangenen Bytes. Eine PNG-Datei in note.json umzubenennen, macht
sie nicht zu gültigem JSON. Umgekehrt wird gültiger Notizinhalt mit dem Namen
note.png oder mit einem falschen MIME-Typ angenommen: Dieses Beispiel
ignoriert bewusst beide clientseitigen Metadaten und lädt jede angenommene Datei als
note.json mit application/json herunter.
Diese Inhaltsrichtlinie gilt für eine bestimmte Anwendung. Das Parsen von JSON beweist nicht, dass eine Datei frei von Schadsoftware ist. Eine Zeichenfolge mit HTML bleibt nicht vertrauenswürdiger Inhalt. Das Beispiel stellt diese Zeichenfolge nie dar. Für weitere Dateitypen benötigen Sie eigene Validierungs- und Verarbeitungsregeln; eine MIME-Positivliste oder eine Dateisignatur allein reicht nicht aus. Siehe die Upload-Empfehlungen von OWASP.
Die lokale Demo einrichten
Verwenden Sie Node.js 26 und einen aktuellen Browser. Das folgende Beispiel wurde unter Linux mit Node.js 26.8.1 und Chromium 152 getestet. Es hat keine Paketabhängigkeiten. Erstellen Sie in einer POSIX-Shell ein neues Verzeichnis:
mkdir ajax-upload-demo && cd ajax-upload-demo
Falls dieser Befehl fehlschlägt, brechen Sie ab und wählen Sie ein neues Verzeichnis;
überschreiben Sie kein bestehendes Projekt. Speichern Sie darin die nächsten drei Dateien:
server.ts, index.html und client.js.
Der Server bindet sich nur an 127.0.0.1 auf einem verfügbaren Port und gibt
diese URL sowie neue Passwörter für alice und
bob aus. Diese lokalen Identitäten sind nur für die Demo gedacht.
Jeder, der das ausgegebene Passwort kennt, kann als dieser Benutzer handeln. Jede Anmeldung
ersetzt die vorherige Session dieses Benutzers. Sessions laufen nach 15 Minuten ab.
Uploads bleiben bis zum Prozessende im Arbeitsspeicher, mit höchstens zehn Dateien pro Benutzer.
Wiederholte Uploads erzeugen separate Einträge; sie ersetzen nie einen früheren Anhang.
Die Regeln auf dem Server durchsetzen
Speichern Sie dies als server.ts. Nur die beiden explizit ausgelieferten
Client-Dateien sind öffentlich zugänglich. Authentifizierung und CSRF-Token der Session werden
geprüft, bevor der Upload-Body gelesen wird. Für Downloads wird die ID in der eigenen Map des
authentifizierten Benutzers gesucht. Ein anderer Benutzer erhält daher dieselbe Antwort
404 wie bei einer unbekannten ID.
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}`)
})
Die Größenbegrenzung des Bodys greift, bevor der Multipart-Parser von Node die einzelnen Felder
puffert. Die Option destroyOnReturn: false ermöglicht es,
bei einer frühzeitigen Ablehnung wegen der Größe noch die HTTP-Antwort zu senden, bevor die
Verbindung geschlossen wird. Erst wenn alle Validierungen erfolgreich sind, wird etwas
gespeichert. Von abgelehnten Bodys bleiben weder Einträge noch Dateien auf dem Datenträger zurück.
Das von /session zurückgegebene Token ist vom HttpOnly-Session-Cookie
getrennt. Der Browser sendet es in X-CSRF-Token, und der Server vergleicht es
mit dem Token dieser Session. Eine exakte Prüfung von Origin schützt
zusätzlich POST-Anfragen, einschließlich der Anmeldung. Es wird kein CORS-Zugriff gewährt.
Diese Maßnahmen folgen dem
Synchronizer-Token-Muster;
SameSite bietet eine weitere Schutzschicht, ersetzt aber nicht die
Token-Prüfung.
Das Browserformular hinzufügen
Speichern Sie dies als index.html. Verwenden Sie die native Dateiauswahl und
Absende-Schaltflächen, damit sich das Formular per Tastatur bedienen lässt. Dieses Beispiel
wählt bewusst nur eine Datei auf einmal aus. Wenn Sie Drag-and-drop oder eine Batch-Queue
hinzufügen, sollte dieselbe Schnittstellenvereinbarung mit dem Server erhalten bleiben.
<!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>
Die Datei senden und auf die Annahme warten
Speichern Sie dies als client.js. Fetch übernimmt die Session-Anfragen;
XMLHttpRequest übernimmt den Upload, da es den
Upload-Fortschritt der Anfrage bereitstellt.
Ein voller Fortschrittsbalken bedeutet, dass der Anfrage-Body gesendet wurde, nicht, dass der
Server die Datei angenommen hat. Nur HTTP 201 von
/upload erzeugt einen Download-Link.
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)
Lassen Sie den Browser Content-Type beim Senden von
FormData setzen: Der Wert muss die generierte Multipart-Trennmarkierung
enthalten. MDN erklärt, warum manuelles Setzen die Anfrage fehlschlagen lässt.
Der Wert accept der Dateiauswahl und die clientseitige Größenprüfung
geben frühzeitig Rückmeldung; beide sind keine serverseitigen Sicherheitsmaßnahmen. Solange
eine Anfrage läuft, ist die Feldgruppe deaktiviert, und eine Schutzabfrage mit
busy ignoriert wiederholtes Absenden. Das ausgewählte Objekt
File wird vor Beginn des Uploads erfasst.
Annahme und Ablehnung testen
Starten Sie den Server aus dem Verzeichnis, das alle drei Dateien enthält:
node server.ts
Öffnen Sie genau die ausgegebene URL, melden Sie sich als alice an,
wählen Sie note.json aus und betätigen Sie Upload. Nach der Meldung
„Accepted into private memory“ verwenden Sie den Download-Link. Er liefert die ursprünglichen
Bytes einschließlich Leerraum als Anhang zurück. Ihr Browser entscheidet, ob er nach einem
Speicherort fragt oder einen neuen Dateinamen wählt, wenn note.json bereits
existiert. Ein Klick auf den Link allein bestätigt nicht, dass eine Datei gespeichert wurde.
Testen Sie eine Datei mit dem Inhalt {"message":42}: Der Balken kann sich
füllen, doch der Server antwortet mit 422, die Seite erklärt den
erforderlichen Inhalt, und die Liste angenommener Dateien erhält keinen Eintrag. Melden Sie
sich in einem separaten privaten Browserfenster als bob an und öffnen
Sie Alices Download-URL. Sie liefert 404 zurück. Ohne Session lautet
die Antwort 401. Die Anwendungsfehler des Servers enthalten feste
Meldungen statt Parserdetails, Pfaden oder übermittelten Inhalten.
| Antwort | Bedeutung und nächster Schritt |
|---|---|
201 | Angenommen und in diesem Prozess gespeichert; für den Eigentümer verfügbar. |
400 / 415 | Korrigieren Sie die Multipart-Anfrage oder ihre Felder. |
401 / 403 | Stellen Sie vor einem weiteren Upload die Session oder den CSRF-Nachweis wieder her. |
413 | Eine feste Größenbegrenzung wurde überschritten. Wählen Sie eine kleinere Datei. |
422 | Korrigieren Sie den Dateiinhalt. |
409 | Für diesen Benutzer sind bereits zehn Dateien gespeichert. Ein Neustart löscht alle Demo-Daten. |
| Netzwerkfehler, Zeitüberschreitung oder anderer Status | Die Annahme ist nicht bestätigt. Aktualisieren Sie die Liste angenommener Dateien, bevor Sie über das weitere Vorgehen entscheiden. |
Es gibt keine automatischen Retries, auch nicht bei 413 oder einer
verlorenen Antwort. Hat der Server die Datei gespeichert, aber die Antwort ging verloren, wird
der neue Eintrag beim Aktualisieren sichtbar. Laden Sie die Datei herunter, um ihre Bytes zu
identifizieren. Ein erneuter manueller Upload erzeugt eine zweite ID. In Produktionssystemen
benötigen Retries eine dauerhafte, benutzerbezogene Vereinbarung zur Deduplizierung, bevor sie
einen Upload sicher wiederholen können. Dieser Server kennzeichnet weder vorübergehende Fehler
noch gibt er Retry-After an.
Das Beispiel in Ihre Anwendung einbinden
Beenden Sie die Demo mit Ctrl+C; angenommene Dateien, Passwörter und Sessions werden verworfen.
Ersetzen Sie für den Produktivbetrieb die lokalen Identitäten und Maps im Arbeitsspeicher durch
die Authentifizierung, Autorisierung, CSRF-Middleware und den privaten dauerhaften Speicher
Ihrer Anwendung. Verwenden Sie HTTPS und ein Session-Cookie mit Secure.
Das HTTP-Cookie der Demo ist auf diese Loopback-Übung beschränkt;
Cookie-Attribute haben unterschiedliche Aufgaben.
Legen Sie für Ihre Bereitstellung passende Ratenbegrenzungen, Grenzen für gleichzeitige Anfragen
und Speicherkontingente fest. Die Grenzen der Demo für Anfragegrößen und Verbindungen ersetzen
diese Maßnahmen nicht.
Größere Dateien benötigen einen Streaming-Parser und einen Speicherort statt dieses begrenzten Parsers im Arbeitsspeicher. Wenn Sie Uploads fortsetzen können müssen, verwenden Sie einen tus-Server und einen kompatiblen Client. Eine Datei lediglich in Blöcke aufzuteilen, implementiert weder Autorisierung noch Zusammensetzen, Bereinigung oder sichere wiederholte Anfragen. Dieses Beispiel hat bewusst keine Endpunkte für Datei-Blöcke.
