Below are concrete security, reliability, and operational findings. Many of these interact; the biggest themes are: untrusted path components, unauthenticated identity, synchronous filesystem I/O, unsafe upload validation, and exposing stored files directly.
1. Path traversal on upload filename
Severity: Critical
Issue:
const fileName = req.body.name;
const outPath = path.join('/var/app/uploads', user, fileName);
fileName is fully attacker-controlled. Values like:
../../../../etc/cron.d/pwn
../otheruser/file.txt
subdir/../../../app/config.json
can escape the intended upload directory.
Exploit / failure mode:
An attacker may overwrite arbitrary files that the Node process can write to, including application files, logs, configuration, or files later consumed by other jobs.
path.join() does not prevent traversal. It merely normalizes the path.
Fix:
Resolve the final path and verify it stays under a fixed base directory.
const UPLOAD_ROOT = '/var/app/uploads';
function safeResolve(base, ...parts) {
const resolved = path.resolve(base, ...parts);
const baseResolved = path.resolve(base) + path.sep;
if (!resolved.startsWith(baseResolved)) {
throw new Error('Invalid path');
}
return resolved;
}
Use it:
const outPath = safeResolve(UPLOAD_ROOT, safeUser, safeFileName);
Also restrict filenames to a safe allowlist.
if (!/^[a-zA-Z0-9._-]{1,128}$/.test(fileName)) {
return res.status(400).json({ error: 'Invalid filename' });
}
2. Path traversal via X-User header
Severity: Critical
Issue:
const user = req.header('X-User') || 'anonymous';
const outPath = path.join('/var/app/uploads', user, fileName);
The user value is trusted from a request header. An attacker can send:
or:
Exploit / failure mode:
This allows writing outside the upload root or writing into another user’s upload directory.
Fix:
Do not trust X-User directly. Use authenticated identity from verified middleware, such as a session, JWT, mTLS identity, or proxy-authenticated header that is stripped and re-added by the reverse proxy.
Also validate user identifiers:
const user = req.auth.userId;
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(user)) {
return res.status(400).json({ error: 'Invalid user' });
}
At the proxy, strip inbound X-User from clients and only inject it after authentication.
3. Path traversal on download
Severity: Critical
Issue:
const p = path.join('/var/app/uploads', req.query.user, req.query.name);
res.sendFile(p);
Both req.query.user and req.query.name are attacker-controlled.
Exploit / failure mode:
A request such as:
GET /download?user=../../../../etc&name=passwd
may resolve to /etc/passwd, depending on normalization and filesystem permissions.
res.sendFile() will send arbitrary readable files if given a valid absolute path.
Fix:
Use path.resolve() containment checks and safe identifiers. Prefer Express’s root option with validated relative paths.
const user = validateUser(req.query.user);
const name = validateFileName(req.query.name);
const filePath = safeResolve(UPLOAD_ROOT, user, name);
res.sendFile(filePath, err => {
if (err) {
if (!res.headersSent) res.status(err.statusCode || 404).end();
}
});
Do not allow arbitrary path separators in name unless subdirectories are explicitly supported and safely handled.
4. Authentication and authorization are missing
Severity: Critical
Issue:
Anyone who can reach the service can upload and download files for any user.
const user = req.header('X-User') || 'anonymous';
GET /download?user=someoneElse&name=secret.pdf
Exploit / failure mode:
Internal users or compromised internal hosts can impersonate any user, overwrite files, or download other users’ content.
Fix:
Require authentication middleware.
Authorize access based on the authenticated principal:
app.post('/upload', (req, res) => {
const user = req.user.id;
// no user supplied by client
});
app.get('/download', (req, res) => {
const user = req.user.id;
// only download own files unless explicit ACL check passes
});
If admin or cross-user access is required, implement explicit authorization checks.
5. Unbounded memory amplification from JSON plus base64 decoding
Severity: High
Issue:
app.use(express.json({limit: '50mb'}));
const content = Buffer.from(req.body.data, 'base64');
A 50 MB JSON body containing base64 may decode into tens of megabytes of binary data. Express must buffer and parse the whole JSON body in memory. Then Buffer.from() creates another large buffer.
Exploit / failure mode:
Concurrent uploads can exhaust memory and crash the process. Base64 also adds roughly 33% overhead, and JSON parsing adds more overhead.
Example: many clients send near-50 MB JSON bodies concurrently.
Fix:
Avoid JSON/base64 for file uploads. Use streaming multipart upload handling with size limits, such as busboy, multer, or direct object storage pre-signed uploads.
Example with streaming controls:
app.post('/upload', requireAuth, upload.single('file'), async (req, res) => {
// enforce fileSize limit in multer config
});
Or stream directly:
req.pipe(fs.createWriteStream(tempPath));
Set tighter body limits if JSON is unavoidable.
6. Synchronous filesystem operations block the event loop
Severity: High
Issue:
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, content);
These operations block the Node.js event loop.
Exploit / failure mode:
Large or slow disk writes make the server unresponsive to all other requests. Concurrent uploads serialize at the event loop level and can cause timeouts, proxy failures, and cascading outages.
Fix:
Use asynchronous filesystem APIs or streams.
await fs.promises.mkdir(path.dirname(outPath), { recursive: true });
await fs.promises.writeFile(outPath, content, { flag: 'wx' });
For large files, prefer streaming:
const stream = fs.createWriteStream(tempPath, { flags: 'wx' });
7. Existing files can be overwritten
Severity: High
Issue:
fs.writeFileSync(outPath, content);
By default, writeFileSync overwrites existing files.
Exploit / failure mode:
An attacker can replace another file, corrupt data, poison downstream jobs, or race with legitimate uploads.
Fix:
Use unique server-generated object names, or fail if the file already exists.
await fs.promises.writeFile(outPath, content, { flag: 'wx', mode: 0o600 });
Better:
const id = crypto.randomUUID();
const outPath = safeResolve(UPLOAD_ROOT, user, id);
Store original filename as metadata, not as the filesystem name.
8. Symlink and hardlink attacks
Severity: High
Issue:
If an attacker can create symlinks under /var/app/uploads or influence the filesystem state, this code will follow symlinks.
fs.writeFileSync(outPath, content);
res.sendFile(p);
Exploit / failure mode:
An attacker could make:
/var/app/uploads/attacker/profile.txt -> /etc/app/config
Then upload to profile.txt and overwrite the symlink target, or download through a symlink to read files outside the upload area.
Fix:
Use a directory with strict ownership and permissions, not writable by other OS users.
Use safer open flags and checks:
const fh = await fs.promises.open(outPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW, 0o600);
For downloads, use lstat to reject symlinks and verify the real path remains inside the upload root:
const real = await fs.promises.realpath(filePath);
const root = await fs.promises.realpath(UPLOAD_ROOT);
if (!real.startsWith(root + path.sep)) {
return res.status(403).end();
}
9. Lack of validation for request body fields
Severity: Medium to High
Issue:
const fileName = req.body.name;
const content = Buffer.from(req.body.data, 'base64');
The code assumes req.body.name and req.body.data exist and are strings.
Exploit / failure mode:
Malformed requests can trigger exceptions:
req.body is null
name is missing
data is not a string
data is extremely large
name contains null bytes, slashes, Unicode control characters, or reserved names
This can produce 500 responses, noisy logs, or process termination depending on error handling.
Fix:
Validate schema before use.
if (
!req.body ||
typeof req.body.name !== 'string' ||
typeof req.body.data !== 'string'
) {
return res.status(400).json({ error: 'Invalid request' });
}
Use a schema validator such as zod, joi, or ajv.
10. Base64 decoding is permissive
Severity: Medium
Issue:
Buffer.from(req.body.data, 'base64');
Node’s base64 decoder is permissive and may ignore whitespace or accept malformed input.
Exploit / failure mode:
Invalid input may still produce bytes. Downstream processors may treat the result as a valid file. Attackers can also send unexpected encodings or malformed content that bypasses superficial checks.
Fix:
Strictly validate base64 before decoding.
function isValidBase64(s) {
if (typeof s !== 'string') return false;
if (s.length % 4 !== 0) return false;
return /^[A-Za-z0-9+/]*={0,2}$/.test(s);
}
Also check decoded size.
const maxBytes = 10 * 1024 * 1024;
const estimatedBytes = Math.floor(req.body.data.length * 3 / 4);
if (estimatedBytes > maxBytes) {
return res.status(413).json({ error: 'File too large' });
}
11. No upload size limit after decoding
Severity: High
Issue:
The JSON body limit is 50 MB, but there is no explicit decoded file size limit.
Exploit / failure mode:
An attacker can upload many large files, fill disk, consume memory, or overload downstream processing jobs.
Fix:
Set explicit per-file, per-user, and global limits.
const MAX_FILE_BYTES = 10 * 1024 * 1024;
if (content.length > MAX_FILE_BYTES) {
return res.status(413).json({ error: 'File too large' });
}
Also enforce quotas at storage level.
12. No rate limiting or abuse protection
Severity: High
Issue:
The API accepts uploads from any reachable internal client without rate limiting.
Exploit / failure mode:
An internal user or compromised host can repeatedly upload large files, causing:
- disk exhaustion
- memory exhaustion
- high CPU from JSON/base64 parsing
- downstream processing backlogs
Fix:
Add rate limits and concurrency limits.
const rateLimit = require('express-rate-limit');
app.use(rateLimit({
windowMs: 60_000,
max: 60
}));
For uploads, add stricter limits per authenticated user and IP. Consider a queue and backpressure mechanism.
13. Disk exhaustion risk
Severity: High
Issue:
Uploads are written directly to local disk with no quota, cleanup, or free-space check.
fs.writeFileSync(outPath, content);
Exploit / failure mode:
Attackers can fill /var or the container volume. This can crash the application, break logging, impact other services, or corrupt in-progress writes.
Fix:
Implement:
- per-user quotas
- global storage quota
- maximum file count
- retention cleanup
- free-space checks
- dedicated isolated filesystem or object storage bucket
Example:
const stat = await checkDiskSpace('/var/app/uploads');
if (stat.free < MIN_FREE_BYTES) {
return res.status(507).json({ error: 'Insufficient storage' });
}
14. Incomplete writes are exposed directly
Severity: Medium
Issue:
The file is written directly to its final path.
fs.writeFileSync(outPath, content);
Although synchronous write reduces some intra-process visibility issues, failures can still leave partial files, especially on disk errors, process crashes, or networked filesystems.
Exploit / failure mode:
Downloads or downstream jobs may see corrupted or partial files.
Fix:
Write to a temporary file in the same directory, fsync it if durability matters, then atomically rename.
const tempPath = `${outPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
await fs.promises.writeFile(tempPath, content, { flag: 'wx', mode: 0o600 });
await fs.promises.rename(tempPath, outPath);
For stronger durability, call fsync on the file and directory.
15. Race conditions with concurrent uploads
Severity: Medium to High
Issue:
Multiple uploads may target the same path.
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, content);
Exploit / failure mode:
Concurrent requests for the same user and fileName race. The final file content depends on timing. Downstream jobs may process one version while another overwrite happens.
Fix:
Use immutable server-generated file IDs or reject duplicates with exclusive create.
await fs.promises.writeFile(outPath, content, { flag: 'wx' });
If overwrites are required, use versioning or locks.
16. Uploaded files may poison downstream jobs
Severity: High
Issue:
The prompt says uploaded files may later be processed by other jobs. The service accepts arbitrary bytes and names.
Exploit / failure mode:
Attackers can upload:
- decompression bombs
- malformed PDFs/images/videos
- files exploiting parser vulnerabilities
- scripts or templates executed by later jobs
- filenames causing shell injection if downstream jobs use shell commands
- huge files that exhaust downstream CPU/memory
Fix:
Treat uploads as untrusted. Implement:
- file type allowlist
- MIME sniffing plus magic-byte checks
- antivirus/malware scanning if applicable
- sandboxed processing
- no shell interpolation of filenames
- resource limits for processing jobs
- quarantine until validation completes
Example:
import { fileTypeFromBuffer } from 'file-type';
const type = await fileTypeFromBuffer(content);
if (!type || !ALLOWED_MIME_TYPES.has(type.mime)) {
return res.status(415).json({ error: 'Unsupported file type' });
}
17. Download endpoint leaks absolute filesystem paths indirectly
Severity: Medium
Issue:
Upload response returns:
res.json({ ok: true, path: outPath });
This discloses internal filesystem layout.
Exploit / failure mode:
Attackers gain information about deployment paths, usernames, storage structure, and potential traversal targets. This also encourages clients to depend on server internals.
Fix:
Return an opaque identifier or relative logical path.
res.json({ ok: true, id: fileId });
Do not expose absolute local paths.
18. res.sendFile() error handling is missing
Severity: Medium
Issue:
No callback handles errors.
Exploit / failure mode:
Missing files, permission errors, invalid paths, and aborted responses may create unhandled logs or default Express error responses. Clients may receive inconsistent behavior. Error details may leak depending on environment and error middleware.
Fix:
Handle errors explicitly.
res.sendFile(filePath, err => {
if (err) {
if (err.code === 'ENOENT') return res.status(404).json({ error: 'Not found' });
if (!res.headersSent) return res.status(500).json({ error: 'Download failed' });
}
});
19. No centralized error handling around upload route
Severity: Medium
Issue:
The upload route can throw from:
Buffer.from(...)
fs.mkdirSync(...)
fs.writeFileSync(...)
There is no try/catch.
Exploit / failure mode:
Invalid input, permission errors, disk full, path errors, and filesystem errors produce 500 responses. Depending on Express version and async handling, thrown errors in async handlers can also result in unhandled promise rejections.
Fix:
Use async filesystem operations and catch errors.
app.post('/upload', async (req, res, next) => {
try {
// validation and write
} catch (err) {
next(err);
}
});
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'Internal error' });
});
20. Dangerous default to anonymous
Severity: Medium
Issue:
const user = req.header('X-User') || 'anonymous';
Unauthenticated or malformed requests are all grouped into one writable namespace.
Exploit / failure mode:
Users can overwrite each other’s anonymous uploads. Attackers can intentionally target shared anonymous files or fill that directory.
Fix:
Require authentication. If anonymous uploads are a real feature, generate an isolated temporary namespace per session/request and enforce strict quotas.
21. Header spoofing behind reverse proxy
Severity: High
Issue:
The service runs behind a reverse proxy, but it trusts X-User from the request.
Exploit / failure mode:
Unless the reverse proxy strips client-supplied X-User, a caller can spoof it. Even if the proxy normally sets it, internal users reaching the service directly can forge it.
Fix:
- Bind the app to localhost or a private interface only reachable from the proxy.
- Firewall direct access to the Node process.
- At the proxy, remove inbound identity headers and set trusted ones.
- Prefer signed headers or verified JWTs.
Example Nginx-style behavior:
proxy_set_header X-User "";
proxy_set_header X-User $authenticated_user;
Better: validate a signed token in the app.
22. Filesystem permissions are unspecified
Severity: Medium
Issue:
The code relies on process umask and default permissions.
fs.mkdirSync(path.dirname(outPath), { recursive: true });
fs.writeFileSync(outPath, content);
Exploit / failure mode:
Files may be created with permissions broader than intended, especially if the process umask is permissive. Other local users or processes may read uploaded files.
Fix:
Set explicit modes.
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
await fs.promises.writeFile(outPath, content, { flag: 'wx', mode: 0o600 });
Ensure the upload root is owned by the app user and not world-writable.
23. Filename edge cases and platform hazards
Severity: Medium
Issue:
User-controlled filenames may contain problematic values:
- slashes
- backslashes
- null bytes
- control characters
- very long names
- Unicode normalization collisions
- names like
. or ..
- Windows reserved names if code is later ported
- leading dashes that confuse downstream CLI tools
Exploit / failure mode:
Can cause write failures, unexpected directories, overwrites, confusing logs, or downstream command-line bugs.
Fix:
Do not use client filenames as storage filenames. Generate a UUID and store the original name as metadata after validation.
If preserving names is necessary:
const safeName = path.basename(fileName);
if (safeName !== fileName || !/^[A-Za-z0-9._-]{1,128}$/.test(safeName)) {
return res.status(400).json({ error: 'Invalid filename' });
}
24. No content-type restrictions for upload requests
Severity: Low to Medium
Issue:
express.json() will parse JSON based on content type, but the route itself does not enforce content type or schema.
Exploit / failure mode:
Unexpected request formats may behave inconsistently. Clients may send wrong types, leading to parsing errors or operational noise.
Fix:
Require JSON for this endpoint if keeping JSON uploads:
if (!req.is('application/json')) {
return res.status(415).json({ error: 'Expected application/json' });
}
Prefer multipart or streaming uploads for binary content.
25. No observability for uploads/downloads
Severity: Low to Medium
Issue:
There is no structured logging, metrics, request IDs, upload size logging, user logging, error tracking, or audit trail.
Exploit / failure mode:
Abuse, data exfiltration, large uploads, failures, and suspicious download patterns may go undetected.
Fix:
Add structured logs and metrics:
- authenticated user ID
- file ID
- upload size
- result status
- latency
- source IP, as determined safely through proxy trust
- rate-limit events
- storage usage
Avoid logging sensitive file contents or untrusted filenames without sanitization.
26. Local storage may not work with multiple app instances
Severity: Medium
Issue:
The code writes to local disk:
Exploit / failure mode:
If the service is scaled horizontally, one instance may receive the upload while another receives the download and cannot find the file. Deployments or container restarts may lose data if storage is ephemeral.
Fix:
Use shared durable storage such as object storage, a managed file store, or a database-backed metadata layer. If using local disk, use sticky routing and persistent volumes, but object storage is usually better.
27. No timeout and slow request protection
Severity: Medium
Issue:
The server accepts large JSON bodies. If the reverse proxy does not enforce strict timeouts and body-rate limits, slow clients can tie up connections.
Exploit / failure mode:
Slowloris-style requests or slow uploads consume sockets and memory.
Fix:
Configure server and proxy timeouts:
const server = app.listen(3000);
server.headersTimeout = 10_000;
server.requestTimeout = 60_000;
At the reverse proxy, enforce body size, upload duration, and minimum data rate.
28. Returning user-controlled paths in response can cause client-side misuse
Severity: Low to Medium
Issue:
res.json({ ok: true, path: outPath });
The returned path contains user-controlled components.
Exploit / failure mode:
Clients, logs, or downstream systems may treat this as a trusted local path. This can contribute to log injection, confusion, or unsafe downstream shell/file operations.
Fix:
Return only server-generated file IDs and sanitized metadata.
res.json({ ok: true, fileId });
Safer sketch
A safer design would avoid client-controlled filesystem paths entirely:
const express = require('express');
const fs = require('fs/promises');
const crypto = require('crypto');
const path = require('path');
const app = express();
const UPLOAD_ROOT = '/var/app/uploads';
const MAX_FILE_BYTES = 10 * 1024 * 1024;
app.use(express.json({ limit: '15mb' }));
function requireAuth(req, res, next) {
// Replace with real auth.
if (!req.user) return res.status(401).json({ error: 'Unauthorized' });
next();
}
function validateBase64(s) {
return typeof s === 'string' &&
s.length % 4 === 0 &&
/^[A-Za-z0-9+/]*={0,2}$/.test(s);
}
function safeResolve(base, ...parts) {
const resolved = path.resolve(base, ...parts);
const root = path.resolve(base);
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
throw new Error('Invalid path');
}
return resolved;
}
app.post('/upload', requireAuth, async (req, res, next) => {
try {
if (!req.is('application/json')) {
return res.status(415).json({ error: 'Expected application/json' });
}
const originalName = req.body?.name;
const data = req.body?.data;
if (
typeof originalName !== 'string' ||
originalName.length > 255 ||
!validateBase64(data)
) {
return res.status(400).json({ error: 'Invalid request' });
}
const estimatedBytes = Math.floor(data.length * 3 / 4);
if (estimatedBytes > MAX_FILE_BYTES) {
return res.status(413).json({ error: 'File too large' });
}
const content = Buffer.from(data, 'base64');
if (content.length > MAX_FILE_BYTES) {
return res.status(413).json({ error: 'File too large' });
}
const userId = req.user.id;
const fileId = crypto.randomUUID();
const userDir = safeResolve(UPLOAD_ROOT, userId);
await fs.mkdir(userDir, { recursive: true, mode: 0o700 });
const finalPath = safeResolve(userDir, fileId);
const tempPath = safeResolve(userDir, `${fileId}.tmp`);
await fs.writeFile(tempPath, content, { flag: 'wx', mode: 0o600 });
await fs.rename(tempPath, finalPath);
// Store metadata separately: fileId, userId, originalName, size, contentType, createdAt.
res.status(201).json({ ok: true, fileId });
} catch (err) {
next(err);
}
});
app.get('/download/:fileId', requireAuth, async (req, res, next) => {
try {
const fileId = req.params.fileId;
if (!/^[0-9a-fA-F-]{36}$/.test(fileId)) {
return res.status(400).json({ error: 'Invalid file id' });
}
const filePath = safeResolve(UPLOAD_ROOT, req.user.id, fileId);
res.sendFile(filePath, err => {
if (err && !res.headersSent) {
if (err.code === 'ENOENT') return res.status(404).json({ error: 'Not found' });
next(err);
}
});
} catch (err) {
next(err);
}
});
The production version should also add rate limiting, quotas, malware/content scanning if relevant, structured audit logging, proxy hardening, and durable storage.