Virus Scan for Rails: protect from users uploading malware
Safeguarding your Ruby on Rails application against potential threats is an ongoing challenge, and one critical area often flies under the radar: user-uploaded files. These seemingly innocuous uploads can serve as Trojan horses for malware, potentially compromising your entire system and endangering your user base. Integrating virus scanning into your Rails application, especially for user uploads, is not just a best practice—it’s a crucial line of defense. By implementing thorough scanning measures before allowing downloads of uploaded files, you create a vital checkpoint that can prevent the spread of malicious software and maintain the security integrity of your platform.
In this post, we’ll walk you through building a secure file upload system in Rails using CarrierWave, enhanced with virus scanning via the attachmentAV API powered by Sophos. Learn how to protect your app and users from malware in uploaded files.
In case you are looking for an example in plain Ruby, check out Antivirus API: Virus Scan for Ruby.
Example written in Rails: Scan user uploads for viruses and malware
The example used in the following allows to create, read, update, and delete users. A user consists of a name, email, and an avatar - a picture of the user.
The goal is to ensure that user uploads are getting scanned for viruses and other kinds of malware.
Install CarrierWave
To begin implementing secure file uploads in your Ruby on Rails application, you’ll first need to install the CarrierWave gem. Add the following line to your Gemfile
:
gem 'carrierwave', '~> 2.0'
Then, run the bundle install command in your terminal:
bundle install
This will install CarrierWave and its dependencies.
Create uploader
After installation, you can generate an uploader by running:
rails generate uploader Avatar
This creates an avatar_uploader.rb
file in app/uploaders/
, which you’ll use to configure your file uploads.
This AvatarUploader
class extends CarrierWave’s functionality to include virus scanning using the attachmentAV API. Here’s a brief explanation:
- It uses file storage and defines a
store_dir
for uploaded files. - A
default_url
is set for when no file is uploaded. - The key addition is the
scan_file_with_api
method, which is called during file processing. - This method sends the uploaded file to the attachmentAV API for virus scanning:
- It sets up an HTTPS POST request to the API.
- The file is streamed to the API, with a 10MB size limit.
- The API response is parsed and handled:
clean
files are allowed.infected
files raise an error and are logged.no
scan result or unknown statuses raise errors.
- Network and parsing errors are caught and raised as
CarrierWave::ProcessingError
.
An API key is required to access the attachmentAV API. Create a subscription to proceed.
This setup ensures that every file uploaded through this uploader is scanned for viruses before being stored, adding a crucial security layer to the file upload process.
require 'net/http'
require 'uri'
require 'json'
class AvatarUploader < CarrierWave::Uploader::Base
storage :file
def store_dir
"uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
end
def default_url(*args)
"/images/fallback/" + [version_name, "default.png"].compact.join('_')
end
process :scan_file_with_api
private
def scan_file_with_api
file_path = file.path
api_key = 'XXX' # Replace with your actual API key
api_url = 'https://eu.developer.attachmentav.com/v1/scan/sync/binary'
uri = URI(api_url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.read_timeout = 60 # Set timeout to 60 seconds as per API specification
request = Net::HTTP::Post.new(uri.path)
request['Content-Type'] = 'application/octet-stream'
request['x-api-key'] = api_key
File.open(file_path, 'rb') do |file|
request.body_stream = file
request.content_length = file.size
if file.size > 10 * 1024 * 1024 # 10 MB
raise CarrierWave::ProcessingError, "File size exceeds 10 MB limit"
end
response = http.request(request)
case response
when Net::HTTPSuccess
result = JSON.parse(response.body)
case result['status']
when 'clean'
# File is clean, do nothing
when 'infected'
Rails.logger.error "File is infected: #{result['finding']}"
raise CarrierWave::ProcessingError, "File is infected: #{result['finding']}"
when 'no'
raise CarrierWave::ProcessingError, "Unable to scan file"
else
raise CarrierWave::ProcessingError, "Unknown scan result: #{result['status']}"
end
else
raise CarrierWave::ProcessingError, "API error: #{response.code} #{response.message}"
end
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
raise CarrierWave::ProcessingError, "Network error while scanning file: #{e.message}"
rescue JSON::ParserError => e
raise CarrierWave::ProcessingError, "Error parsing API response: #{e.message}"
end
end
A brief look into model, view, and controller
Next, an in-depth look into model, view, and controller. The following snippet shows the model /app/models/user.rb
.
class User < ApplicationRecord
mount_uploader :avatar, AvatarUploader
end
And here is the view defined in /app/views/users/new.html.erb
to create a new user.
<h1>New User</h1>
<%= render 'form', user: @user %>
<%= link_to 'Back', users_path %>
The view includes the following form defined in /app/views/users/_form.html.erb
.
<%= form_with(model: user, local: true) do |form| %>
<% if @user.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@user.errors.count, "error") %> prohibited this upload from being saved:</h2>
<ul>
<% @user.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= form.label :name %>
<%= form.text_field :name %>
</div>
<div class="field">
<%= form.label :email %>
<%= form.email_field :email %>
</div>
<div class="field">
<%= form.label :avatar %>
<%= form.file_field :avatar %>
</div>
<div class="actions">
<%= form.submit %>
</div>
<% end %>
And last but not least, let’s take a look at the controller /app/controllers/users_controller.rb
.
class UsersController < ApplicationController
def index
@users = User.all
end
def show
@user = User.find(params[:id])
end
def new
@user = User.new
end
def create
@user = User.new(user_params)
respond_to do |format|
if @user.save
format.html { redirect_to @user, notice: 'User was successfully created.' }
format.json { render :show, status: :created, location: @user }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @user.errors, status: :unprocessable_entity }
end
end
end
def edit
@user = User.find(params[:id])
end
def update
@user = User.find(params[:id])
respond_to do |format|
if @user.update(user_params)
format.html { redirect_to @user, notice: 'User was successfully updated.' }
format.json { render :show, status: :ok, location: @user }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @user.errors, status: :unprocessable_entity }
end
end
end
def destroy
@user = User.find(params[:id])
@user.destroy
redirect_to users_url, notice: 'User was successfully destroyed.'
end
private
def user_params
params.require(:user).permit(:name, :email, :avatar)
end
end
Getting started with attachmentAV
Scan files for viruses, worms, and trojans by sending them to the attachmentAV API powered by Sophos. Check out the attachmentAV Virus and Malware Scan API documentation to get started!
Published on September 19, 2024 | Written by Andreas