How to stop spam bots from submitting your form in mvc

5 min read

First things first, read the below blog post from Ned Batchelder. It was the single most useful blog post on this subject that I found:

http://nedbatchelder.com/text/stopbots.html

After finding Ned Batchelder's post I began to put his suggestions into action. This is my take take on his musings. The following post covers the following subjects:

  • The Honeypot
  • The Timestamp
  • The Spinner
  • Field Names

Prep

Before we implement the fixes, lets assume we have a something similar to the following highly simplistic example:

CreateViewModel.cs [Model]

public class CreateViewModel
{
    [Required]
    public string Name { get; set; }
    [Required, EmailAddress]        
    public string Email { get; set; }
    [Required]
    public string Content { get; set; }
    [Required]
    public int PostId {get; set }
}

Create.cshtml [View]

@model CreateViewModel
@using(Html.BeginForm())
{
    @Html.AntiForgeryToken()
    @Html.HiddenFor(m => m.PostId)
    <div>
        @Html.LabelFor(m => m.Name): 
        @Html.TextboxFor(m => m.Name)
    </div>
    <div>
        @Html.LabelFor(m => m.Email): 
        @Html.TextboxFor(m => m.Email)
    </div>
    <div>
        @Html.LabelFor(m => m.Email):
        @Html.TextAreaFor(m => m.Content)
    </div>
    
    <input type="submit" value="submit" />
}

Controller.cs [Post action method only]

[HttpPost, ValidateAntiForgeryToken]
public ActionResult CreateComment(CreateViewModel viewModel)
{
    if(ModelState.IsValid)
    {
        //Save the comment and redirect
    }        
    return View(viewModel);        
}

Before we start, notice I have already implemented MVC's AntiForgeryToken and decorated my post action method with the [ValidateAntiForgeryToken] attribute to prevent Cross Site Request Forgery

The Honeypot

One way of thwarting the form filling bots is to implement a honey pot. The idea here is you have an input that is invisible to a human user but is picked up by the bots.

###Implementing the Honeypot

We need to add the honeypot property to the Model and validate it as follows:

public class CreateViewModel : IValidatableObject
{
    // ...existing properties
    
    public string Honeypot { get; set; }
    
    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if(!String.IsNullOrEmpty(this.Honeypot))
        {
            return new[]{new ValidationResult("An error occured")}
        }        
    }        
}

Notice we are now implementing the IValidatableObject interface which means we have to implement the Validate method. This is the method that is called when check ModelState.IsValid in our controller.

'Displaying' the Honeypot

Obviously we are only displaying the honeypot to the bots, not the humans.

@model CreateViewModel
@using(Html.BeginForm())
{
    @* ..existing markup *@
    <div class="hidden">
        @Html.TextboxFor(m => m.Honeypot)
    </div>
}

Above you can see that I have decided to hide the containing div, not the input itself.

The Timestamp

###Implementing the Timestamp You might decide to implement this in a slightly less obvious way like so, below I mix up how we display .ToString() the timestamp

public class CreateViewModel : IValidatableObject
{
    // ...existing properties
    
    // New constructor
    public CreateViewModel()
    {
        // Format the datetime in an inventive way
        this.Timestamp = DateTime.UtcNow.toString("ffffHHMMyytssddmm");
    }
    
    // New property
    public string Timestamp { get; set; }
}

Notice how I have mixed up each of the date-parts to create an unintelligible value. Refer to the Custom Date and Time MSDN page to create your own variant.

Validating the Timestamp propertty means we need to extend the Validate() method.

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
    var failedValidationResult = new ValidationResult("An error occured");
    var failedResult = new[] { failedValidationResult };

    if(!String.IsNullOrEmpty(this.Honeypot))
    {
        return failedResult;
    }        
    
    DateTime timestamp;
    if (!DateTime.TryParseExact(this.Timestamp, "ffffHHMMyytssddmm", 
                                null, DateTimeStyles.None, out commentDate))
    {
        return failedResult;
    }
        
    //Check timestamp is within the last 20 minutes
    if (DateTime.UtcNow.AddMinutes(-20) > timestamp)
    {
        return failedResult;
    }
}            

Adding the Timestamp to the View

Next we need to add the Timestamp property as a hidden input to our View

@model CreateViewModel
@using(Html.BeginForm())
{
    @Html.HiddenFor(m => m.Timestamp)
    @* ..existing markup *@
    <div class="hidden">
        @Html.TextboxFor(m => m.Honeypot)
    </div>
}

The Spinner

Hashing the timestamp with other items adds another layer of security and makes it tamper proof should the attacker be able to decipher your custom date format. To do this we need to change our Model.

public class CreateViewModel : IValidatableObject
{
    public CreateViewModel()
    {
        // Format the datetime in an inventive way
        var timestamp = DateTime.UtcNow.toString("ffffHHMMyytssddmm");
        this.Timestamp = timestamp;
        byte[] salt = HashHelper.CreateSalt();
        // Build your spinner in which ever way you choose            
        var toHash = String.Format("{0}{1}{2}", timestamp, Request.UserHostAddress, this.Id);
        //Be as creative as you please (I've added the word 'HASH')           
        //var toHash = String.Format("H{0}A{1}S{2}H", timestamp, Request.UserHostAddress, this.Id);            
        this.Hashed = HashHelper.Hash(toHash, salt);            
        this.Salt = Convert.ToBase64String(salt);
    }

    // ...existing properties        
    
    public string Timestamp { get; set; }        
    public string Salt { get; set; }
    public string Hashed { get; set; }
}

As before, the additional properties need to be added to the View using the @Html.HiddenFor() Html helper.

Additionally, the HashHelper used above is a custom hash helper I have built that enables the quick creation of salts and hashes and no doubt, the subject of a future post. You will need to decide how you generate your salts and hashes for your given situation.

Validating the Spinner

As before we need to update our Validate method to validate against our salt and hash.

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
    var failedValidationResult = new ValidationResult("An error occured");
    var failedResult = new[] { failedValidationResult };

    if(!String.IsNullOrEmpty(this.Honeypot) || String.IsNullOrEmpty(this.Timestamp)
       ||  String.IsNullOrEmpty(this.Salt) || String.IsNullOrEmpty(this.Hashed))
    {
        // Some expected data is missing
        return failedResult;
    }        
    
    DateTime timestamp;
    if (!DateTime.TryParseExact(this.Timestamp, "ffffHHMMyytssddmm", 
                                null, DateTimeStyles.None, out timestamp))
    {
        // TImestamp no longer matches our custom format
        return failedResult;
    }
        
    //Check timestamp is within the last 20 minutes
    if (DateTime.UtcNow.AddMinutes(-20) > timestamp)
    {
        // Form timestamp is older the 20minutes ago 
        return failedResult;
    }
    
    byte[] salt = Convert.FromBase64String(this.Salt);
  
    // Remember to use the same format you did when building the hash
    var toHash = String.Format("{0}{1}{2}", this.Timestamp, Request.UserHostAddress, this.Id);
    //var toHash = String.Format("H{0}A{1}S{2}H", this.Timestamp, Request.UserHostAddress, this.Id);            

    var hashed = HashHelper.Hash(toHash, salt); 
    
    //Check the hash and salt have not been tampered with
    // If anything has changed or been altered, the hashes will not match
    if(this.Hashed.Equals(Convert.ToBase64String(hashed)))        
    {
        // Hashes match, all is good
        return new[]{ValidationResult.Success};
    }
    return failedResult;
}            

Adding the Hash and Salt to the View

We now need to add the properties to our form:

@model CreateViewModel
@using(Html.BeginForm())
{
    @Html.HiddenFor(m => m.Timestamp)
    @Html.HiddenFor(m => m.Salt)
    @Html.HiddenFor(m => m.Hashed)
    @* ..existing markup *@
    <div class="hidden">
        @Html.TextboxFor(m => m.Honeypot)
    </div>
}

So now our form has everything we need added to it and the validation has been implemented. There is just one more task to complete.

Field names

In order to remove the obvious field names, we could hash them with a secret, and a spinner, however I chose to simply use random property names. This changes every aspect of our code. Our final structure might look like this:

Model

public class CreateViewModel : IValidatableObject
{
    public CreateViewModel()
    {
        // Format the datetime in an inventive way
        var timestamp = DateTime.UtcNow.toString("ffffHHMMyytssddmm");
        this.PWHJVT = timestamp;
        byte[] salt = HashHelper.CreateSalt();
        // Build your spinner in which ever way you choose            
        var toHash = String.Format("{0}{1}{2}", timestamp, Request.UserHostAddress, this.Id);
        //Be as creative as you please (I've added the word 'HASH')           
        //var toHash = String.Format("H{0}A{1}S{2}H", timestamp, Request.UserHostAddress, this.Id);            
        this.BHVESH = HashHelper.Hash(toHash, salt);            
        this.BSRGVS = Convert.ToBase64String(salt);
    }

    [Required(ErrorMessage = "The Name field is required")] 
    public string VJKSDN { get; set; }
    [Required(ErrorMessage = "The Email field is required")]
    [EmailAddress(ErrorMessage = "The Email field is not valid")]        
    public string GOVSWE { get; set; }
    [Required(ErrorMessage = "The Content field is required")]
    public string BPASCC { get; set; }
    [Required]
    public int ESVERI {get; set }

    public string SGEGEH { get; set; } // Honeypot
    public string PWHJVT { get; set; } // Timestamp
    public string BSRGVS { get; set; } // Salt 
    public string BHVESH { get; set; } // Hash
    
    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        var failedValidationResult = new ValidationResult("An error occured");
        var failedResult = new[] { failedValidationResult };

        if(!String.IsNullOrEmpty(this.SGEGEH) || String.IsNullOrEmpty(this.PWHJVT)
           ||  String.IsNullOrEmpty(this.BSRGVS) || String.IsNullOrEmpty(this.BHVESH))
        {
            // Some expected data is missing
            return failedResult;
        }        
    
        DateTime timestamp;
        if (!DateTime.TryParseExact(this.PWHJVT, "ffffHHMMyytssddmm", 
                                    null, DateTimeStyles.None, out timestamp))
        {
            // TImestamp no longer matches our custom format
            return failedResult;
        }
        
        //Check timestamp is within the last 20 minutes
        if (DateTime.UtcNow.AddMinutes(-20) > timestamp)
        {
            // Form timestamp is older the 20 minutes ago 
            return failedResult;
        }
    
        byte[] salt = Convert.FromBase64String(this.BSRGVS);
  
        // Remember to use the same format you did when building the hash
        var toHash = String.Format("{0}{1}{2}", this.PWHJVT, Request.UserHostAddress, this.Id);
        //var toHash = String.Format("H{0}A{1}S{2}H", this.PWHJVT, Request.UserHostAddress, this.Id);            

        var hashed = HashHelper.Hash(toHash, salt); 
    
        //Check the hash and salt have not been tampered with
        // If anything has changed or been altered, the hashes will not match
        if(this.BHVESH.Equals(Convert.ToBase64String(hashed)))        
        {
            // Hashes match, all is good
            return new[]{ValidationResult.Success};
        }
        return failedResult;
    }                
}

View

@model CreateViewModel
@using(Html.BeginForm())
{
    @Html.AntiForgeryToken()
    @Html.HiddenFor(m => m.ESVERI)
    @Html.HiddenFor(m => m.PWHJVT)
    @Html.HiddenFor(m => m.BSRGVS)
    @Html.HiddenFor(m => m.BHVESH)

    <div>
        @Html.LabelFor(m => m.VJKSDN): 
        @Html.TextboxFor(m => m.VJKSDN)
    </div>
    <div>
        @Html.LabelFor(m => m.GOVSWE): 
        @Html.TextboxFor(m => m.GOVSWE)
    </div>
    <div>
        @Html.LabelFor(m => m.BPASCC):
        @Html.TextAreaFor(m => m.BPASCC)
    </div>
    <div class="hidden">
        @Html.TextboxFor(m => m.SGEGEH)
    </div>
    
    <input type="submit" value="submit" />
}

Conclusion

Hopefully the above post will help you protect your sites from spam bots. Remember, this will not stop humans with malicious intent. If a human does try and take advantage of your site, make sure you have protected your output. For instance, if you are building a comment system, when you are writing out the comment, make sure you use @Html.Encode(m => m.Content) to encode any html or scripts that might get posted.

Final thought I'd love to hear from anyone out there this as this post is merely my take on Ned's post for an ASP.Net MVC environment.

If there is enough interest I may follow this up with new oss project that makes it easy to make any class adhere to these rules as we could create custom html helpers and a base spam-proof class to base models on.

Let me know if that is something you think might be worth looking further into.

Thanks for reading this lengthy post. I hope it helped.


Comments
abhinav says:June 21, 2015

how can we implement this in php...and plz derscribe more about field names

Peter says:August 30, 2017

I know this is old but is this in fact not that protective? All the information needed to create the hash is present in the form, e.g. the salt.

Would it not be better to store these values in the users session and only include the base64 hash in the output?