5.9.8. Secure File Handling
Table of Contents
Why Secure File Handling Matters
Letting users upload or download files is powerful, but it is also one of the most dangerous features in a backend.
Attackers can try to:
- Upload malicious scripts or executables.
- Overwrite important files on your server.
- Use file names or paths to access other users’ data.
- Abuse large uploads to exhaust disk or memory.
- Upload content that is illegal or violates policy.
Never treat user-provided files as safe. Always validate, restrict, and isolate them.
In this chapter we focus on practical rules, patterns, and examples that you can apply independently of any specific framework.
General Principles for Secure File Handling
Treat all user input as untrusted
A file is just another kind of user input. Everything about it is untrusted:
- File name
- File size
- File content
- File extension
- Reported MIME type (like
image/png)
You must not:
- Use the user’s file name directly as a path on the server.
- Trust the MIME type the browser sends.
- Assume the file is actually what the extension says.
Instead, you inspect and restrict every important aspect of the file.
Defense in depth
You do not rely on a single check. You combine many small protections.
Typical layers:
| Layer | Example |
|---|---|
| Access control | Only logged in users may upload files. |
| Size limits | Reject files over 5 MB. |
| Type validation | Only allow images jpg, png, gif. |
| Storage isolation | Store uploads outside public web root. |
| Name randomization | Use generated IDs instead of original file names. |
| Virus scanning | Scan files with an antivirus before accepting. |
| Authorization on access | Only owner or admin can download a file. |
Secure file handling is a combination of multiple controls, not a single “magic” check.
Validating File Names and Paths
Why file names are dangerous
User-provided file names can contain:
- Path traversal sequences:
../../etc/passwd - Strange Unicode characters
- Very long names
- Characters that mean something in shells or URLs
If you simply join a user file name to your upload folder, like:
# DANGEROUS
full_path = "/var/app/uploads/" + user_filename
an attacker might choose ../../../../var/app/config.py and make your code write or overwrite sensitive files.
Path traversal protection
To avoid path traversal:
- Ignore the directory portion of the user’s name.
- Normalize paths and ensure they stay inside a single base directory.
- Use your own generated name for storage.
Example pattern in Python:
import os
import uuid
BASE_DIR = "/var/app/uploads"
def safe_save(user_filename: str, file_bytes: bytes) -> str:
# Only keep the last part, remove any directories
name_only = os.path.basename(user_filename)
# Extract extension (or empty string)
_, ext = os.path.splitext(name_only)
# Generate safe random name, keep extension if allowed
random_name = f"{uuid.uuid4().hex}{ext}"
final_path = os.path.join(BASE_DIR, random_name)
# Extra safety: resolve and verify base path
real_base = os.path.realpath(BASE_DIR)
real_final = os.path.realpath(final_path)
if not real_final.startswith(real_base + os.sep):
raise RuntimeError("Unsafe file path generated")
with open(final_path, "wb") as f:
f.write(file_bytes)
return random_nameKey ideas:
- Use
os.path.basenameto drop any user-controlled directory components. - Generate your own unique name, for instance with
uuid4. - Resolve and compare real paths with
realpathto ensure they stay under your upload folder.
Never use a user-provided path directly on your filesystem, and never allow .. to influence where you write.
Sanitizing displayed file names
You often still want to show something like the original name to the user in the UI.
Approach:
- Store two fields:
stored_filename(server generated, used on disk).original_filename(user provided, only used for display).- Sanitize the display name before using it in HTML or logs.
For example:
- Strip control characters.
- Limit length.
- Escape HTML.
Example rule:
Use server-generated names for storage and routing, user-provided names only for display and always HTML-escape them.
Limiting File Size
Why size limits matter
Without limits, an attacker can:
- Upload huge files to fill your disk.
- Send a very large upload to tie up memory or CPU for a long time.
- Try a “zip bomb” which decompresses into huge size.
Set limits at multiple levels:
- Web server or reverse proxy (Nginx, etc): maximum request body size.
- Application server: maximum upload size.
- Application code: business specific limits per file type.
Practical size limit strategies
Examples:
- Limit a profile picture to 2 MB.
- Limit a PDF document to 10 MB.
- Reject any upload above your maximum limit with a clear error message.
In many frameworks you can:
- Set a global max body size.
- Check
Content-Lengthheader and reject early. - Stream content instead of reading all into memory.
Python style pseudo code:
MAX_BYTES = 5 * 1024 * 1024 # 5 MB
def validate_size(file_obj) -> None:
# If you can read the size from metadata:
if file_obj.size > MAX_BYTES:
raise ValueError("File too large")
# Or if streaming, count as you read:
total = 0
chunks = []
for chunk in file_obj.chunks():
total += len(chunk)
if total > MAX_BYTES:
raise ValueError("File too large")
chunks.append(chunk)
return b"".join(chunks)Always enforce a maximum allowed size for uploads, do not rely on the client to respect it.
Validating File Types
Why extensions and MIME types are not enough
Attackers can fake:
- File extensions:
evil.php.jpg - MIME types: claim
image/pngfor aphpscript.
Only checking ".jpg" or Content-Type: image/jpeg is not reliable.
Use a combination of:
- Allowed extensions.
- Allowed MIME types.
- Content based checks (magic bytes, simple parsing, or library-level validation).
Whitelisting allowed types
Use a whitelist (allow list), never a blacklist:
| Context | Good allow list example |
|---|---|
| Avatars | .jpg, .jpeg, .png, .gif |
| Documents | .pdf, .docx, .xlsx |
| Public downloads | Only specific known safe file types |
Example logic:
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png"}
ALLOWED_MIME_TYPES = {"image/jpeg", "image/png"}
def validate_file_type(filename: str, mime_type: str, content: bytes) -> None:
# Extension check
_, ext = os.path.splitext(filename.lower())
if ext not in ALLOWED_EXTENSIONS:
raise ValueError("File type not allowed")
# MIME type check (from header / environment)
if mime_type not in ALLOWED_MIME_TYPES:
raise ValueError("MIME type not allowed")
# Content-based check for images
from PIL import Image
from io import BytesIO
try:
img = Image.open(BytesIO(content))
img.verify() # verify image structure
except Exception:
raise ValueError("Invalid image file")Do not rely on extensions alone. Validate file content with appropriate libraries when possible.
Avoid executing uploaded files
Whatever the file type:
- Do not execute it as code.
- Do not evaluate its content.
For example, never:
- Run an uploaded script with
python,bash, or any interpreter. - Dynamically import uploaded Python code.
- Use
evalon uploaded content.
If you must process files with external tools, see the section on “Handling External Tools” later.
Safe Storage Locations and Permissions
Store files outside the public web root
A common mistake:
- Server root is
/var/www/html. - You put uploads in
/var/www/html/uploads. - Then a user can access any file directly through
https://example.com/uploads/filename.ext.
If an attacker manages to upload a script that the web server can execute, they may get remote code execution.
More secure:
- Use a storage directory outside the public web root, for example
/var/app/uploads. - Serve files through a backend endpoint that checks permissions and streams content.
Directory layout example:
/var/www/html/ # public static assets only (your JS, CSS, images)
/var/app/uploads/ # user-uploaded content, not directly servedKeep user-uploaded files outside the web server document root, and expose them only through controlled endpoints.
File and directory permissions
Restrict filesystem permissions so that:
- The application user can read and write upload directories.
- Other system users cannot access them if not necessary.
- The web server process cannot execute scripts from upload directories.
Use OS-level permissions:
chmod 700on private directories.- Avoid
chmod 777.
Simple principle:
| Resource | Permission goal |
|---|---|
| Upload folder | App user: read/write. Others: minimal access. |
| Config / keys | App user only. No read for regular users. |
| Log files | App user: write. Admin only read. |
Using cloud/object storage
Often you will use S3 or similar object storage:
- Buckets can be private or public.
- For private content, you typically:
- Store the object with private ACL.
- Generate temporary presigned URLs for download.
Security tips:
- Do not expose the bucket name and region in public URLs if possible.
- Use least-privilege IAM roles.
- Apply server-side encryption when needed.
- Limit max object size at application level.
Preventing Overwrites and Conflicts
Unique naming
If you use original names, two users uploading resume.pdf might overwrite each other.
Better:
- Generate unique file IDs.
- Optionally keep the original name as metadata.
Common approaches:
| Strategy | Example |
|---|---|
| UUID | 550e8400e29b41d4a716446655440000.pdf |
| Timestamp + random | 20240828_101530_ab12cd34.png |
| Hash-based | sha256-of-content.ext |
UUID is simple and effective.
import uuid
import os
def generate_storage_name(original_filename: str) -> str:
_, ext = os.path.splitext(original_filename.lower())
return f"{uuid.uuid4().hex}{ext}"Handling duplicates logically
If users should not upload the same file twice, or you want to detect duplicates:
- Compute a hash of the content, for example SHA256.
- Store the hash in the database.
- Before saving a new file, check if the hash already exists.
Pseudo code:
import hashlib
def file_hash(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()Note that this is for deduplication, not security. Do not use this alone for integrity or authentication.
Securing File Downloads
Enforce authorization before download
Every download endpoint must check:
- Is the requester authenticated?
- Is the requester allowed to access this specific file?
Typical data model:
| Column | Meaning |
|---|---|
id | File record ID |
owner_user_id | User who uploaded or owns the file |
stored_name | File name on disk or object store |
original_name | Display name |
content_type | Stored safe MIME type |
size_bytes | Size |
private | Boolean flag |
Example pseudo code for download:
def download_file(file_id: int, current_user_id: int):
file = db.get_file(file_id)
if not file:
raise NotFound()
if file.private and file.owner_user_id != current_user_id and not current_user_is_admin():
raise Forbidden()
# Stream the file from disk or storage
return stream_file(file.stored_name, content_type=file.content_type)Never rely only on a random URL to protect files. Always check permissions on every download.
Content-Disposition and safe names
When sending a file, you often set Content-Disposition:
Content-Disposition: attachment; filename="report.pdf"Do not use the raw user-provided name without sanitizing:
- Limit length.
- Remove control characters.
- Escape dangerous characters like quotes.
For safety, you can:
- Use a simplified, ASCII-only display name.
- Fall back to a generic name like
file.pdfif sanitizing fails.
Avoiding content sniffing issues
Browsers may try to detect file types themselves, which can cause issues if your headers are wrong.
- Set
Content-Typecorrectly. - Consider sending the
X-Content-Type-Options: nosniffheader. - For uploads that may contain HTML, do not send them as
text/htmlunless you really mean to.
This reduces risk of the browser executing the content as HTML or JavaScript.
Scanning and Processing Uploaded Files
Antivirus and malware scanning
For systems that accept user content, consider:
- Integrating an antivirus like ClamAV.
- Scanning files before final acceptance.
- Quarantining or deleting suspicious files.
Workflow:
- User uploads file.
- Store file in a temporary location.
- Run antivirus scan.
- If clean, move to permanent storage. If not, reject and log.
Handling external tools safely
Sometimes you must call external commands for processing, for example:
- Image resizing.
- Video transcoding.
- Document conversion.
Risks:
- Command injection if you pass user-controlled values to shell commands.
- Resource exhaustion, for instance converting a huge video.
Guidelines:
- Do not use
shell=Truewith user-controlled input. - Use argument lists with
subprocess.run([...]). - Add timeouts to external commands.
- Run heavy processing in background jobs with resource controls.
Example pattern:
import subprocess
def resize_image(input_path: str, output_path: str):
subprocess.run(
["convert", input_path, "-resize", "1024x1024>", output_path],
check=True,
timeout=10,
)
Here, convert is called with explicit argument list, not a shell string.
Avoiding Sensitive Data Exposure
Do not store secrets inside uploaded files
If users upload configuration files or logs, ensure you do not accidentally expose:
- API keys
- Passwords
- Access tokens
You cannot fully control what users upload, but you can:
- Mark such features as “for admin only”.
- Avoid making these files publicly accessible.
- Educate users if necessary.
Separate public and private files
Not all uploads are equal.
Typical categories:
| Type | Example | Access pattern |
|---|---|---|
| Public | Product images on an e-commerce site | Everyone can view |
| Private | User invoices, medical docs | Only specific user and admins |
| Internal | Logs, export files | Only staff or internal systems |
Design:
- Different buckets / directories for each category.
- Different URL schemes or services.
- Different permissions and access controls.
Rate Limiting and Abuse Protection
Throttling upload and download
To prevent denial of service or abuse:
- Limit how many uploads a user can perform per minute or per hour.
- Limit total daily storage per user.
- Limit download rates and frequency for private content.
Examples:
- At API gateway or reverse proxy, restrict requests per IP.
- In the application, track counters: how many uploads this user has created today.
Storage quotas
To avoid some users consuming all disk space:
- Assign a quota per user, team, or tenant.
- Track used storage in the database.
- Deny new uploads when quota is exceeded.
Example model:
| Column | Meaning |
|---|---|
user_id | Owner |
storage_limit | Maximum number of bytes allowed |
used_bytes | Current usage |
Each time a file is uploaded or deleted, update used_bytes.
Logging and Auditing File Operations
What to log
Record key events:
- File uploaded:
- User ID.
- File ID.
- Original name.
- Size.
- IP address.
- File downloaded:
- User ID.
- File ID.
- Time.
- File deleted:
- Who deleted and when.
Do not log sensitive file contents, but do log metadata.
Detecting suspicious activity
Use logs to spot:
- Many upload failures from the same IP.
- Very large or frequent uploads from a single account.
- Strange file types in a system that only expects images.
You can feed these logs into your monitoring system or SIEM to create alerts.
Common Pitfalls and How to Avoid Them
Typical mistakes
Here are frequent insecure patterns and their safer alternatives:
| Insecure pattern | Safer approach |
|---|---|
Saving to uploads/ with user_filename directly | Use basename, random ID, and extension whitelist |
| Allowing any file type | Restrict to a small, explicit allow list |
| Reading full file into memory unconditionally | Stream uploads and downloads |
| Serving uploads directly from public web root | Store outside, serve through controlled endpoints |
| No max size checks | Enforce limits at reverse proxy and application |
Trusting Content-Type only | Validate file content with libraries |
| Using shell commands with user input | Use argument lists and avoid shell=True |
| No authorization on download | Check per-file ownership and permissions |
Summary checklist
You can use this short checklist when implementing file handling:
Secure File Handling Checklist
- All user file names are ignored for paths, only used for display.
- Path traversal is prevented with
basenameand base directory checks. - Unique server-generated names (UUIDs or similar) are used for storage.
- Maximum upload size is enforced at multiple levels.
- Only a small set of file types is allowed, with content validation.
- Upload directories are outside the public web root.
- Files and directories use least-privilege filesystem permissions.
- Download endpoints always check authentication and authorization.
- External tools are called safely, without
shell=True, with timeouts. - Logs capture key file events for auditing.
If you follow these rules systematically, your backend will be much more resilient against common file-related attacks.
Views: 8
KAHIBARO