If you have an app that accepts file uploads, then either you’re aware of the potential risks, or you should be.
With Accord ODR, we started by limiting the types of files that can be uploaded by users. This is the easy part: disallow executable files or anything else that can be scripted. This includes not only obvious candidates like JavaScript files but also things like WMF images, since these can contain executable code.
That’s all well and good, but file format detection can absolutely be circumvented, and so even when we get an allowed file format uploaded, we have to quarantine it until it’s been scanned for viruses, and always assume that the file is infected until it has been proven to be clean.
Of course, virus scanning is not a panacea and files can (and do) get through a scan while also containing a virus. There’s always the chance that we’ll allow another user to download a file from the site that contains a virus even though our scan has marked it as clean. However, by isolating these files in S3, they at least cannot infect the app itself and potentially obtain access to personal data.
Using S3 VirusScan
The most popular solution currently available is Widdix’s AWS S3 VirusScan, available from the AWS Marketplace. This is basically a wrapper around ClamAV, an open source virus scanner. The wrapper includes a Lambda function that is triggered each time a file is uploaded to a watched S3 bucket, which then runs ClamAV against the file, and adds metadata to the file to tag its scan result. It also sends a notification to SNS which can be used to trigger other actions (more about that below).
We use Rails’ built-in Active Storage to upload files to the watched S3 bucket which then triggers a virus scan.
Getting virus scan results into your app
Since the virus scanning happens in AWS, the app has no idea of the current scan status of a given uploaded file.
Luckily for us, S3 VirusScan comes with an SNS topic where notifications are posted each time a file is scanned. The notification contains the name of the file and the scanned status (clean or infected).
Using an SNS topic subscription, we can subscribe an endpoint in our app to receive these notifications:
resource "aws_sns_topic_subscription" "app_s3_virusscan_subscription" {
topic_arn = aws_cloudformation_stack.s3_virusscan.outputs.FindingsTopicArn
protocol = "https"
endpoint = var.app_s3_virusscan_notification_endpoint
endpoint_auto_confirms = true
}
The app_s3_virusscan_notification_endpoint
variable is the full URL to an HTTP endpoint that will be called each time a notification is available (for example, https://webhooks.example.com/s3-virusscan).
Setting up the endpoint controller
At our endpoint, we have a controller than handles incoming SNS notifications. Looking closely, the endpoint_auto_confirms
flag is set to true
, and this means that when the subscription is first created, the endpoint must also be able to return a confirmation message, otherwise the subscription will fail.
With that caveat out of the way, let’s take a look at our controller:
def s3_virus_scan
head 400 unless subscription_confirmation_message? || notification_message?
raw_message_body = request.body.read
message_body = JSON.parse(raw_message_body)
verify_message_authenticity!(raw_message_body)
confirm_sns_subscription(message_body) if subscription_confirmation_message?
update_virus_scan_status_for_blob(message_body) if notification_message?
rescue JSON::ParserError
head 400
end
This is the method that we have linked to our endpoint route and therefore is called for every notification.
Here, we check the notification is either a subscription confirmation or an actual virus scan notification, then we parse the JSON body and either confirm the subscription, or update our own internal state with the virus scan result for the scanned file.
Helpfully for us, SNS notifications are sent with a X-AMS-SNS-Message-Type
HTTP header that allows us to easily determine what we have:
def subscription_confirmation_message?
request.headers["x-amz-sns-message-type"] == "SubscriptionConfirmation"
end
def notification_message?
request.headers["x-amz-sns-message-type"] == "Notification"
end
With that done, we next need to parse the body to extract the information we need. To make sure we aren’t being fooled by someone sending us a fake notification to mark a virus-laden file as clean, we run a method provided by the aws-sdk-sns
gem which verifies signatures on the notification to make sure it’s legitimate:
def verify_message_authenticity!(message)
verifier = Aws::SNS::MessageVerifier.new
head 401 and return unless verifier.authentic?(message)
end
Now that we know we have a legitimate notification, we need to process it. If the notification is a subscription confirmation, it means we’re setting up the subscription for the first time. We just need to respond to signal to AWS that the subscription can be set up:
def confirm_sns_subscription(message)
head 400 and return unless message["SubscribeURL"].present?
# Send an HTTP GET request to the given URL to confirm the subscription
url = URI.parse(message["SubscribeURL"])
Net::HTTP.start(url.host, url.port, use_ssl: true) do |http|
request = Net::HTTP::Get.new(url)
http.read_timeout = 5
http.max_retries = 0
http.request(request)
end
render json: { confirmed: true }.to_json, status: 200
rescue Errno::EADDRNOTAVAIL, Net::ReadTimeout
head 400
end
A subscription confirmation comes with a SubscribeURL
field. We make a request to that URL, which confirms the subscription.
If, on the other hand, the notification is about a virus scan result, then we need to update our records for the given file to mark it as clean or infected:
VIRUS_SCAN_STATUS_MAPPING = { "no" => :no_scan, "clean" => :clean, "infected" => :infected }.freeze
def update_virus_scan_status_for_blob(message)
key = message["MessageAttributes"]["key"]["Value"]
status = VIRUS_SCAN_STATUS_MAPPING[message["MessageAttributes"]["status"]["Value"]]
blob = ActiveStorage::Blob.find_by(key: key)
blob&.update(virus_scan_status: status)
render json: { key: key, status: status }.to_json, status: 200
end
Here, we find the Active Storage blob based on the file name, and we update its virus scan status accordingly.
The virus_scan_status
attribute for a blob is a custom one added by a simple migration:
class AddVirusScanStatusToActiveStorageBlobs < ActiveRecord::Migration[6.0]
def change
add_column :active_storage_blobs, :virus_scan_status, :integer, default: 0, null: false
end
end
We then set this field up as an enum on blobs (this is an initialiser that is run when the app starts):
module ActiveStorageBlobVirusScanStatus
extend ActiveSupport::Concern
included do
enum virus_scan_status: %i[no_scan clean infected]
end
end
Rails.configuration.to_prepare do
ActiveStorage::Blob.include(ActiveStorageBlobVirusScanStatus)
end
“Quarantining” infected files
If the virus scanner marks a file as infected, then we want to make sure that file cannot be downloaded by app users. We could do this by immediately deleting the file, but there is a chance the status is a false positive (all virus scanner vendors sometimes mess up their signature files), or we may be interested in analysing the file further.
For these reasons, we keep infected files in our S3 bucket, but we prevent them from being downloaded using an S3 bucket policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject"
],
"Principal": {
"AWS": "${cloudfront_oai_iam_arn}"
},
"Resource": [
"${assets_s3_bucket}/*"
],
"Condition": {
"StringEquals": {
"s3:ExistingObjectTag/s3-virusscan": "clean"
}
}
}
]
}
In a previous blog post, we discussed setting up an S3 bucket policy for our assets bucket (where we store uploaded files) to allow CloudFront to serve these files, authenticating itself with an origin access identity (OAI).
We now add a condition to that policy, which only allows access by CloudFront if the virus scan status of a file is “clean”. This means that S3 will deny access to any files that have either not yet been scanned, or have been marked as infected.
This is a nice failsafe to ensure that even if the app attempts to construct a link to such a file and serve it, policy will block that file from actually being served.
Doing something with the status
Now that everything is set up, every uploaded file starts with a no_scan
status. Once the file is scanned and we’ve been notified of the result, we can take various actions.
In our app, we display a message if the file has not yet been scanned or if it has been marked as infected. This gives feedback to the user about the status of their uploads and allows them to take action, for example to re-upload a file or get in touch with us if they suspect a false positive.
Periodic re-scanning
Right now, this setup scans each file once when it has been first uploaded. This is fine for the majority of cases, but there may be situations where a very new virus which is not yet detectable by virus scanners has infected a file, and that file is marked as clean.
The best way around this issue is to periodically re-scan all the files in our S3 bucket and mark any that are now found to be infected. This could be done, for example, with an EventBridge schedule rule which triggers the virus scanner.
However, the issue with this approach is that as the number of files in the bucket increases, the scan time will also increase, which increases costs in Lambda run time. It also increases costs in repeatedly retrieving files from the bucket and then writing their status metadata back.
This is an area we are still investigating, and hopefully in the future we’ll be able to come up with a solution that balances the requirement to detect infected files with the requirement to not add extra unnecessary infrastructure cost.