DEV Community

Cover image for Rust Cloudflare Workers: Turnstile Example
Rodney Lab
Rodney Lab

Posted on • Originally published at rodneylab.com

Rust Cloudflare Workers: Turnstile Example

☁️ Rust Cloudflare Workers

In this post, we step beyond initial setup and the basics of Rust Cloudflare workers, where you write worker code in the Rust language and compile to WASM. In a previous post, we saw how to set up your system for writing Rust, as well as building out a router for a Rust API. Here, we look at parsing form data on an incoming API HTTP request and also sending HTTP requests to external servers.

Turnstile

We will step things up a notch, adding an API route which can be used as a serverless backend for Turnstile CAPTCHA challenges. These are the checks websites add to verify the user is not a bot, and so protect forms, for example, from abuse. Turnstile is a CAPTCHA offering from Cloudflare, which does not require users to solve puzzles. This is in contrast to alternatives such as Arkose MetaKey, Google’s reCAPTCHA or hCaptcha. Cloudflare JavaScript code runs code in the browser, which it can use to assess whether the use is a human or bot, and without asking them to count traffic lights.

Like other CAPTCHAs, Cloudflare submits some analysis data from the user browser and responds (to the browser) with code. A second step, run on your site backend, involves submitting the browser response to Cloudflare, along with a secret site key to get Cloudflare's verdict on whether the visitor is a human or bot. We focus on this second step here, which can be useful for static or JAMStack websites.

You might want to read a previous article on getting started with Rust Cloudflare workers, before carrying on, as this post using that, previous, post’s code as a starting point.

⚙️ Project Setup

In the previous, getting started with Rust Cloudflare workers tutorial, we built up a basic REST API, based on the worker Rust template. To start, create a new Git branch in your repo from that tutorial or get the code from the Rodney Lab GitHub repo, then run pnpm install in the project directory.

🤖 CAPTCHA API Route

We will start by adding a new CAPTHCA route to the router code, in src/lib.rs. The code will listen for form submissions sent from the frontend. In a real world application, that POST request would contain form field data, filled out by the site visitor, as well as a cf-turnstile-response field. Here, we won’t look at business logic for processing the form inputs, so just pull that cf-turnstile-response field off the incoming POST request.

#[event(fetch)]
async fn main(req: Request, env: Env, _ctx: Context) -> Result<Response> {
    log_request(&req);
    let router = Router::new();

    router
        .get("/health_check", |_, _| Response::ok("OK"))
        .post_async("/v0/captcha", |mut req, ctx| async move {
            // Read in inputs
            let turnstile_response = match req.form_data().await {
                Ok(value) => {
                    // Get `cf-turnstile-response` field if it exists
                }
                Err(_) => {
                    return Response::error("Bad request", 400);
                }
            };

            // Prepare Turnstile verification HTTP request


            // Verify and process JSON response

            Response::ok("OK")
        })
        .run(req, env)
        .await
}
Enter fullscreen mode Exit fullscreen mode

We call req.form_data() to pull form fields off the request. This returns a Rust result, which we match on. If, for example, the request was sent in the wrong format, and we weren’t able to parse it as form data, we would end up in the Err arm, and would return a Bad request response.

Assuming the request is well-formed, we next want to try to get the value, for the cf-turnstile-response field.

            let turnstile_response = match req.form_data().await {
                Ok(value) => {
                    match value.get("cf-turnstile-response") {
                        Some(FormEntry::Field(field_value)) => field_value,
                        Some(_) => return Response::error("Bad request", 400),
                        None => return Response::error("Bad request", 400),
                    }
                }
                Err(_) => {
                    return Response::error("Bad request", 400);
                }
            }
Enter fullscreen mode Exit fullscreen mode

The form data might be well-formed, but missing the cf-turnstile-response field. Options, in Rust, are used to represent a variable which can have a value of a known type or an alternative, unexpected or invalid value. value, in the code block above, is the identifier we assigned to the form data, and calling get on it returns a Rust Option. That Option will be None, if the cf-turnstile-response is missing. When the field exists, it could be a file or field string value. We only care about the field value, and anything else (represented by the _ should result in a bad request response).

The code above works just fine, though we can make it a little cleaner using Rust if let syntax, in place of the inner match:

            let turnstile_response = match req.form_data().await {
                    if let Some(FormEntry::Field(turnstile_response_value)) =
                        value.get("cf-turnstile-response")
                    {
                        turnstile_response_value
                    } else {
                        return Response::error("Bad request", 400);
                    }
                Err(_) => {
                    return Response::error("Bad request", 400);
                }
            }
Enter fullscreen mode Exit fullscreen mode

How does that work? Let’s see an example. We get a thermometer reading. This will be a number (64-bit float) if everything went well. If the thermometer got unplugged, though, we will get nothing back. Using an option to represent this, the temperature could be Some(25.0), as an example of a normal reading, or None when there is no reading for some reason.

    let temperature: Option<f64> = get_reading();

    if let Some(value) = temperature {
        console_log!("The temperature is {value} degrees")
    } else {
        console_log!("The thermometer broke.")
    }
Enter fullscreen mode Exit fullscreen mode

For our Some(25.0) example, here, value will be 25.0, and we can print the temperature. For the None case, we have no need for value, and just say there is no reading.

Our turnstile example takes it up another level, as we specify we only use the true if value.get("cf-turnstile-response") is Some, and also it is of FormEntry::Field type.

⚽️ Reading Request Headers

Next, we can, optionally, pass the client IP address to Cloudflare in our verification request (we look at that request itself in a moment). The IP address might be included in an HTTP header on the incoming form request to our API. We can check for the CF-Connecting-IP header and get its value with:

let client_ip_option = req.headers().get("CF-Connecting-IP").unwrap();
Enter fullscreen mode Exit fullscreen mode

The req.headers().get function call returns a Result, like (req.form_data()). “unwrap” here, is a way of handling the result without pattern matching, causing a panic in the event of an error.

Pulling it all together, we can build an HTTP request, to send to Turnstile for the verification result using the reqwest crate:

            // Prepare Turnstile verification HTTP request
            let client = reqwest::Client::new();

            let mut body_form_map = HashMap::<&str, String>::new();
            if let Some(value) = client_ip_option {
                body_form_map.insert("remoteip", value);
            };
            body_form_map.insert("response", turnstile_response);
            body_form_map.insert("secret", turnstile_secret);
Enter fullscreen mode Exit fullscreen mode

To be able to use reqwest, though, we should add it in Cargo.toml:

[dependencies]
reqwest = { version = "0.11.18", features = ["json"]}
serde = "1"
worker = "0.0.17"
Enter fullscreen mode Exit fullscreen mode

Of course, we have only prepared the request, and not sent it! Here is the missing code:

            // Prepare Turnstile verification HTTP request
            let client = reqwest::Client::new();

            let mut body_form_map = HashMap::<&str, String>::new();
            if let Some(value) = client_ip_option {
                body_form_map.insert("remoteip", value);
            };
            body_form_map.insert("response", turnstile_response);
            body_form_map.insert("secret", turnstile_secret);

            // Verify and process JSON response
            match client
                .post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
                .form(&body_form_map)
                .send()
                .await
            {
                Ok(value) => match value.json::<TurnstileVerifyResponse>().await {
                    Ok(data) => {
                        let TurnstileVerifyResponse { success } = data;
                        console_log!("Turnstile verified: {success}");
                        Ok(Response::ok("OK")?.with_headers(headers))
                    }
                    Err(_) => Response::error("Bad request", 400),
                },
                Err(_) => Response::error("Bad gateway", 501),
            }
        })
        .run(req, env)
        .await
}
Enter fullscreen mode Exit fullscreen mode

We are sending the data as POST form request. Naturally, reqwest supports JSON POST requests as well as GET and other types of HTTP request.

🤫 Secrets and Environment Variables

In the previous post, we saw how you can read meta from Cargo.toml, made available as environment variables by Rust. Let’s look a little more at environment variable handling. Wrangler lets you define variables in the wrangler.toml file. If you need to store secret variables, hang on a moment, we will see something designed specifically for secrets in a moment.

We expect visitors to submit forms from a browser, and so we need to cater for CORS requests. Specifically, our response needs to include the Access-Control-Allow-Origin header. To see an example of storing variables in wrangler.toml, we will add a list of allowed CORS origins there. Update wrangler.toml:

name = "worker-rust"
main = "build/worker/shim.mjs"
compatibility_date = "2023-03-22"

[vars]
CORS_ORIGIN = "http://127.0.0.1:3001,https://example.com"

[build]
command = "cargo install -q worker-build && worker-build --release"
Enter fullscreen mode Exit fullscreen mode

We can then access that parameter from Rust code using:

ctx.var("CORS_ORIGIN")?.to_string()
Enter fullscreen mode Exit fullscreen mode

Secrets

You can push secrets into your Cloudflare production environment from the Terminal:

pnpm wrangler secret put TURNSTILE_SECRETKEY
Enter fullscreen mode Exit fullscreen mode

However, for local development, use a .dev.vars file in your project root directory:

TURNSTILE_SECRETKEY="0x11111111111_abcdefghijklmnopqrstu"
Enter fullscreen mode Exit fullscreen mode

Then to access this value from your Rust code, use:

ctx.secret("TURNSTILE_SECRETKEY")?.to_string();
Enter fullscreen mode Exit fullscreen mode

🧰 CORS Header Utility Function

We will add a utility function, before wrapping up with the final version of the code. You can add this code in src/lib.rs before the main function:

fn cors_response_headers(request_headers: &worker::Headers, cors_origin: &str) -> worker::Headers {
    let mut headers = worker::Headers::new();
    let origin = match request_headers.get("Origin").unwrap() {
        Some(value) => value,
        None => return headers,
    };
    headers
        .set("Access-Control-Allow-Headers", "Content-Type")
        .expect("Unable to set header");
    headers
        .set("Access-Control-Allow-Methods", "POST")
        .expect("Unable to set header");
    headers.set("Vary", "Origin").expect("Unable to set header");
    if cors_origin.split(',').any(|val| val == cors_origin) {
        headers
            .set("Access-Control-Allow-Origin", &origin)
            .expect("Unable to set header");
    }
    headers
        .set("Access-Control-Max-Age", "86400")
        .expect("Unable to set header");
    headers
}
Enter fullscreen mode Exit fullscreen mode

The CAPTCHA route code will call this function, passing in the request headers and the comma separated list of allowed CORS origins. The request headers should include an Origin header, which we can check against the list of allowed origins.

Two points to note here are:

  • the use of expect to handle results; and
  • the Rust closure in lines 40-44.

expect works like unwrap and causes a panic when the result is not OK. The difference is that it takes an argument, which gives some feedback if it does panic.

cors_origin.split(',') in line 40 splits a comma separated list, in a string such as "http://127.0.0.1:3001,https://example.com" into a vector of strings like: ["http://127.0.0.1:3001", "https://example.com"] and then returns an iterator over that vector. We can then call the any closure on that value, looking for matches, and return true if there is a match. This functional approach provides a cleaner alternative than an explicit loop over the vector elements, in this case.

🖥️ Final Router Code

Pulling everything together, your final version will look something like this:

#[event(fetch)]
async fn main(req: Request, env: Env, _ctx: Context) -> Result<Response> {
    log_request(&req);
    let router = Router::new();

    router
        .get("/health_check", |_, _| Response::ok("OK"))
        .options("/v0/captcha", |req, ctx| {
            let headers =
                cors_response_headers(req.headers(), &ctx.var("CORS_ORIGIN")?.to_string());

            Ok(Response::empty()
                .unwrap()
                .with_headers(headers)
                .with_status(204))
        })
        .post_async("/v0/captcha", |mut req, ctx| async move {
            // Read in inputs
            let client_ip_option = req.headers().get("CF-Connecting-IP").unwrap();
            let turnstile_secret = ctx.secret("TURNSTILE_SECRETKEY")?.to_string();

            let headers =
                cors_response_headers(req.headers(), &ctx.var("CORS_ORIGIN")?.to_string());

            let turnstile_response = match req.form_data().await {
                Ok(value) => {
                    if let Some(FormEntry::Field(turnstile_response_value)) =
                        value.get("cf-turnstile-response")
                    {
                        turnstile_response_value
                    } else {
                        return Response::error("Bad request", 400);
                    }
                }
                Err(_) => {
                    return Response::error("Bad request", 400);
                }
            };

            // Prepare Turnstile verification HTTP request
            let client = reqwest::Client::new();

            let mut body_form_map = HashMap::<&str, String>::new();
            if let Some(value) = client_ip_option {
                body_form_map.insert("remoteip", value);
            };
            body_form_map.insert("response", turnstile_response);
            body_form_map.insert("secret", turnstile_secret);

            // Verify and process JSON response
            match client
                .post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
                .form(&body_form_map)
                .send()
                .await
            {
                Ok(value) => match value.json::<TurnstileVerifyResponse>().await {
                    Ok(data) => {
                        let TurnstileVerifyResponse { success } = data;
                        console_log!("Turnstile verified: {success}");
                        Ok(Response::ok("OK")?.with_headers(headers))
                    }
                    Err(_) => Response::error("Bad request", 400),
                },
                Err(_) => Response::error("Bad gateway", 501),
            }
        })
        .run(req, env)
        .await
}
Enter fullscreen mode Exit fullscreen mode

Here, we just put everything together, also adding an extra captcha route, listening for CORS OPTIONS requests.

💯 Rust Cloudflare Workers: Check your Work

Rust Cloudflare workers:Terminal screen capture shows logs for a request on the /v0/captcha route with text showing Turnstile successfully verified the user.  Environment variables are also listed.  The value for CORS_ORIGIN is listed, while the TURNSTILE_SECRETKEY is hidden.

To test this out, you might want to spin up a quick frontend using Deno Fresh or Astro. You will need to configure Turnstile in your Cloudflare account to get a site key and secret key.

🙌🏽 Rust Cloudflare Workers: Wrapping Up

We continued our exploration of Rust and Cloudflare workers adding new routes to the API. More specifically, we have seen:

  • how to parse form data on an incoming HTTP request using Rust Cloudflare workers;
  • how to use reqwest to make REST requests to external servers; and
  • examples of adding response headers on API endpoints.

The Rust Cloudflare Workers Turnstile Example code is in the Rodney Lab GitHub repo.

I hope you found this article useful, and am keen to hear where you will go next, as well as potential improvements, both to the Rust code and explanations above. Also, let me know if you would like to see another edition in this series, and which feature would be interesting to explore.

🙏🏽 Rust Cloudflare Workers: Feedback

Have you found the post useful? Would you prefer to see posts on another topic instead? Get in touch with ideas for new posts. Also, if you like my writing style, get in touch if I can write some posts for your company site on a consultancy basis. Read on to find ways to get in touch, further below. If you want to support posts similar to this one and can spare a few dollars, euros or pounds, please consider supporting me through Buy me a Coffee.

Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via @askRodney on Twitter and also askRodney on Telegram. Also, see further ways to get in touch with Rodney Lab. I post regularly on Astro as well as SvelteKit. Also, subscribe to the newsletter to keep up-to-date with our latest projects.

Top comments (0)