Rick Strahl's Web Log

Wind, waves, code and everything in between...
ASP.NET • C# • HTML5 • JavaScript • AngularJs
Contact   •   Articles   •   Products   •   Support   •   Search
Ad-free experience sponsored by:
ASPOSE - the market leader of .NET and Java APIs for file formats – natively work with DOCX, XLSX, PPT, PDF, images and more

Updated ASP.NET wwCaptcha Control on the WebLog


:P
On this page:

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.

I've been using Jeff Atwoods Captcha control and after some initial problems with caching some time ago (using NotRemovable was the solution) it's been working reliably. The biggest problem with the control though is that it uses cache to store the captcha ID generated along with some other data and since I'm running on a fairly memory strapped server and this blog is reasonably busy my Cache ended up filled with large amounts of captcha records in them. In fact the original problem was that the cache was immediately dumping even the 30 bytes or so the control was using. I had to set a limit for the cache entry life time and that's getting in the way. The problem is that depending on the lifetime setting these things will stick around if a user never submits the page. So if you went to the page clicked comment, then never submitted the cache entry stuck around for 10 minutes. Again with sizable numbers of users in this strapped environment this actually added up to some memory problems in this server environment.

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:

wwCaptcha

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.

  • Generates a unique Control ID every time a page is generated
  • Adds a fudge value to the end of the entered value with JavaScript.
    The fudge value is expected in the value and stripped out before reading
  • The Captcha has a timeout option

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;
    }

}

 

To use the control you simply drop it on a page:

<ww:wwCaptcha runat="server" ID="wwCaptcha" 
CssClass="gridalternate"
Width="300px"
Style="padding: 10px; margin: 5px; border: solid 1px navy;" />

and then in your code you can simply check the Validated property and read the ErrorMessage:

 

protected void btnComment_Click(object sender, EventArgs e)

{

    // *** Force focus onto the txtBody control when we return

    this.SetFocus(this.txtBody);

 

    if (!this.wwCaptcha.Validated)

    {

        this.ErrorDisplayComment.ShowError(this.wwCaptcha.ErrorMessage);

        return;

    }
    ...
}

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.

Posted in ASP.NET  

The Voices of Reason


 

Josh Stodola
December 20, 2007

# re: Updated ASP.NET wwCaptcha Control on the WebLog

Nice. I use pretty much the same thing on my blog, but I am going to steal your "fudging" concept. Thanks!

By the way, I must tell you that "Please validate the following expression:" makes my head hurt! How about saying something like "Answer this:" so that it doesnt look like something Shakespeare wrote.

Haacked
December 20, 2007

# re: Updated ASP.NET wwCaptcha Control on the WebLog

I did something similar a while back except I let Javascript solve the problem for the user and hid the problem using javascript. The user never needs to know about it if they are using a modern js enabled browser.

Why?

I've found that pretty much every spam bot that bothers with my site doesn't evaluate javascript. So they end up having to "solve" the math problem. Just as they would here.

Most users have JS enabled, so as far as they're concerned, there is no captcha. A nicer experience. It's WIN WIN!

If spambots ever got smarter, I could always turn of the JS solving part and leave them with the math problem like you have here.

Check it out:

http://haacked.com/archive/2006/09/26/Lightweight_Invisible_CAPTCHA_Validator_Control.aspx

Jeff Atwood
December 20, 2007

# re: Updated ASP.NET wwCaptcha Control on the WebLog

Yep, what Haacked said. Simply having some kind of non-trivial javascript in there will stop 99.99% of all comment bots.

I *do* expect the bots to get around this much faster than a graphical captcha, but it's still a huge barrier.

Brian Brackeen
December 20, 2007

# re: Updated ASP.NET wwCaptcha Control on the WebLog

Rick,

Nice tool, thanks for sharing. I have been enjoying your blog for awhile now.

-brian

Rick Strahl
December 20, 2007

# re: Updated ASP.NET wwCaptcha Control on the WebLog

@Phil - Yes I looked at your control actually - downloaded it out of subtext. I think the problem though is that once you get a valid result you can still spoof. The problem comes in if you have a human spot the system, figure out the logic make it work and then pass that forward. It's rare, but it's happened a bunch here and the extra manual step of requiring user input will throw off most attempts.

I agree with high the 99.9% part I guess, but the problem is once you have 'valid data' once you still have a potential hole. The graphicial Captcha has truly reduced 99.9% of spam for me - there are only a handful of completely apparently manually added entries that are not going to be trappable. I'll have to see how this fares.

It'd be interesting to switch the control to run invisible by automatically filling the value by say hovering over the Post button. Hmmm... <s>

Josh Stodola
December 20, 2007

# re: Updated ASP.NET wwCaptcha Control on the WebLog

Rick, your last comment literally blew my mind! You'd also have to handle onkeypress event on the textboxes to invoke the buttons click event handler when the user presses the enter key to submit the form (not everybody clicks). You could also hide the math quiz container with javascript so that it would still be accessible for that rare breed of people who browse around with Javascript disabled. Zippity-doo-dah-day!

Rick Strahl
December 20, 2007

# re: Updated ASP.NET wwCaptcha Control on the WebLog

Well one problem with this would be that you'd end up somehow embedding the Expected value into the page since you'd have to feed it to the JavaScript somehow so it becomes potentially parsable.

Actually it might just be easier to directly embed some OnSubmitCode that does this automatically - if you're going on the assumption that JavaScript is required for the validation.

The whole thing will currently fail if you have no JavaScript because without that the control Id doesn't get appended to the value which automatically fails.

Have to play with this a little more - yeah it would be nice to not have to have a visible anything on the page, right? <s>

+++ Rick --
 

West Wind  © Rick Strahl, West Wind Technologies, 2005 - 2019