File Uploads Are an Attack Surface
File upload endpoints consistently appear in OWASP's testing guides and top-risk lists. They accept arbitrary binary data from untrusted clients, write it to disk or storage, and often process it further. Every step is an opportunity for exploitation: unrestricted file type uploads, path traversal, storage exhaustion, and remote code execution through uploaded content.
OWASP's guidance on file upload security isn't theoretical. It's drawn from real-world breaches where attackers uploaded web shells disguised as images, overwrote server files via crafted filenames, or exhausted disk space with incomplete uploads. When you're handling chunked uploads with Resumable.js, every one of these risks applies — and the chunked nature introduces a few more.
The Threat Model
OWASP identifies four primary file upload threats:
Unrestricted file upload — an attacker uploads executable files (.php, .jsp, .aspx) to a web-accessible directory. If the server executes them, the attacker has a shell.
Path traversal — filenames containing ../ or absolute paths trick the server into writing outside the intended upload directory, potentially overwriting critical files.
Denial of service — uploading extremely large files, many files simultaneously, or never completing uploads to exhaust disk, memory, or connection pools.
Content-based attacks — files that pass type checks but contain malicious payloads: SVGs with JavaScript, images with embedded PHP, PDFs with auto-executing actions.
Authenticate Every Request
With chunked uploads, a single file may produce dozens or hundreds of HTTP requests. Every one of those requests must be authenticated. It's not sufficient to authenticate the initial upload — each chunk request needs a valid token.
Resumable.js supports this through the configuration options. Pass authentication headers or query parameters:
const r = new Resumable({
target: '/api/upload',
headers: {
'Authorization': `Bearer ${getAccessToken()}`
},
// Or include in query parameters
query: { uploadToken: getUploadToken() }
});
On the server, validate the token on every chunk request — not just the first. If you're using short-lived tokens, account for uploads that take longer than the token's TTL. Either issue upload-specific tokens with longer expiry or implement token refresh in the headers function:
const r = new Resumable({
target: '/api/upload',
headers: () => ({
'Authorization': `Bearer ${getFreshAccessToken()}`
})
});
Authorization matters too. Verify that the authenticated user has permission to upload, that they haven't exceeded their quota, and that the upload target (project, folder, conversation) belongs to them. Check this per-request, not per-session.
Sanitize Filenames Aggressively
Never use a client-provided filename directly for storage. OWASP's recommendation is explicit: generate filenames server-side or sanitize ruthlessly.
A minimal sanitization function:
const path = require('path');
const crypto = require('crypto');
function sanitizeFilename(original) {
// Extract just the filename, strip any path components
const basename = path.basename(original);
// Remove anything that isn't alphanumeric, dash, underscore, or dot
const cleaned = basename.replace(/[^a-zA-Z0-9._-]/g, '_');
// Prevent hidden files and directory traversal
const safe = cleaned.replace(/^\.+/, '');
// Enforce a maximum length
const truncated = safe.slice(0, 200);
// Prefix with a unique ID to prevent collisions and enumeration
const id = crypto.randomBytes(8).toString('hex');
return `${id}_${truncated || 'upload'}`;
}
Better yet, don't use the original filename for storage at all. Store files with generated names (UUIDs, hashes) and keep the original filename as metadata in your database. This eliminates path traversal, encoding attacks, and filename collision issues entirely.
The server receivers guide covers how to handle filenames in your upload endpoint.
Validate File Type and Content
Extension checks and MIME type headers are easy to forge. Use them as a first filter, then validate the actual file content server-side.
The minimum validation stack:
- Extension whitelist — reject files whose extensions aren't in your allowed list
- MIME type check — compare the
Content-Typeheader against expectations - Magic byte verification — read the file's actual bytes and validate the signature (covered in detail in the file signature validation guide)
- Format-specific parsing — attempt to decode/parse the file with a real library (Pillow for images, a PDF parser for PDFs)
For size limits, enforce them both in Resumable.js via maxFileSize and maxChunkSize in configuration and on the server. The client-side limit improves UX; the server-side limit is the actual enforcement. See file validation for detailed configuration.
Storage Isolation
OWASP's most critical recommendation for file storage: never write uploads to a directory that the web server can execute.
This means:
- Upload directories should be outside the web root
- The upload directory should have no execute permissions
- Uploaded files should be served through a separate handler that sets
Content-Disposition: attachmentandX-Content-Type-Options: nosniff - If possible, store uploads in object storage (S3, R2) rather than the local filesystem
If you're processing uploads with a Python backend, the Django/Flask chunked upload guide shows how to structure the storage directory.
# nginx: serve uploads with safe headers
location /uploads/ {
alias /var/data/uploads/;
add_header X-Content-Type-Options nosniff always;
add_header Content-Disposition "attachment" always;
add_header Content-Security-Policy "default-src 'none'" always;
# Never execute anything in this directory
location ~* \.(php|py|pl|sh|cgi|jsp|aspx)$ {
deny all;
}
}
Malware Scanning
Scanning uploaded files before making them available to users is a critical layer. Files that pass all type and format checks can still contain malware.
ClamAV is the standard open-source option. Run it as a daemon (clamd) and scan files after assembly:
import clamd
def scan_file(filepath):
cd = clamd.ClamdUnixSocket()
result = cd.scan(filepath)
if result and filepath in result:
status, reason = result[filepath]
if status == 'FOUND':
return False, reason # Malware detected
return True, None
Cloud providers offer native scanning: AWS has Amazon GuardDuty Malware Protection for S3, Google Cloud has Cloud DLP, and Cloudflare R2 can integrate with Workers for scanning pipelines. Pick the one that fits your infrastructure.
Scan after assembly, not per-chunk. Malware signatures span across byte ranges that may split across chunk boundaries. The complete file is the unit of scanning.
Temporary Chunk Cleanup
Chunked uploads create temporary files for each chunk. If a user starts an upload and never finishes — closes the tab, loses connectivity, walks away — those chunks persist indefinitely unless you clean them up.
Implement TTL-based cleanup:
- Track when each chunk was last written
- Run a periodic cleanup job (cron, scheduled task) that deletes chunks older than a threshold (4–24 hours is typical)
- Log cleanup activity for debugging
Without this, an attacker can exhaust your disk by initiating thousands of uploads and never completing them. This is a straightforward DoS vector that OWASP specifically calls out.
See the timeouts guide for how to configure cleanup intervals and the rate limits guide for throttling upload initiation.
Least Privilege
Your upload endpoint should have the minimum permissions necessary:
- Write-only access to the upload directory or bucket — no read, no delete, no list
- No execute permissions on the storage path
- Separate credentials from your application's main database or API keys
- Scoped IAM roles if using cloud storage — the upload Lambda or Worker should only have
PutObject, notGetObjectorDeleteObject
This limits the blast radius if the upload endpoint is compromised. An attacker who gains write access to a storage bucket can't read existing files or delete data.
CORS Restrictions
Lock down CORS on your upload endpoint. Only allow the specific origins that host your upload UI:
// Express middleware for upload routes
app.use('/api/upload', (req, res, next) => {
const allowed = ['https://app.example.com', 'https://www.example.com'];
const origin = req.headers.origin;
if (allowed.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET, POST');
res.setHeader('Access-Control-Allow-Headers',
'Content-Type, Authorization, X-Resumable-Chunk-Number');
}
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
Never use Access-Control-Allow-Origin: * on upload endpoints. The CORS guide covers the full configuration for Resumable.js.
Chunked Upload–Specific Risks
Chunked uploads introduce attack vectors that don't exist with single-request uploads:
Chunk injection — an attacker sends extra chunks or chunks with out-of-range numbers, hoping to overwrite parts of another user's upload. Your server must validate that each chunk number is within the expected range (1 to totalChunks) and is associated with the correct upload session.
Chunk replay — re-sending previously uploaded chunks to trigger duplicate processing. Use idempotent chunk handling: if a chunk with a given number already exists for an upload session, overwrite it or ignore the duplicate, don't append.
Session hijacking — if upload identifiers are predictable, an attacker can send chunks to someone else's upload. Use cryptographically random upload identifiers, not sequential IDs.
// Server: validate chunk parameters
function validateChunkRequest(req, session) {
const chunkNumber = parseInt(req.body.resumableChunkNumber, 10);
const totalChunks = parseInt(req.body.resumableTotalChunks, 10);
if (totalChunks !== session.expectedTotalChunks) return false;
if (chunkNumber < 1 || chunkNumber > totalChunks) return false;
if (req.body.resumableIdentifier !== session.identifier) return false;
return true;
}
Combining these measures — authentication, validation, isolation, scanning, cleanup, least privilege, and CORS — creates the defense-in-depth posture OWASP recommends. No single layer is sufficient. Each one catches threats the others miss. Start with the security guide for the overall approach, then implement these specifics for your chunked upload pipeline.
