Sichere AJAX-Datei-Uploads: Bewährte Verfahren und Techniken
Die sichere Verarbeitung von Datei-Uploads ist in der modernen Webentwicklung entscheidend. Da AJAX-Datei-Uploads immer verbreiteter sind, ist es unerlässlich, Ihre Anwendung vor möglichen Schwachstellen zu schützen. In diesem Beitrag betrachten wir moderne bewährte Verfahren und Techniken – darunter die Nutzung der Fetch API, Chunk-Uploads und eine umfassende Fehlerbehandlung –, um sichere Datei-Upload-Systeme zu erstellen.
AJAX-Datei-Uploads verstehen
AJAX (Asynchronous JavaScript and XML) ermöglicht es Webanwendungen, Daten asynchron an den Server zu senden und von ihm zu empfangen, ohne die Seite neu zu laden. Bei der Umsetzung von AJAX-Datei-Uploads ist es wichtig, Sicherheitsaspekte sowohl auf Client- als auch auf Serverseite zu berücksichtigen.
Browser-Kompatibilität
Moderne Browser bieten über verschiedene APIs eine robuste Unterstützung für Datei-Uploads:
- Die FormData API ist in allen modernen Browsern verfügbar.
- Die Fetch API ist gegenüber Legacy-Techniken der empfohlene Ansatz.
- Die File API bietet erweiterte Funktionen für den Umgang mit Dateien.
- XMLHttpRequest unterstützt Fortschrittsereignisse für den Upload der Anfrage, die Fetch nicht direkt bereitstellt.
Weitere Einzelheiten finden Sie in der MDN-Dokumentation zu FormData und zur Fetch API.
Moderne Datei-Uploads umsetzen
Die fetch API mit async/await verwenden
Die Fetch API bietet in Kombination mit async/await eine saubere Methode zum Hochladen von Dateien.
Der weiter unten gezeigte Endpunkt /upload akzeptiert ein Multipart-Feld file und gibt JSON
zurück. Liefern Sie den Client vom selben Ursprung aus, oder konfigurieren Sie CORS für Ihre
Anwendung:
const uploadFile = async (file, idempotencyKey) => {
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch('/upload', {
method: 'POST',
body: formData,
headers: idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : undefined,
})
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`)
}
return await response.json()
} catch (error) {
console.error('Upload error:', error)
throw error
}
}
Upload-Fortschritt verfolgen
Verwenden Sie die Ereignisse von XMLHttpRequest.upload,
um die in der Anfrage gesendeten Bytes zu verfolgen. Das Auslesen von response.body misst den Download
der Antwort, nicht den Datei-Upload. Ein Fortschrittswert von 100 % bedeutet, dass der Body gesendet
wurde; warten Sie auf die Antwort des Servers, bevor Sie Erfolg melden:
const uploadWithProgress = (file, onProgress = console.log) => {
return new Promise((resolve, reject) => {
const formData = new FormData()
formData.append('file', file)
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && event.total > 0) {
onProgress((event.loaded / event.total) * 100)
}
})
xhr.open('POST', '/upload')
xhr.responseType = 'json'
xhr.timeout = 120000
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response)
} else {
reject(new Error(`Upload failed (HTTP ${xhr.status})`))
}
}
xhr.onerror = () => reject(new Error('Upload failed: network error'))
xhr.ontimeout = () => reject(new Error('Upload timed out'))
xhr.onabort = () => reject(new DOMException('Upload canceled', 'AbortError'))
xhr.send(formData)
})
}
Chunk-Uploads verarbeiten
Bei großen Dateien erhöht die Aufteilung des Uploads in kleinere Chunks die Zuverlässigkeit bei
instabilen Netzwerkbedingungen. Dies veranschaulicht einen eigenen Endpunkt-Vertrag und nicht
Endpunkte, die der unten stehende Multer-Server implementiert. Der Server muss fileId an den
authentifizierten Benutzer binden, Chunk-Indizes, Anzahl und Gesamtgröße validieren und alle Chunks
zusammenfügen, bevor er den Abschluss bestätigt. Er muss unvollständige Uploads sicher ablaufen
lassen und wiederholte Chunk- und Abschlussanfragen idempotent machen. Setzen Sie
tus-Server und -Client ein, wenn Sie ein
standardisiertes Protokoll für fortsetzbare Uploads benötigen:
const CHUNK_SIZE = 1024 * 1024 // 1MB chunks
const uploadLargeFile = async (file) => {
if (file.size === 0) throw new Error('Please select a nonempty file')
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
const fileId = crypto.randomUUID()
for (let chunk = 0; chunk < totalChunks; chunk++) {
const start = chunk * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, file.size)
const fileChunk = file.slice(start, end)
const formData = new FormData()
formData.append('chunk', fileChunk)
formData.append('fileId', fileId)
formData.append('chunkIndex', chunk)
formData.append('totalChunks', totalChunks)
formData.append('fileSize', file.size)
try {
const response = await fetch('/upload/chunk', {
method: 'POST',
body: formData,
})
if (!response.ok) {
throw new Error(`Chunk ${chunk} failed (HTTP ${response.status})`)
}
} catch (error) {
console.error(`Chunk ${chunk} failed:`, error)
throw error
}
}
// Finalize the upload
const response = await fetch('/upload/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ fileId }),
})
if (!response.ok) {
throw new Error(`Upload completion failed (HTTP ${response.status})`)
}
return response
}
Mehrere Datei-Uploads verarbeiten
Das gleichzeitige Hochladen mehrerer Dateien kann die Performance verbessern. Mit dem Attribut multiple
des Eingabefelds und modernem JavaScript können Sie eine kleine Auswahl gleichzeitig verarbeiten.
Dieses Beispiel begrenzt jede Auswahl auf fünf Dateien; verwenden Sie für größere Stapel eine
begrenzte Queue:
const uploadMultipleFiles = async (files) => {
if (files.length > 5) throw new Error('Select up to five files at a time')
const uploadPromises = Array.from(files).map((file) => {
const formData = new FormData()
formData.append('file', file)
return fetch('/upload', { method: 'POST', body: formData })
.then((response) => {
if (!response.ok) {
throw new Error(`Upload failed for ${file.name}`)
}
return response.json()
})
.catch((error) => {
console.error(`Error uploading ${file.name}:`, error)
throw error
})
})
return Promise.all(uploadPromises)
}
Drag-and-Drop-Datei-Upload
Verbessern Sie die Nutzererfahrung, indem Sie Datei-Uploads per Drag-and-Drop umsetzen. Das folgende Beispiel richtet eine Drop-Zone ein, die auf Drag-Ereignisse reagiert:
const dropZone = document.getElementById('drop-zone')
dropZone.addEventListener('dragover', (e) => {
e.preventDefault()
dropZone.classList.add('highlight')
})
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('highlight')
})
dropZone.addEventListener('drop', async (e) => {
e.preventDefault()
dropZone.classList.remove('highlight')
const files = e.dataTransfer.files
try {
const results = await uploadMultipleFiles(files)
console.log('Files uploaded:', results)
} catch (error) {
console.error('Error during drag-and-drop upload:', error)
}
})
Clientseitige Validierung des Dateityps
Validieren Sie Dateitypen vor dem Upload mithilfe der File API, um Nutzern unmittelbar Rückmeldung zu geben:
const validateFileType = (file) => {
const allowedTypes = ['image/png', 'image/jpeg', 'application/pdf']
if (!allowedTypes.includes(file.type)) {
alert(`Invalid file type: ${file.type}`)
return false
}
return true
}
const handleFileInput = async (event) => {
const files = event.target.files
try {
for (const file of files) {
if (validateFileType(file)) await uploadFile(file)
}
} catch {
alert('Unable to upload the selected file. Please try again.')
}
}
document.getElementById('file-input').addEventListener('change', handleFileInput)
Serverseitige Umsetzung
Dieses Node.js-Beispiel verwendet Speicherung auf der Festplatte, Anfragelimits und bereinigte Fehlermeldungen. Multer 2.3.0 behebt das Denial-of-Service-Problem, das im offiziellen Advisory beschrieben wird. Installieren Sie die Abhängigkeiten:
npm install express@5 multer@2.3.0 express-rate-limit@8
Führen Sie dies als CommonJS-Serverdatei mit Node.js aus. Speichern Sie uploads/ außerhalb des
Web-Roots. Dieses Beispiel nimmt Dateien in einen privaten Speicher auf; Autorisierung, CSRF-Schutz
für Cookie-basierte Sitzungen, Inhaltsprüfung und Malware-Scans müssen ergänzt werden, bevor Dateien
veröffentlicht oder verarbeitet werden. Der vom Client gelieferte MIME-Wert ist nur ein vorläufiger
Filter und kein Nachweis für den Dateiinhalt.
const express = require('express')
const multer = require('multer')
const path = require('path')
const crypto = require('crypto')
const app = express()
const storage = multer.diskStorage({
destination: 'uploads/',
filename: (req, file, cb) => {
// Generate a secure random filename
crypto.randomBytes(16, (err, raw) => {
if (err) return cb(err)
cb(null, raw.toString('hex') + path.extname(file.originalname))
})
},
})
const fileFilter = (req, file, cb) => {
const allowedTypes = ['image/png', 'image/jpeg', 'application/pdf']
if (allowedTypes.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error('Invalid file type'), false)
}
}
const upload = multer({
storage,
limits: {
fileSize: 5 * 1024 * 1024, // 5 MB
files: 1,
fields: 0,
parts: 2,
},
fileFilter,
})
const { rateLimit } = require('express-rate-limit')
const uploadLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 uploads per window
})
app.post('/upload', uploadLimiter, upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded.' })
}
res.json({ message: 'File accepted into private storage.' })
})
// Error handling middleware
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400
return res.status(status).json({ error: 'File exceeds an upload limit.' })
}
if (err.message === 'Invalid file type') {
return res.status(400).json({ error: 'Unsupported file type.' })
}
console.error('Upload failed', { code: err.code ?? 'UNKNOWN' })
res.status(500).json({ error: 'Unable to upload the file.' })
})
app.listen(3000)
Bewährte Verfahren für die Sicherheit
Schützen Sie Ihr Datei-Upload-System durch robuste Sicherheitsmaßnahmen:
- Setzen Sie eine Content Security Policy (CSP) für die HTML-Antworten. Registrieren Sie diese Middleware vor den Seiten-Routen und laden Sie Skripte aus separaten Dateien. Diese Grundkonfiguration verbietet Inline-Skripte; passen Sie eine Nonce- oder Hash-basierte Richtlinie an Ihre Anwendung an, wenn Inline-Skripte erforderlich sind:
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'",
)
next()
})
- Geben Sie Nutzern mit Browser-Prüfungen frühzeitig Rückmeldung und setzen Sie anschließend Größen- und Inhaltsregeln auf dem Server durch:
const validateFile = (file) => {
const maxSize = 5 * 1024 * 1024 // 5MB
const allowedTypes = ['image/png', 'image/jpeg', 'application/pdf']
if (file.size > maxSize) {
throw new Error('File too large')
}
if (!allowedTypes.includes(file.type)) {
throw new Error('Invalid file type')
}
}
-
Konfigurieren Sie eine sichere Speicherung:
- Speichern Sie Dateien außerhalb des Web-Roots.
- Verwenden Sie zufällig generierte Dateinamen.
- Setzen Sie geeignete Dateiberechtigungen.
- Ziehen Sie Cloud-Storage-Optionen für die Skalierbarkeit in Betracht.
-
Setzen Sie Rate Limiting ein, um Missbrauch einzudämmen.
-
Erzwingen Sie HTTPS, um Dateiübertragungen zu verschlüsseln.
-
Verwenden Sie signierte URLs für sichere Uploads direkt in den Speicher.
-
Integrieren Sie Virenscans mithilfe zuverlässiger cloudbasierter Lösungen.
Fehlerbehandlung und Wiederherstellung
Eine robuste Fehlerbehandlung verbessert die Nutzererfahrung. Implementieren Sie eine Retry-Logik
für vorübergehende Netzwerkfehler. Eine verlorene Antwort kann auftreten, nachdem der Server eine
Datei gespeichert hat; verwenden Sie dies daher nur mit einem Server, der Retries anhand eines
anwendungsdefinierten Idempotenzschlüssels dedupliziert. Der oben gezeigte Multer-Server
implementiert keine Deduplizierung; ergänzen Sie eine dauerhafte Verarbeitung von Idempotency-Key,
beschränkt auf den authentifizierten Benutzer, bevor Sie diesen Wrapper aktivieren:
const uploadWithRetry = async (file, maxRetries = 3) => {
if (!Number.isInteger(maxRetries) || maxRetries < 1) {
throw new Error('maxRetries must be a positive integer')
}
const idempotencyKey = crypto.randomUUID()
let attempts = 0
while (attempts < maxRetries) {
try {
const result = await uploadFile(file, idempotencyKey)
return result
} catch (error) {
attempts++
if (attempts === maxRetries) {
throw new Error(`Upload failed after ${maxRetries} attempts`, { cause: error })
}
// Exponential backoff before retrying
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempts) * 1000))
}
}
}
Fazit
Sichere Datei-Uploads erfordern gründliche Maßnahmen auf Client- und Serverseite. Indem Sie moderne Techniken für Validierung, Fehlerbehandlung und effiziente Uploads umsetzen, bauen Sie ein robustes System auf, das gängige Schwachstellen abmildert.
Für Upload-Fortschritt und fortsetzbare Übertragungen sollten Sie Uppy mit einem Uploader-Plugin in Betracht ziehen, das zum Protokoll Ihres Servers passt.
