ASP.NET Core Form protection with Cloudflare's Turnstile

ASP.NET Core Form protection with Cloudflare's Turnstile

TLDR;

You can see the full sample in my GitHub repository BEN ABT Samples - ASP.NET Core Form protection with Cloudflare's Turnstile

Turnstile is a pretty new, free product from Cloudflare and is intended to be a better alternative to Google's reCAPTCHA. Cloudflare itself advertises that Turnstile offers a better user experience and at the same time increases security and does not jeopardize data protection.

The free use of Turnstile is limited to

  • 10 widgets
  • The Cloudflare branding is included
  • Only the use of the "managed mode" is possible
  • 10 hostnames per widget

The paid version, which has no public pricing but is part of Cloudflare's Enterprise plan, offers the following features

  • Unlimited number of widgets
  • No Cloudflare branding
  • Different modes, managed, but also "never interactive mode"
  • No limit on hostnames

Setup Cloudflare

Turnstile works very similarly to reCAPTCHA or hCAPTCHA

  • There is an HTML widget that is loaded via JavaScript.
  • The JavaScript file is loaded by the browser from a Cloudflare endpoint, and thus the user's IP address is also transmitted to Cloudflare.
  • A data-cf-turnstile attribute containing the ID of the widget is inserted into an HTML form.
  • When the form is sent, the turnstile widget inserts a hidden form data element that is required on the server for evaluation (token).
  • The implementation of the server when accepting the form data performs the token check.
  • For this purpose, a verify endpoint is addressed, to which the token and optionally the user IP address and a uniqueness feature ("idempotency key") are sent
  • In addition, a site key and a secret are required to authenticate the endpoint.
  • The Verify endpoint responds whether the request was legitimate or not; additionally an error array if there were problems with the request.

To use Turnstile, you need to create an account with Cloudflare and register a page. The 'Turnstile' tab in the navigation menu takes you to the Turnstile overview, where you can create a new page.

2024-02-cf-turnstile-create-site

After creation, the site key and the secret key are displayed and must be transferred to the configuration.

2024-02-cf-turnstile-site-created

ASP.NET Core MVC View Implementation

The turnstile widget is integrated in the HTML view or, in this case, the ASP.NET Core View. To do this, the JavaScript file from Cloudflare is loaded and the data-cf-turnstile attribute is inserted into the form.

@inject Microsoft.Extensions.Options.IOptions<CloudflareTurnstileSettings> CFTOptions
@{
    var turnstileConfig = CFTOptions.Value;

    ViewData["Title"] = "Home Page";
}

<!-- Cloudflare Turnstile Setup -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback" defer></script>

<form method="post">
    @(Html.AntiForgeryToken())

    <input type="text" placeholder="Sample Text" name="@(nameof(PostSampleSubmitModel.SampleInput))" />

    <div class="cf-turnstile" data-sitekey="@(turnstileConfig.SiteKey)" data-callback="javascriptCallback"></div>

    <button type="submit">Submit</button>
</form>

The data-cf-turnstile attribute is enriched with the site key, which is provided by Cloudflare to identify which website the setup applies to. In addition, there are various data-callback attribute values that influence the behavior of the script and can be found in the documentation.

The form view then looks as follows:

2024-02-cf-turnstile-form

.NET Server Side Implementation

The server-side implementation is currently a bit more complex in .NET, as there is no ready-made SDK from Cloudflare; unfortunately, .NET is not taken into account for many things from Cloudflare, so you have to write a lot yourself.

Thankfully, however, there are many things in .NET that make life easier, e.g. Refit for addressing endpoints. Via the Refit definition of a client via an interface, the Verify endpoint is defined by Turnstile, which includes both a request model and a result model.

public interface ICloudflareTurnstileClient
{
    [Post("/siteverify")]
    [Headers("Content-Type: application/json")]
    public Task<CloudflareTurnstileVerifyResult> Verify(CloudflareTurnstileVerifyRequestModel requestModel,
          CancellationToken ct);
}

public record class CloudflareTurnstileVerifyRequestModel(
    // https://developers.cloudflare.com/turnstile/get-started/server-side-validation
    [property: JsonPropertyName("secret")] string SecretKey,
    [property: JsonPropertyName("response")] string Token,
    [property: JsonPropertyName("remoteip")] string? UserIpAddress,
    [property: JsonPropertyName("idempotency_key")] string? IdempotencyKey);

public record class CloudflareTurnstileVerifyResult(
    // https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
    [property: JsonPropertyName("success")] bool Success,
    [property: JsonPropertyName("error-codes")] string[] ErrorCodes,
    [property: JsonPropertyName("challenge_ts")] DateTimeOffset On,
    [property: JsonPropertyName("hostname")] string Hostname
    );

This fully describes the endpoint communication to Cloudflare. However, to make things easier in the code itself, it is worth writing an additional provider here, which takes over communication with the client and should then also include exception handling in a productive implementation.

// This code example is very simplified to show the basic functionality. You should also add interfaces and exception handling to your code.

public class CloudflareTurnstileSettings
{
    [Required]
    public string BaseUrl { get; set; } = null!;

    [Required]
    public string SiteKey { get; set; } = null!;

    [Required]
    public string SecretKey { get; set; } = null!;
}

public class CloudflareTurnstileProvider
{
    private readonly CloudflareTurnstileSettings _turnstileSettings;
    private readonly ICloudflareTurnstileClient _client;

    public CloudflareTurnstileProvider(IOptions<CloudflareTurnstileSettings> turnstileOptions,
        ICloudflareTurnstileClient client)
    {
        _turnstileSettings = turnstileOptions.Value;
        _client = client;
    }

    public async Task<CloudflareTurnstileVerifyResult> Verify(string token, string? idempotencyKey = null,
        IPAddress? userIpAddress = null, CancellationToken ct = default)
    {
        CloudflareTurnstileVerifyRequestModel requestModel = new(_turnstileSettings.SecretKey, token, userIpAddress?.ToString(), idempotencyKey);

        CloudflareTurnstileVerifyResult result = await _client
                .Verify(requestModel, ct)
                .ConfigureAwait(false);

        return result;
    }
}

The provider requires settings that are stored in appsettings.json (API URL, key, secret), as well as the client that handles communication with the verify endpoint. This makes the use in the code (e.g. in the logic or in the action) as simple as possible and the communication with Cloudflare is encapsulated. This also helps enormously when writing tests.

The only thing missing now is dependency injection, which registers all components of the Cloudflare Turnstile implementation.

public static class CloudflareTurnstileRegistration
{
    public static void AddCloudflareTurnstile(
        this IServiceCollection services, IConfigurationSection configurationSection)
    {
        // configure
        services.Configure<CloudflareTurnstileSettings>(configurationSection);

        // read url required for refit
        string clientBaseUrl = configurationSection.GetValue<string>(nameof(CloudflareTurnstileSettings.BaseUrl))!;
        // we can enforce not-null because we have a required attribute in the settings

        // in this sample the provider can be a singleton
        services.AddSingleton<CloudflareTurnstileProvider>();

        // add refit client
        services.AddRefitClient<ICloudflareTurnstileClient>()
            .ConfigureHttpClient(c => c.BaseAddress = new Uri(clientBaseUrl));
    }
}

The method AddCloudflareTurnstile is called in Startup.cs or Program.cs (Minimal API) and registers all components of the Cloudflare Turnstile implementation. The settings are read from appsettings.json and the client is configured. The provider is registered as a singleton, as it does not save any states and can therefore be reused for all requests.

// Program.cs

// Add Cloudflare Turnstile
builder.Services.AddCloudflareTurnstile(builder.Configuration.GetRequiredSection("CloudflareTurnstile"));
// appsettings.json
{
  "CloudflareTurnstile": {
    "BaseUrl": "https://challenges.cloudflare.com/turnstile/v0",
    "SiteKey": "0xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "SecretKey": "0xxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  }
}

Verify the Token in the Controller

The setup is complete - the token can now be checked in the action when processing the form. To do this, the CloudflareTurnstileProvider is injected into the controller and the token is passed to the provider. This simple sample only uses the IP address at this point, but it is generally recommended by Turnstile to also use the idempotencyKey in order to uniquely identify the request.

public class HomeController(CloudflareTurnstileProvider cloudflareTurnstileProvider) : Controller
{
    [HttpGet]
    public IActionResult Index() => View();

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Index(PostSampleSubmitModel submitModel,
        [FromForm(Name = "cf-turnstile-response")] string turnstileToken)
    {
        // read users ip address 
        // proxy? => https://learn.microsoft.com/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-8.0&WT.mc_id=DT-MVP-5001507
        IPAddress? userIP = Request.HttpContext.Connection.RemoteIpAddress;

        // verify token
        CloudflareTurnstileVerifyResult cftResult = await cloudflareTurnstileProvider
            .Verify(turnstileToken, userIpAddress: userIP);

        // in a productive environment you can implement this as action filter

        // present result
        ViewBag.Result = cftResult;

        return View();
    }
}

public record class PostSampleSubmitModel(string SampleInput);

If you wish, you can outsource the entire provider logic to a filter and provide the action with a corresponding attribute. This keeps the actions simple if tokens are to be checked in several places.

Full Sample

The full example and all relevant code snippets can be found in my GitHub repository BEN ABT Samples - ASP.NET Core Form protection with Cloudflare's Turnstile

Have fun protecting your application!