If you've ever managed private files in web applications, you've probably encountered the security distribution problem. It's a vulnerability as common as it is underestimated, one that can transform into a serious risk for privacy and data security.
The Direct Access Vulnerability: A Universal Problem
Picture this scenario: you have an application managing confidential documents - contracts, financial reports, medical data. Your files are accessible through direct URLs like https://app.com/uploads/confidential-document.pdf
. An authorized user gets this link, shares it (perhaps by mistake) via email or chat, and suddenly anyone with that URL can download the document.
The mechanism is as simple as it is dangerous:
- The application generates static URLs for files
- URLs are "guessable" or easily shareable
- There's no access control at the request level
- Anyone with the URL can access the file
The impact is significant: sensitive data exfiltration, privacy violations, potential legal consequences. You don't need to be a hacker, just a copy-paste at the wrong moment.
This vulnerability isn't specific to any framework or technology - you'll find it in PHP, Python, Node.js, Rails applications. Anywhere files are served directly from the web server without application-level controls.
Solution Strategies: Architectural Principles
There are several approaches to solve this problem, each with its own trade-offs. Let's analyze the main methods, regardless of implementation technology.
1. Backend Proxy Pattern
Concept: All file requests pass through the application, which acts as an authorization proxy.
How it works:
- User requests a file through an application endpoint (e.g.,
/documents/123/download
) - Application verifies authentication and authorization
- If authorized, the application serves the file
Advantages:
- Total control over authentication and authorization
- Complete auditing of all accesses
- Complex business logic (e.g., "only owner on business days")
Disadvantages:
- Potential performance issues
- Higher load on application servers
2. Web Server Offloading
Concept: The application authorizes, but delegates actual transfer to the web server (Nginx, Apache).
How it works:
- Application verifies permissions
- Sends a special header to the web server (e.g.,
X-Accel-Redirect
for Nginx) - Web server handles file transfer
Advantages:
- Excellent performance
- Immediate application worker liberation
- Security controls maintained
Disadvantages:
- Only works with files on local filesystem
- Requires specific web server configuration
3. Signed URLs
Concept: The application generates temporary cryptographically signed URLs pointing directly to storage.
How it works:
- Application verifies authorization
- Generates a signed URL with temporal expiration
- User downloads directly from storage (S3, GCS, etc.)
Advantages:
- Maximum scalability
- Zero server load for transfer
- Global performance with CDN
Disadvantages:
- Dependency on cloud services
- More complex configuration
4. Advanced Proxy with Auth Request
Concept: The web server acts as an intelligent proxy, delegating only authorization to the application.
How it works:
- Web server receives file request
- Makes a sub-request to the application for authorization
- If authorized, serves file directly from storage
Advantages:
- Optimal performance
- Clean separation between authorization and transfer
Disadvantages:
- Very complex configuration
- Requires advanced web server modules
Implementation with Rails and Active Storage
Now let's see how to implement these strategies using Rails and Active Storage, which has become the de facto standard for file management in Rails.
Base Setup with Active Storage
First, let's ensure Active Storage is configured correctly:
class Document < ApplicationRecord
has_one_attached :file
belongs_to :user
# Avoid public URLs by default
def self.attachment_definitions
{ file: { public: false } }
end
end
Strategy 1: Backend Proxy with Active Storage
Let's implement the proxy pattern using Active Storage:
Rails.application.routes.draw do
resources :documents, only: [:show, :index] do
member do
get :download
end
end
end
class DocumentsController < ApplicationController
before_action :authenticate_user!
def download
@document = Document.find(params[:id])
# Authorization (using Pundit or similar)
authorize @document, :download?
# Log access for auditing
AccessLog.create!(
user: current_user,
document: @document,
ip_address: request.remote_ip,
accessed_at: Time.current
)
# Serve file through Active Storage
redirect_to rails_blob_path(@document.file, disposition: "attachment")
end
private
def authorize_download!
# Implement your authorization logic
# e.g., only owner can download
unless @document.user == current_user || current_user.admin?
raise Pundit::NotAuthorizedError
end
end
end
Strategy 2: Web Server Offloading with Nginx
For local disk files, we can use X-Accel-Redirect
:
class DocumentsController < ApplicationController
def download
@document = Document.find(params[:id])
authorize @document, :download?
# Instead of serving through Rails, use Nginx
if Rails.env.production?
# Internal path for Nginx (configured on server)
internal_path = "/internal/storage/#{@document.file.blob.key}"
response.headers['X-Accel-Redirect'] = internal_path
response.headers['Content-Type'] = @document.file.content_type
response.headers['Content-Disposition'] = "attachment; filename=\"#{@document.file.filename}\""
head :ok
else
# In development, use standard behavior
redirect_to rails_blob_path(@document.file, disposition: "attachment")
end
end
end
And the corresponding Nginx configuration:
# In nginx.conf
location /internal/storage/ {
internal;
alias /app/storage/;
}
Strategy 3: Signed URLs for Cloud Storage
If you use S3 or Google Cloud Storage, signed URLs are the most elegant solution:
class DocumentsController < ApplicationController
def download
@document = Document.find(params[:id])
authorize @document, :download?
# Generate signed URL valid for 1 hour
signed_url = @document.file.url(expires_in: 1.hour)
# Log access
AccessLog.create!(
user: current_user,
document: @document,
ip_address: request.remote_ip
)
# Redirect to signed URL
redirect_to signed_url, allow_other_host: true
end
end
Recommendations for Choosing the Approach
For applications with local disk files: use web server offloading (Strategy 2). It's the best compromise between performance and simplicity.
For cloud-native applications: go straight to signed URLs (Strategy 3). Maximum scalability and global performance.
For prototypes or very small files: backend proxy (Strategy 1) is fine to start, but plan the evolution immediately.
For complex architectures with mixed storage: advanced proxy (Strategy 4) offers maximum flexibility, but requires advanced skills.
The fundamental principle always remains the same: never expose sensitive files through direct URLs. The application must always be the guardian that decides, request by request, who can access what.
File security isn't an addition, it's a fundamental architectural requirement. Implementing it from the beginning is always easier than retrofitting it onto an existing system.
Useful Resources: