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.

Virus Scan for Rails file uploads with CarrierWave and attachmentAV

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.

Rails app blocks users from uploading infected files

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:

  1. It uses file storage and defines a store_dir for uploaded files.
  2. A default_url is set for when no file is uploaded.
  3. The key addition is the scan_file_with_api method, which is called during file processing.
  4. 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.
  5. 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

Stay up-to-date

Monthly digest of security updates, new capabilities, and best practices.