Spam is everywhere. If you put out a form publicly, expect to be bombarded with unsolicited advertisements and links to dangerous websites. One of my customers received a LOT of spam via their marketing website. The website is heavily integrated with their exchange mailbox and CRM system. As a result, the spam flowed through both systems which is a security risk and waste of time for the salespeople.
Luckily, there are many ways to prevent this type of spam:
- Captcha tests such as Google ReCaptcha
- Honeypot technique (extra hidden fields to trick spam bots into filling them out)
- Products like Bot fighting mode offered by Cloudflare
- and more (let me know!)
You'll catch almost all of the spam if you employ multiple of these techniques. But if you have UI tests verifying your forms and a captcha protecting those same forms, your UI tests will no longer function. So how do you keep captcha's on your form but also have passing UI tests?
Well, it depends, are you able to update the server captcha validation code?
No? Then you're going to have a tough time. If you can't update the website, there's no way to distinguish your UI test from any other bot. I don't have the solution for you.
Yes? Great! In that case, you can extend your website and captcha validation to somehow trust your UI tests and bypass the captcha.
One way of establishing that trust between UI tests and your website would be to have your UI tests add extra hidden fields to your form to pass additional parameters as part of your form submission. Your website can then validate those extra parameters like a pre-shared key beknownst only to your website and your UI tests.
This would work, but you would have to update all your UI tests with some JavaScript to insert the additional hidden fields.
Alternatively, you could create an additional form hosted at a different path on your websit e. The form can request a pre-shared key beknownst only to your website and your UI tests. When the UI test submits the secret key, the website can use a session cookie to keep track of the automated browser session and store a boolean 'true' in the session state.
Wherever you validate your captcha on the server, check if this boolean is present and is 'true'. If so, skip the captcha check.
The benefit of this solution is that you only have to fill out this secret form once per automated browser session before running your existing UI tests. This solution does rely on sessions, so if you can't use those, you could use HTTP only cookies. If you can't use cookies at all, you'll have to go with the first option.
Or you could think of a completely different solution which I would love to hear about. Let me know!
For my clients, I have provided a form to bypass captchas using session state and the QA engineer updated the selenium tests to submit the bypass form. This approach has served us well so far, and this is the approach used in the sample below.
Contact us form sample using ASP.NET Core Razor Pages
The sample for this blog post uses Google's ReCaptcha V2 checkbox on a contact form developed using ASP.NET Core Pages. Even though the sample uses a specific captcha vendor and web stack, the solution easily translates to other vendors and stacks.
The sample consists of two projects: an ASP.NET Core Razor Pages project and an MSTest project for selenium testing.
The Razor Pages project has the following contact us form:
As you can see in the give above, if you don't solve the ReCaptcha challenge, an error is displayed at the top of the form saying "Solve the captcha challenge". The GIF above is a recording of the selenium UI test before implementing the bypass mechanism.
Here's the source code of the razor file:
@page
@model IndexModel
@{
ViewData["Title"] = "Home Page";
}
@section Head{
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
}
<form method="post">
@if (!ViewData.ModelState.IsValid)
{
<div class="alert alert-danger" role="alert" asp-validation-summary="All">
</div>
}
<div class="row">
<div class="col-sm">
<div class="mb-3">
<label for="firstName" class="form-label">First Name</label>
<input asp-for="ContactFormViewModel.FirstName" class="form-control" id="firstName" required>
</div>
</div>
<div class="col-sm">
<div class="mb-3">
<label for="lastName" class="form-label">Last Name</label>
<input asp-for="ContactFormViewModel.LastName" class="form-control" id="lastName" required>
</div>
</div>
</div>
<div class="mb-3">
<label for="emailAddress" class="form-label">Email Address</label>
<input asp-for="ContactFormViewModel.EmailAddress" type="email" class="form-control" id="emailAddress"
placeholder="name@example.com" required>
</div>
<div class="mb-3">
<label for="question" class="form-label">Question</label>
<textarea asp-for="ContactFormViewModel.Question" class="form-control" id="question" rows="3"
required></textarea>
</div>
<div class="mb-3">
<div class="g-recaptcha" data-sitekey="@Model.ReCaptchaSiteKey"></div>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
This post won't go into detail on how to add ReCaptcha to your project, but here's a basic explanation of how the ReCaptcha has been implemented.
The script reference to '_ https://www.google.com/recaptcha/api.js _' looks for a DOM node with the ' g-recaptcha' CSS class.
The node also needs to supply the site key using the data-sitekey
attribute. The ReCaptchaSiteKey
property is initialized in the code-behind of the razor page.
Here's the code-behind the Razor Page:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using BypassReCaptcha.Web.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
namespace BypassReCaptcha.Web.Pages
{
public class IndexModel : PageModel
{
private static readonly HttpClient httpClient = new HttpClient();
public string ReCaptchaSiteKey { get; set; }
private string ReCaptchaSecretKey { get; set; }
[BindProperty]
public ContactFormViewModel ContactFormViewModel { get; set; }
public IndexModel(IConfiguration configuration)
{
ReCaptchaSiteKey = configuration.GetValue<string>("ReCaptcha.SiteKey");
ReCaptchaSecretKey = configuration.GetValue<string>("ReCaptcha.SecretKey");
}
public void OnGet()
{
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid || !await ValidateReCaptcha())
{
return Page();
}
return RedirectToPage("./ThankYou");
}
private async Task<bool> ValidateReCaptcha()
{
if (Request.Form.TryGetValue("g-recaptcha-response", out StringValues reCaptchaResponse))
{
var formResult = new FormUrlEncodedContent(new Dictionary<string, string>(){
{"secret", ReCaptchaSecretKey},
{"response", reCaptchaResponse.First()},
{"remoteip", Request.HttpContext.Connection.RemoteIpAddress.ToString()}
});
var response = await httpClient.PostAsync("https://www.google.com/recaptcha/api/siteverify", formResult);
using var responseContentStream = await response.Content.ReadAsStreamAsync();
var json = await JsonDocument.ParseAsync(responseContentStream);
var success = json.RootElement.GetProperty("success").GetBoolean();
if (!success)
{
ModelState.AddModelError("InvalidReCaptcha", "Solve the captcha challenge");
return false;
}
return success;
}
else
{
ModelState.AddModelError("InvalidReCaptcha", "Solve the captcha challenge");
return false;
}
}
}
}
The SiteKey
and SecretKey
is loaded in from the IConfiguration
object injected by the built-in dependency injection. When the form is submitted, in addition to the built-in validation provided by ModelState.IsValid
, the ValidateReCaptcha
method is called.
ValidateReCaptcha
will grab the ' g-recaptcha-response' value from the form. This form value is passed from a hidden field generated by Google's ReCaptcha widget in the browser.
To validate the captcha, the 'g-recaptcha-response', the SecretKey
, and the client's IP address are posted to ' https://www.google.com/recaptcha/api/siteverify'.
The response will tell if the captcha is valid or not in the 'success' field.
If the form field is missing, or the response from Google says it's invalid, an error is pushed to the ModelState
and false is returned.
If the form submission is valid, the client will be redirected to the '/ThankYou' page, otherwise, the form is re-rendered with the errors.
Read the ReCaptcha V2 documentation to learn more.
Selenium UI tests sample
Here's the source code of the Selenium UI tests before implementing the bypass functionality:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
namespace BypassReCaptcha.UiTests
{
[TestClass]
public class ContactUsFormTests
{
private static IConfiguration configuration;
[ClassInitialize]
public static async Task SetupTests(TestContext testContext)
{
Dictionary<string, string> testParametersDictionary = new Dictionary<string, string>();
foreach (var key in testContext.Properties.Keys)
{
testParametersDictionary.Add(key.ToString(), testContext.Properties[key].ToString());
}
var builder = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddUserSecrets<ContactUsFormTests>(optional: true)
.AddEnvironmentVariables()
.AddInMemoryCollection(testParametersDictionary);
configuration = builder.Build();
var chromeDriverInstaller = new ChromeDriverInstaller();
await chromeDriverInstaller.Install();
}
[TestMethod]
public void Submitting_Form_Without_ReCaptcha_Should_Throw_Exception()
{
Assert.ThrowsException<AssertFailedException>(() =>
{
var chromeOptions = new ChromeOptions();
chromeOptions.AddArguments("headless");
using (var driver = new ChromeDriver(chromeOptions))
{
SubmitForm(driver);
}
});
}
[TestMethod]
public void Submitting_Form_With_BypassReCaptcha_Should_Succeed()
{
var chromeOptions = new ChromeOptions();
chromeOptions.AddArguments("headless");
using (var driver = new ChromeDriver(chromeOptions))
{
BypassRecaptcha(driver);
SubmitForm(driver);
}
}
private void SubmitForm(IWebDriver driver)
{
driver.Navigate().GoToUrl("https://localhost:5001");
driver.FindElement(By.Id("firstName")).SendKeys("Jon");
driver.FindElement(By.Id("lastName")).SendKeys("Doe");
driver.FindElement(By.Id("emailAddress")).SendKeys("jon.doe@contoso.net");
driver.FindElement(By.Id("question")).SendKeys("Hello World!");
driver.FindElements(By.CssSelector("form button")).First().Click();
Assert.AreEqual("https://localhost:5001/ThankYou", driver.Url);
Assert.IsTrue(driver.PageSource.Contains("Thank you for contacting us"));
}
private void BypassRecaptcha(IWebDriver driver)
{
throw new NotImplementedException();
}
}
}
The SetupTests
method loads in the configuration using a bunch of different configuration providers. The configuration isn't used in the sample above, but will be later.
Additionally, the ChromeDriverInstaller automatically installs the correct version of the ChromeDriver to the machine if necessary.
The important part of the sample above is the two tests:
Submitting_Form_Without_ReCaptcha_Should_Throw_Exception
As the name alludes to, this test will submit the contact us form, but because the ReCaptcha isn't solved, it should throw an exception. The goal of this test is to verify that not solving the ReCaptcha does indeed prevent the form from being submitted.
This test creates a ChromeDriver and then invoked the SubmitForm
method. The SubmitForm
method browses to the contact us form, fills it out, and submits it without touching the ReCaptcha.
The server will re-render the form with the error message "Solve the captcha challenge". The first assertion in SubmitForm
will throw an error because the URL will still be 'https://localhost:5001'.
That exception is caught by Assert.ThrowsException
which will cause the test to pass.
Submitting_Form_With_BypassReCaptcha_Should_Succeed
This test also invokes the SubmitForm
method, but before doing so the BypassRecaptcha
is called. BypassRecaptcha
is currently not implement and will cause this test to fail, but that will be fixed later.
The goal of this test is to verify the functionality of the form without bothering with the ReCaptcha. The ReCaptcha itself will have to be tested manually for a full end-to-end test.
Bypassing ReCatpcha's in Selenium UI tests
To fix the second test, you need to implement the bypass functionality on both the server and in the UI test. To implement the functionality on the server, add the following Razor Page:
BypassReCaptcha.cshtml:
@page
@model BypassReCaptchaModel
@{
ViewData["Title"] = "Bypass ReCaptcha";
}
<form method="post">
<div class="mb-3">
<label for="secret" class="form-label">Secret</label>
<input asp-for="BypassSecret" class="form-control" id="secret" required>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
BypassReCaptcha.cshtml.cs:
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Configuration;
namespace BypassReCaptcha.Web.Pages
{
public class BypassReCaptchaModel : PageModel
{
private string serverBypassSecret;
[BindProperty]
public string BypassSecret { get; set; }
public BypassReCaptchaModel(IConfiguration configuration)
{
serverBypassSecret = configuration.GetValue<string>("BypassSecret");
}
public void OnGet()
{
}
public IActionResult OnPost()
{
if (!ModelState.IsValid)
{
return Page();
}
if(BypassSecret != serverBypassSecret)
{
return Content("Wrong secret π");
}
else
{
HttpContext.Session.SetString("BypassReCaptcha", "true");
return Content("Success! π");
}
}
}
}
With this extra page, you can now browse to '/BypassReCaptcha', fill out the "BypassSecret", and submit the form.
If the correct secret is submitted, the key-value pair "BypassReCaptcha" => "true" will be added to the session state. (read the session state documentation to configure session state)
Once this key-value pair has been added to the session, you can use this pair to check whether to bypass the ReCaptcha validation wherever you validate your captcha.
Here's the updated code-behind of the contact us form:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using BypassReCaptcha.Web.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
namespace BypassReCaptcha.Web.Pages
{
public class IndexModel : PageModel
{
private static readonly HttpClient httpClient = new HttpClient();
public bool BypassReCaptcha => Boolean.Parse(HttpContext.Session.GetString("BypassReCaptcha") ?? "false");
public string ReCaptchaSiteKey { get; set; }
private string ReCaptchaSecretKey { get; set; }
[BindProperty]
public ContactFormViewModel ContactFormViewModel { get; set; }
public IndexModel(IConfiguration configuration)
{
ReCaptchaSiteKey = configuration.GetValue<string>("ReCaptcha.SiteKey");
ReCaptchaSecretKey = configuration.GetValue<string>("ReCaptcha.SecretKey");
}
public void OnGet()
{
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid || !await ValidateReCaptcha())
{
return Page();
}
return RedirectToPage("./ThankYou");
}
private async Task<bool> ValidateReCaptcha()
{
if (BypassReCaptcha)
{
return true;
}
if (Request.Form.TryGetValue("g-recaptcha-response", out StringValues reCaptchaResponse))
{
var formResult = new FormUrlEncodedContent(new Dictionary<string, string>(){
{"secret", ReCaptchaSecretKey},
{"response", reCaptchaResponse.First()},
{"remoteip", Request.HttpContext.Connection.RemoteIpAddress.ToString()}
});
var response = await httpClient.PostAsync("https://www.google.com/recaptcha/api/siteverify", formResult);
using var responseContentStream = await response.Content.ReadAsStreamAsync();
var json = await JsonDocument.ParseAsync(responseContentStream);
var success = json.RootElement.GetProperty("success").GetBoolean();
if (!success)
{
ModelState.AddModelError("InvalidReCaptcha", "Solve the captcha challenge");
return false;
}
return success;
}
else
{
ModelState.AddModelError("InvalidReCaptcha", "Solve the captcha challenge");
return false;
}
}
}
}
The BypassReCaptcha
property will return false if the key-value pair is missing or has a falsy value. Otherwise, it will return true.
Inside the ValidateReCaptcha
you can check whether to bypass the ReCaptcha using the BypassReCaptcha
property. If it is true, immediately return true and don't bother validating the captcha.
Optionally, you can also update the razor code to exclude the ReCaptcha related code:
@page
@model IndexModel
@{
ViewData["Title"] = "Home Page";
}
@if (!Model.BypassReCaptcha)
{
@section Head{
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
}
}
<form method="post">
@if (!ViewData.ModelState.IsValid)
{
<div class="alert alert-danger" role="alert" asp-validation-summary="All">
</div>
}
<div class="row">
<div class="col-sm">
<div class="mb-3">
<label for="firstName" class="form-label">First Name</label>
<input asp-for="ContactFormViewModel.FirstName" class="form-control" id="firstName" required>
</div>
</div>
<div class="col-sm">
<div class="mb-3">
<label for="lastName" class="form-label">Last Name</label>
<input asp-for="ContactFormViewModel.LastName" class="form-control" id="lastName" required>
</div>
</div>
</div>
<div class="mb-3">
<label for="emailAddress" class="form-label">Email Address</label>
<input asp-for="ContactFormViewModel.EmailAddress" type="email" class="form-control" id="emailAddress"
placeholder="name@example.com" required>
</div>
<div class="mb-3">
<label for="question" class="form-label">Question</label>
<textarea asp-for="ContactFormViewModel.Question" class="form-control" id="question" rows="3"
required></textarea>
</div>
@if (!Model.BypassReCaptcha)
{
<div class="mb-3">
<div class="g-recaptcha" data-sitekey="@Model.ReCaptchaSiteKey"></div>
</div>
}
<button type="submit" class="btn btn-primary">Submit</button>
</form>
In the above razor code, the ReCaptcha-related code is wrapped with @if(!Model.BypassReCaptcha){}
which will remove the code if bypass is set to true in the session state.
Now the UI test needs to be updated to fill out the form at '/BypassRecaptcha' first before running the SubmitForm
method. This is what the updated BypassRecaptcha
code looks like:
private void BypassRecaptcha(IWebDriver driver)
{
driver.Navigate().GoToUrl("https://localhost:5001/BypassRecaptcha");
driver.FindElement(By.Id("secret")).SendKeys(configuration.GetValue<string>("BypassSecret"));
driver.FindElements(By.CssSelector("form button")).First().Click();
Assert.IsTrue(driver.PageSource.Contains("Success!"));
}
Now both tests will pass as you can see below:
Summary
Captcha's are often used as a way to combat spam on website forms. Unfortunately, this also makes it harder to verify the functionality of the forms using UI tests like Selenium.
To work around this you can extend your website with a bypass form. If you provide the correct pre-shared key to the bypass form, the form keeps track of your bypass preference in session and removes the captcha validation for the duration of your session.
Many things weren't covered in this blog post but are part of the sample code, check out the following resources to catch up:
- The sample uses session state which isn't configured by default. Learn how to configure Sessions in ASP.NET Core.
- The sample uses multiple configuration providers such as json files, user secrets, environment variables, and in-memory collection. Learn more about configuration in ASP.NET Core. The configuration providers aren't exclusive to ASP.NET Core. The sample also uses configuration providers inside of the MSTest project. To add the same configuration support to other .NET projects, you need to add the relevant NuGet packages.
- The sample uses Selenium on .NET which you can learn about at "How to UI test using Selenium and .NET Core on Windows, Ubuntu, and MacOS"
- To ensure the correct version of ChromeDriver is installed, the
ChromeDriverInstaller
is used. I blogged about this class at "Download the right ChromeDriver version & keep it up to date on Windows/Linux/macOS using C# .NET".
Top comments (0)