File uploads represent one of the most vulnerable parts of web applications. I’ve worked on numerous Ruby projects where implementing secure file upload systems was critical to preventing security breaches. In this article, I’ll share eight essential techniques that form the foundation of secure file handling in Ruby applications.
Content Type Validation
When accepting user uploads, never trust the file extension alone. Content type validation serves as your first line of defense against malicious files.
def validate_content_type(file)
allowed_types = %w[image/jpeg image/png image/gif application/pdf]
# Check the declared content type
unless allowed_types.include?(file.content_type)
raise InvalidFileError, "Content type #{file.content_type} not allowed"
end
# Perform secondary validation using file analysis
mime_type = FileMagic.new(FileMagic::MAGIC_MIME).file(file.path)
unless allowed_types.any? { |type| mime_type.start_with?(type) }
raise InvalidFileError, "File contents don't match declared type"
end
end
This approach uses both the declared content type and actual file analysis. The FileMagic
gem helps detect the actual MIME type based on file content rather than relying solely on extension or user-provided information.
For Rails applications, you can implement this at the model level using ActiveStorage validations:
class Document < ApplicationRecord
has_one_attached :file
validate :acceptable_file
private
def acceptable_file
return unless file.attached?
unless file.content_type.in?(%w[image/jpeg image/png application/pdf])
errors.add(:file, "must be a JPEG, PNG, or PDF")
end
unless file.byte_size <= 10.megabytes
errors.add(:file, "is too large (maximum is 10 MB)")
end
end
end
Secure Filename Handling
Filenames can be weaponized to perform path traversal attacks or trick systems into executing malicious code. Proper sanitization is essential.
def sanitize_filename(filename)
# Remove directory path components
sanitized = File.basename(filename)
# Replace special characters
sanitized.gsub!(/[^0-9A-Za-z.\-]/, '_')
# Add a random prefix to prevent overwriting
"#{SecureRandom.uuid}_#{sanitized}"
end
This function removes path components that could be used for directory traversal, replaces special characters that might cause issues in various file systems, and adds a random UUID to prevent filename collisions and potential overwrites.
For production systems, I recommend going further with a comprehensive approach:
def secure_filename(original_filename)
# Extract file extension
extension = File.extname(original_filename).downcase
# Whitelist allowed extensions as an additional safety measure
allowed_extensions = %w[.jpg .jpeg .png .gif .pdf .doc .docx]
extension = '.txt' unless allowed_extensions.include?(extension)
# Generate completely new filename with safe extension
"#{SecureRandom.uuid}#{extension}"
end
File Size Limitations
Unchecked file sizes can lead to denial of service through disk space exhaustion or memory consumption during processing.
class FileSizeValidator < ActiveModel::Validator
def validate(record)
file = record.attachment
if file.attached? && file.blob.byte_size > options[:max_size]
max_size_mb = options[:max_size] / (1024 * 1024)
record.errors.add(:attachment, "file size exceeds the limit of #{max_size_mb} MB")
end
end
end
class Upload < ApplicationRecord
has_one_attached :attachment
validates_with FileSizeValidator, max_size: 10.megabytes
end
For non-Rails applications, implement a similar check manually:
def validate_file_size(file_path, max_size_bytes = 10.megabytes)
file_size = File.size(file_path)
if file_size > max_size_bytes
File.delete(file_path) if File.exist?(file_path) # Clean up
raise FileTooLargeError, "File size (#{file_size / 1.megabyte} MB) exceeds maximum allowed (#{max_size_bytes / 1.megabyte} MB)"
end
end
Antivirus Scanning
Scanning uploads for malware is crucial for systems handling files that will be accessed by other users or systems.
class VirusScanner
def initialize
@clamscan_path = '/usr/bin/clamscan'
end
def scan(file_path)
return true unless File.exist?(@clamscan_path) # Skip if ClamAV not installed
result = `#{@clamscan_path} --no-summary #{file_path}`
status = $?.exitstatus
case status
when 0
# No virus found
true
when 1
# Virus found
File.delete(file_path) if File.exist?(file_path)
false
else
# Scanning error
Rails.logger.error("Virus scanning error: #{result}")
false
end
end
end
For production systems, I recommend using background jobs for virus scanning:
class VirusScanJob < ApplicationJob
queue_as :default
def perform(upload_id)
upload = Upload.find(upload_id)
scanner = VirusScanner.new
unless scanner.scan(ActiveStorage::Blob.service.path_for(upload.file.key))
# Mark as infected and notify admin
upload.update(status: 'infected')
AdminMailer.virus_detected(upload).deliver_later
end
end
end
Metadata Extraction and Validation
Examining and validating file metadata provides another security layer and helps verify file authenticity.
def extract_image_metadata(file_path)
require 'exifr/jpeg'
begin
metadata = EXIFR::JPEG.new(file_path)
# Validate image dimensions
if metadata.width > 5000 || metadata.height > 5000
raise InvalidImageError, "Image dimensions too large"
end
# Check for suspicious creation dates
if metadata.date_time && metadata.date_time > Time.now
raise InvalidImageError, "Invalid creation date"
end
# Return sanitized metadata
{
width: metadata.width,
height: metadata.height,
date_time: metadata.date_time,
camera: metadata.model
}
rescue EXIFR::MalformedJPEG
raise InvalidImageError, "Not a valid JPEG file"
end
end
For PDF files, use a similar approach with appropriate libraries:
def validate_pdf(file_path)
require 'pdf-reader'
begin
reader = PDF::Reader.new(file_path)
# Check for JavaScript (potential security risk)
if reader.pages.any? { |page| page.attributes[:JS] }
raise SuspiciousPdfError, "PDF contains JavaScript"
end
# Verify page count is reasonable
if reader.page_count > 1000
raise InvalidPdfError, "PDF exceeds maximum page count"
end
true
rescue PDF::Reader::MalformedPDFError
raise InvalidPdfError, "Not a valid PDF file"
end
end
Storage Strategy Abstraction
Creating an abstraction layer for file storage enhances security by ensuring consistent handling regardless of storage backend.
class SecureStorage
def self.store(file, options = {})
strategy = options.fetch(:strategy, Rails.env.production? ? :s3 : :local)
handler = storage_strategy(strategy)
handler.store(file, options)
end
def self.retrieve(identifier, options = {})
strategy = options.fetch(:strategy, Rails.env.production? ? :s3 : :local)
handler = storage_strategy(strategy)
handler.retrieve(identifier, options)
end
def self.storage_strategy(strategy)
case strategy
when :local
LocalStorage.new
when :s3
S3Storage.new
else
raise ArgumentError, "Unknown storage strategy: #{strategy}"
end
end
class LocalStorage
def store(file, options = {})
sanitized_filename = sanitize_filename(file.original_filename)
directory = options.fetch(:directory, 'uploads')
path = File.join(Rails.root, 'storage', directory)
FileUtils.mkdir_p(path) unless File.directory?(path)
final_path = File.join(path, sanitized_filename)
File.open(final_path, 'wb') { |f| f.write(file.read) }
{ path: final_path, filename: sanitized_filename }
end
def retrieve(identifier, options = {})
# Implementation for retrieval logic
end
private
def sanitize_filename(filename)
# Implementation from earlier example
end
end
class S3Storage
# Similar implementation for S3 storage
end
end
Access Control Implementation
Proper access controls ensure that users can only access files they’re authorized to view.
class FileAccessController < ApplicationController
before_action :authenticate_user!
before_action :verify_access_permission, only: [:show, :download]
def show
@file = Upload.find(params[:id])
render :show
end
def download
@file = Upload.find(params[:id])
# Log the access attempt
AccessLog.create(
user: current_user,
upload: @file,
action: 'download',
ip_address: request.remote_ip
)
# Stream file using proper content type and disposition
send_data @file.data,
type: @file.content_type,
disposition: 'attachment',
filename: @file.filename
end
private
def verify_access_permission
@file = Upload.find(params[:id])
unless @file.accessible_by?(current_user)
flash[:alert] = "You don't have permission to access this file"
redirect_to root_path
end
end
end
For more sophisticated access control, implement a dedicated authorization system:
class FilePermission < ApplicationRecord
belongs_to :upload
belongs_to :user
enum permission: { read: 0, edit: 1, admin: 2 }
validates :user_id, uniqueness: { scope: :upload_id }
end
class Upload < ApplicationRecord
has_many :file_permissions
has_many :authorized_users, through: :file_permissions, source: :user
def accessible_by?(user)
return true if user.admin? || user.id == self.user_id
file_permissions.exists?(user_id: user.id)
end
def grant_access_to(user, permission = :read)
file_permissions.create(user: user, permission: permission)
end
def revoke_access_from(user)
file_permissions.where(user: user).destroy_all
end
end
Secure Download Implementation
Implementing secure downloads prevents unauthorized access and protects sensitive file information.
class SecureDownloadController < ApplicationController
def download
# Use a signed URL with expiration to prevent unauthorized access
token = params[:token]
begin
# Verify the token is valid and not expired
payload = JWT.decode(
token,
Rails.application.credentials.secret_key_base,
true,
{ algorithm: 'HS256', verify_expiration: true }
).first
upload_id = payload['upload_id']
upload = Upload.find(upload_id)
# Stream the file through a controller to avoid exposing storage URLs
response.headers['Content-Type'] = upload.content_type
response.headers['Content-Disposition'] = "attachment; filename=\"#{upload.filename}\""
# Use streaming for large files to avoid memory issues
if upload.stored_locally?
send_file upload.file_path, disposition: 'attachment'
else
# For cloud storage, stream the file
stream = upload.storage_service.stream(upload.storage_key)
self.response_body = stream
end
rescue JWT::ExpiredSignature
render plain: 'Download link has expired', status: :gone
rescue JWT::DecodeError, ActiveRecord::RecordNotFound
render plain: 'Invalid download link', status: :not_found
end
end
def generate_download_token(upload, expires_in = 1.hour)
payload = {
upload_id: upload.id,
exp: expires_in.from_now.to_i
}
JWT.encode(
payload,
Rails.application.credentials.secret_key_base,
'HS256'
)
end
end
For generating secure download links in views or emails:
def secure_download_url(upload)
token = generate_download_token(upload)
download_with_token_url(token: token)
end
Comprehensive Implementation Example
Here’s how these techniques come together in a comprehensive file upload service:
class SecureFileService
ALLOWED_TYPES = {
image: %w[image/jpeg image/png image/gif],
document: %w[application/pdf application/msword application/vnd.openxmlformats-officedocument.wordprocessingml.document],
spreadsheet: %w[application/vnd.ms-excel application/vnd.openxmlformats-officedocument.spreadsheetml.sheet]
}.freeze
MAX_FILE_SIZES = {
image: 5.megabytes,
document: 10.megabytes,
spreadsheet: 8.megabytes
}.freeze
def initialize(user)
@user = user
@storage_service = determine_storage_service
@virus_scanner = VirusScanner.new
end
def upload(file, type: :document, metadata: {})
# Validate file presence
raise ArgumentError, "No file provided" if file.nil?
# Validate file type
validate_file_type(file, type)
# Validate file size
validate_file_size(file, type)
# Sanitize filename and determine storage path
sanitized_filename = secure_filename(file.original_filename)
storage_path = generate_storage_path(type, sanitized_filename)
# Store file temporarily for virus scanning
temp_path = store_temporarily(file)
# Scan for viruses
unless @virus_scanner.scan(temp_path)
File.delete(temp_path) if File.exist?(temp_path)
raise SecurityError, "Potential security threat detected in file"
end
# Extract and validate metadata based on file type
extracted_metadata = extract_metadata(temp_path, file.content_type)
# Store file permanently
storage_result = @storage_service.store(
temp_path,
path: storage_path,
content_type: file.content_type
)
# Clean up temporary file
File.delete(temp_path) if File.exist?(temp_path)
# Create database record
upload = Upload.create!(
user: @user,
filename: sanitized_filename,
original_filename: file.original_filename,
content_type: file.content_type,
byte_size: file.size,
checksum: Digest::SHA256.file(temp_path).hexdigest,
storage_path: storage_result[:path],
storage_key: storage_result[:key],
metadata: metadata.merge(extracted_metadata),
category: type
)
# Generate access token
access_token = generate_access_token(upload)
# Return result
{
id: upload.id,
filename: upload.filename,
content_type: upload.content_type,
byte_size: upload.byte_size,
access_token: access_token,
metadata: upload.metadata
}
end
def download(upload_id)
upload = Upload.find_by(id: upload_id)
# Verify upload exists
raise ActiveRecord::RecordNotFound, "Upload not found" unless upload
# Verify user has access
unless upload.accessible_by?(@user)
raise SecurityError, "Access denied"
end
# Generate download token
token = generate_download_token(upload)
# Return download info
{
id: upload.id,
filename: upload.filename,
content_type: upload.content_type,
download_url: download_url(token),
expires_at: 1.hour.from_now
}
end
private
def validate_file_type(file, type)
allowed = ALLOWED_TYPES[type] || []
unless allowed.include?(file.content_type)
raise ArgumentError, "Invalid file type: #{file.content_type}. Allowed types: #{allowed.join(', ')}"
end
end
def validate_file_size(file, type)
max_size = MAX_FILE_SIZES[type] || 5.megabytes
if file.size > max_size
raise ArgumentError, "File too large (#{file.size / 1.megabyte} MB). Maximum size: #{max_size / 1.megabyte} MB"
end
end
def secure_filename(original_filename)
# Implementation from earlier examples
end
def generate_storage_path(type, filename)
date_path = Time.current.strftime('%Y/%m/%d')
"#{type}/#{date_path}/#{filename}"
end
def store_temporarily(file)
temp_dir = Rails.root.join('tmp', 'uploads')
FileUtils.mkdir_p(temp_dir) unless File.directory?(temp_dir)
temp_path = File.join(temp_dir, "#{SecureRandom.uuid}#{File.extname(file.original_filename)}")
File.open(temp_path, 'wb') { |f| f.write(file.read) }
temp_path
end
def extract_metadata(file_path, content_type)
case content_type
when /^image\//
extract_image_metadata(file_path)
when /^application\/pdf/
extract_pdf_metadata(file_path)
else
{}
end
rescue StandardError => e
Rails.logger.error("Metadata extraction error: #{e.message}")
{}
end
def determine_storage_service
# Logic to determine which storage service to use
Rails.env.production? ? S3StorageService.new : LocalStorageService.new
end
def generate_access_token(upload)
# Implementation for generating access tokens
end
def generate_download_token(upload)
# Implementation from earlier examples
end
def download_url(token)
# Generate URL for the download endpoint with token
end
end
By implementing these eight techniques in your Ruby applications, you create a robust defense system against common file upload vulnerabilities. The key is applying multiple layers of security while maintaining usability for legitimate users.
Remember that security is an ongoing process. Regularly update your security measures as new threats emerge and stay informed about best practices in the Ruby community. For highly sensitive applications, consider additional measures like rate limiting, IP-based restrictions, and regular security audits.
When implementing file uploads, think like an attacker but design like a user. This balance will help you create systems that are both secure and usable, providing the best experience while protecting your application and its data.