A few frequent commenters on this WebLog have complained recently that they are not - uhm, enarmoured with the Captcha box that I use on this site. Specifically I have the Captcha set to a 10 minute (previously 5 minute) timeout and so if somebody spends more than that the Captcha fails. The Captcha also gets blown if the site is restarted either explicitly or by IIS or for whatever reason AppDomain recycles occur.
Anyway, I haven't been completely happy with the Captcha approach for some time because it requires multiple server trips, installation of a separate module and the cache issue I mentioned.
So last night I sat down to quickly create a new self contained captcha routine that uses simple math expressions for validation. Sometime ago I saw that Wilco had built something like this (couldn't find his code though) and I thought it'd be easy enough to implement in a control:
The idea that that rather than generating some image to display to the user to decipher you'd have to type in a the result of a single math operation like 4 + 9 or 3 + 2. The expression value is then compared on the server with the expected result value traveling in the Page's ControlState with the idea being that it's relatively complicated to decode viewstate from script code and not likely that a robot will go there.
The advantage of this approach is that it's fully self contained - everything that's needed travels in the page itself so there's no modules and no extra requests. The downside is that once you've entered a valid code it's possible to send multiple requests with it within the timeout period. I'm thinking though that that's not really an issue for spam bots since they general scan a site first and then come back sometime later to post their trash.
To 'fudge' things up a little more and make it harder to spoof the control adds a few other twists.
I just threw this control together rather, so the code is a little rough - I didn't set up the rendering with tons of options. Here's the code:
public class wwCaptcha : WebControl
{
protected int ExpectedResult = 0;
protected DisplayExpression Expression = null;
protected string EnteredValue = string.Empty;
/// <summary>
/// Set during validation
/// </summary>
[Description("Validation Status of the control. Typically set automatically."),DefaultValue(false)]
[Category("Validation")]
public bool Validated
{
get { return _Validated; }
set { _Validated = value; }
}
private bool _Validated = false;
/// <summary>
/// The error message that is displayed when the not validated
/// </summary>
[Description("The error message that is displayed when the not validated")]
[Category("Validation")]
public string ErrorMessage
{
get { return _ErrorMessage; }
set { _ErrorMessage = value; }
}
private string _ErrorMessage = "";
/// <summary>
///
/// </summary>
[Description("The error message that is displayed when the not validated")]
[Category("Validation"), DefaultValue("Please validate the following expression:")]
public string DisplayMessage
{
get { return _DisplayMessage; }
set { _DisplayMessage = value; }
}
private string _DisplayMessage = "Please validate the following expression:";
/// <summary>
/// The timeout for this message in minutes.
/// </summary>
[Description("Timeout for this captcha in minutes"),DefaultValue(10)]
public int Timeout
{
get { return _Timeout; }
set { _Timeout = value; }
}
private int _Timeout = 10;
/// <summary>
/// The CSS Class that is applied to the outer DIV of the control
/// </summary>
[Description("The CSS Class that is applied to the outer DIV of the control")]
[Category("Display"), DefaultValue("")]
public string CssClass
{
get { return _CssClass; }
set { _CssClass = value; }
}
private string _CssClass = "";
/// <summary>
/// The Width for the control
/// </summary>
[Description("The Width for the control")]
[Category("Display")]
public Unit Width
{
get { return _Width; }
set { _Width = value; }
}
private Unit _Width = Unit.Empty;
protected override void OnInit(EventArgs e)
{
base.OnInit(e);
this.Page.RegisterRequiresControlState(this);
/// *** Use PreLoad instead of IPostbackDataHandler
/// for loading control POST data as part of Validate()
/// cycle. Validate must fire prior to Load but
/// after ViewState's been restored.
this.Page.PreLoad += this.OnPreLoad;
}
protected void OnPreLoad(object sender, EventArgs e)
{
if (!this.Page.IsPostBack)
this.GenerateExpression();
else
this.Validate();
// *** Fudge the page so the control Id is added to the numeric value entered
this.Page.ClientScript.RegisterOnSubmitStatement(this.GetType(), this.Expression.Id,
"var Ctl = document.getElementById('" + this.Expression.Id + "');\r\n" +
"if (Ctl) Ctl.value += '_" + this.Expression.Id + "';");
}
protected void Validate()
{
this.EnteredValue = this.Page.Request.Form[this.Expression.Id] as string;
if (this.EnteredValue == null)
this.EnteredValue = "";
// *** Check for the fudge value
if (!EnteredValue.EndsWith("_" + this.Expression.Id))
{
this.GenerateExpression();
this.Validated = false;
this.EnteredValue = string.Empty;
this.ErrorMessage = "Invalid Page validation - missing security code.";
return;
}
// *** Strip out fudge value added with JavaScript
this.EnteredValue = this.EnteredValue.Replace("_" + this.Expression.Id, "");
if (this.Expression == null)
{
this.GenerateExpression();
this.Validated = false;
this.EnteredValue = string.Empty;
this.ErrorMessage = "Page validation cannot be applied - please reload the page";
return;
}
if (this.Expression.Entered < DateTime.UtcNow.AddMinutes(this.Timeout * -1))
{
this.GenerateExpression();
this.Validated = false;
this.EnteredValue = string.Empty;
this.ErrorMessage = "Page validation code has expired";
return;
}
int val = -1;
int.TryParse(this.EnteredValue,out val);
if (val == -1)
{
this.Validated = false;
this.ErrorMessage = "Invalid page validation input value";
this.GenerateExpression();
return;
}
if (this.Expression.ExpectedValue == val)
this.Validated = true;
else
{
this.Validated = false;
this.ErrorMessage = "Page validation failed.";
this.EnteredValue = string.Empty;
this.GenerateExpression();
}
}
/// <summary>
/// Method can be used to generate a new Expression object
/// with new values to use.
/// </summary>
/// <returns></returns>
public void GenerateExpression()
{
DisplayExpression exp = new DisplayExpression();
Random rand = new Random();
exp.Value1 = rand.Next(9) + 1;
exp.Value2 = rand.Next(9) + 1;
exp.Operation = rand.Next(1) == 0 ? "+" : "*" ;
if (exp.Operation == "+")
exp.ExpectedValue = exp.Value1 + exp.Value2;
if (exp.Operation == "*")
exp.ExpectedValue = exp.Value1 * exp.Value2;
this.Expression = exp;
}
protected override void Render(HtmlTextWriter writer)
{
if (this.CssClass == null)
this.Style.Add(HtmlTextWriterStyle.Padding, "5px");
else
writer.AddAttribute(HtmlTextWriterAttribute.Class, this.CssClass);
if (!this.Width.IsEmpty)
this.Style.Add(HtmlTextWriterStyle.Width, this.Width.ToString());
writer.AddAttribute(HtmlTextWriterAttribute.Style,this.Style.Value);
writer.RenderBeginTag(HtmlTextWriterTag.Div);
if (this.Expression == null)
this.Expression = new DisplayExpression();
if (!string.IsNullOrEmpty(this.DisplayMessage))
writer.Write(this.DisplayMessage + "<br />" );
// *** Write the Expression label
writer.Write(this.Expression.Value1.ToString() + " " +
this.Expression.Operation + " " +
this.Expression.Value2.ToString() + " = " );
writer.Write(" <input type='text' value='" + this.EnteredValue +
"' id='" + this.Expression.Id + "' name='" + this.Expression.Id + "' style='width: 30px;' />");
writer.RenderEndTag(); // main div
}
protected override void LoadControlState(object savedState)
{
this.Expression = savedState as DisplayExpression;
if (this.Expression == null)
this.Expression = new DisplayExpression();
}
protected override object SaveControlState()
{
return this.Expression;
}
[Serializable]
public class DisplayExpression
{
public int ExpectedValue = 0;
public int Value1 = 0;
public int Value2 = 0;
public string Operation = "+";
public string Id= Guid.NewGuid().GetHashCode().ToString("x");
public DateTime Entered = DateTime.UtcNow;
}
}
and then in your code you can simply check the Validated property and read the ErrorMessage:
The key to the control is the DisplayExpression class at the bottom of the control class file, which is a message container that holds the actual expression to display generated of random values. An instance of this class is created and then persisted into ControlState so it travels as part of the page payload. On the first request the expression is generated new with random values and the control renders the display expression with an empty field. The Expression object itself ends up in ViewState.
On the postback or submission of the form the control reads the same DisplayExpression out of viewstate and compares the value entered by the user against the expected value. It also strips out the 'fudge' code - the control ID added to the end of the entered value via JavaScript in the OnSubmit of the page - before reading back the value from Request.Form. There are a few additional checks for a timeout (10 minutes by default) so that the captcha can't be used after the timeout.
As I said the biggest vulnerability here is that once a valid key's been entered, it's possible to post it back many times as long as its within the timeout period, but I think for typical bot snapping that's not an issue.
There are a couple of improvements that could be done here. The UI of the control probably could be done up a bit nicer support more of the WebControl implementation features. The DisplayExpression class probably should have a TypeConverter implementation to reduce the ViewState size which bloats a bit with the object serialization from the LosSerializer (I really wish there was a SimpleObjectTypeConverter() or something like that that automatically can serialize single hierarchical objects).
I've been running the control for the last few days and things seem to be working alright. I still see lots of comment spam bouncing out in my logs and yes there are already bots using the new POST data so they've found it ALREADY. So far so good.