Simple User Registration Spam Prevention in Rails 3

Tonight, in a little under an hour, I was able to implement spam prevention for my user registration flow thanks to the simple API provided by Stop-Registration-Spam.org.

In Rails 3, creating new validators is relatively painless. Create a new validator class in a folder defined by you. For my project, I decided to place all validators within app/validators.

The class should extend the ActiveModel::EachValidator class found within Rails 3 core, in which you’ll generally override two methods: check_validity! and validate_each.

  1. check_validity! is called by the class’ initializer to verify that the validator’s arguments supplied are valid. If they are not, throw an exception that will be seen by the developer using your validator.
  2. validate_each is where the actual validation occurs on the values set for each attribute implementing this validator at runtime.

The code for the validator is included in the code snippet below.

class SpamValidator < ActiveModel::EachValidator
  DEFAULTS = {:level => 1, :timeout => 5}.freeze
  MESSAGES = {:block => "is blacklisted", :no_domain => "includes invalid domain", :ip_not_recognized => nil, :over_limit => nil}.freeze
  RESERVED_OPTIONS = [:level, :timeout, :block, :no_domain, :ip_not_recognized, :over_limit].freeze
 
  def initialize(options)
    DEFAULTS.each do |key, value|
      options[key] ||= value
    end
 
    super(options)
  end
 
  def check_validity!
    unless options[:level].is_a?(Integer) && (1..5).include?(options[:level])
      raise ArgumentError, ":level must be an Integer between 1-5"
    end
 
    unless options[:timeout].is_a?(Integer) && options[:timeout] > 0
      raise ArgumentError, ":timeout must be an Integer greater than 0"
    end
  end
 
  def validate_each(record, attribute, value)
    return if value.nil?
    raise ArgumentError, "#{attribute} must be a String." unless value.is_a?(String)
 
    begin
      # API REF: http://www.stop-registration-spam.org/api
      url = "http://www.stop-registration-spam.org/api/level#{options[:level]}/json?email=#{CGI.escape(value)}"
      result = JSON.parse(open(url, :read_timeout => options[:timeout]).read)
    rescue
      # TODO log error
    end
 
    if result && result['request_status']
      error_key = result['request_status'].to_s.downcase.to_sym
      error_message = MESSAGES[error_key]
 
      if error_message
        errors_options = options.except(*RESERVED_OPTIONS)
        errors_options[:message] ||= options[error_key] if options[error_key]
 
        record.errors.add(attribute, error_message, errors_options)
      end
    end
  end
end

Using this validator is incredibly easy. In your model, just add a :spam key with whatever values you require passed as a hash within the validates call for your email-formatted attributes.

class User < ActiveRecord::Base
  validates :email, :spam => { :level => 5 }
end

The hash accepts the following parameters:

  • :level – integer from 1-5 (default 1) – The level of spam prevention to utilize as defined by the Stop-Registration-Spam.org API.
  • :timeout – integer greater than 0 (default 5) – The number of seconds to timeout when unable to connect with, or receive a response from, the API.
  • :block – string (default “is blacklisted”) – The message to use for response code BLOCK, meaning this email address did not meet the checks at the level requested.
  • :no_domain – string (default “includes invalid domain”) – The message to use for response code NO_DOMAIN, meaning the domain provided is not valid.
  • :ip_not_recognized – string (default nil) – The message to use for response code IP_NOT_RECOGNISED, meaning the requesting IP is not valid for high volume commercial use.
  • :over_limit – string (default nil) – The message to use for response code OVER_LIMIT, meaning you have reached your daily request limit.

My decision to leave :ip_not_recognized and :over_limit as nil values with no error messages is due to my not wanting to prevent users from registering when the service is not responding as expected. Similarly, the validator passes when a timeout occurs. This could easily be modified to allow for further customizations through additional hash values, but this is suitable for my needs, and it demonstrates the simplicity behind implementing a custom Rails 3 validator.

Leave a Reply

Your email address will not be published. Required fields are marked *