Rocket - multi counter using encrypted cookies

cookies add_private get_private secret_key Rocket.toml private_cookie

In an earlier example we saw how to create a counter using cookies as part of the Rocket series.

We saw that the user can pretend to have any number in the counter by using curl or some other client where it is easy to send in any cookie.

This is another one of the counter examples where we'll use encrypted private cookies.

There are only a few changes from the free-text cookie version.

Changes

Dependencies in Cargo.toml

examples/rocket/multi-counter-using-encrypted-cookies/Cargo.toml

[package]
name = "multi-counter-using-encrypted-cookies"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
rocket = { version = "0.5", features = ["secrets"] }

We added the secrets feature

Secret_key in Rocket.toml

examples/rocket/multi-counter-using-encrypted-cookies/Rocket.toml

[debug]
secret_key = "qqrqdOg7fX4YNaDFzXf1mu6050BQ9okssS5sKkZFMVsd"

[release]
secret_key = "dOg7fX4YNaDFzXf1mu6050BQ9okssS5sKkZFMVsdpXg="

In this example we provide a secret_key for both the debug and the release mode.

If we don't provide a secret_key for the debug mode then it will be generated each time we start the application. That means cookies will not be recognized and accepted after we restart the web application.

If we don't provide a secret_key for the release mode then the application will refuse to start.

The secret key can be generated, or one can just type in random characters.

The code

examples/rocket/multi-counter-using-encrypted-cookies/src/main.rs

#[macro_use]
extern crate rocket;

use rocket::http::CookieJar;

#[get("/")]
fn index(cookies: &CookieJar<'_>) -> String {
    let counter: u32 = match cookies.get_private("counter") {
        Some(cookie) => match cookie.value().parse() {
            Ok(val) => val,
            Err(_) => {
                eprintln!("Invalid value {} for the 'counter' cookie.", cookie.value());
                0
            }
        },
        None => 0,
    };
    let counter = counter + 1;
    cookies.add_private(("counter", counter.to_string()));

    format!("Counter: {}", counter)
}

#[launch]
fn rocket() -> _ {
    rocket::build().mount("/", routes![index])
}

#[cfg(test)]
mod tests;

Here the difference is that we use get_private instead of get and add_private instead of add.

Running

cargo run

Checking manually with two browsers

After launching the application we can visit http://localhost:8000/ and we'll see Counter: . Every time we reload the page we will get a higher number.

You can open a separate private tab or a different browser and visit the same URL. You'll notice the counters increase independently.

If you open the "Web developer tools", the "Storage" tab, Cookies, you will see a line corresponding to the URL http://localhost:8000/ The name of the cookie "counter" will be visible, but the value will be just some random string. This is the encrypted value of the cookie.

Without the secret_key you won't be able to decrypt it. At least not in a reasonable amount of time.

Checking manually with curl

The fist request goes well. We can see visible part Counter: 1 and in the header we can see the cookie.

$ curl -i http://localhost:8000

HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
set-cookie: counter=cEdonPR4JzPyB0ox85kNcWU7Rmepp+trOcwjqWQ%3D; HttpOnly; SameSite=Strict; Path=/; Expires=Fri, 19 Jan 2024 15:34:11 GMT
server: Rocket
x-frame-options: SAMEORIGIN
permissions-policy: interest-cohort=()
x-content-type-options: nosniff
content-length: 10
date: Fri, 12 Jan 2024 15:34:11 GMT

Counter: 1

We can send in the encrypted cookie. We'll get back Counter: 2 as expected and a new cookie value.

$ curl -i --cookie counter=cEdonPR4JzPyB0ox85kNcWU7Rmepp+trOcwjqWQ%3D http://localhost:8000

HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
set-cookie: counter=cJutBaxBcNPnGx17pKGJ6Gw%2FPzvVGkiP38S7BNo%3D; HttpOnly; SameSite=Strict; Path=/; Expires=Fri, 19 Jan 2024 15:35:54 GMT
server: Rocket
x-frame-options: SAMEORIGIN
permissions-policy: interest-cohort=()
x-content-type-options: nosniff
content-length: 10
date: Fri, 12 Jan 2024 15:35:54 GMT

Counter: 2

We can repeat the original request and we'll get a new cookie.

We can replace the value of the cookie with any other string. Unless we get lucky and hit one of the possible cookie values we will keep getting "Counter: 1" as the application will keep disregarding the value.

Testing with Rust

examples/rocket/multi-counter-using-encrypted-cookies/src/tests.rs

use rocket::http::Status;
use rocket::local::blocking::Client;

#[test]
fn test_without_cookie() {
    let client = Client::tracked(super::rocket()).unwrap();
    let client = client;

    let response = client.get("/").dispatch();

    assert_eq!(response.status(), Status::Ok);

    // "counter=C3YSnaksM52BVeYOPjvcOp7pNRAez5ZB8aq+a+A%3D; HttpOnly; SameSite=Strict; Path=/; Expires=Mon, 15 Jan 2024 06:44:15 GMT"
    let cookie = response.headers().get_one("set-cookie").unwrap();
    assert!(cookie.contains("counter="));
    assert!(cookie.contains("; HttpOnly; SameSite=Strict; Path=/; Expires="));

    assert_eq!(response.into_string(), Some("Counter: 1".into()));
}

#[test]
fn test_with_cookie() {
    let client = Client::tracked(super::rocket()).unwrap();

    let response = client.get("/").private_cookie(("counter", "41")).dispatch();
    assert_eq!(response.status(), Status::Ok);

    assert_eq!(response.into_string(), Some("Counter: 42".into()));
}

#[test]
fn test_with_bad_cookie() {
    let client = Client::tracked(super::rocket()).unwrap();
    let response = client
        .get("/")
        .private_cookie(("counter", "bla"))
        .dispatch();

    assert_eq!(response.status(), Status::Ok);
    assert_eq!(response.into_string(), Some("Counter: 1".into()));
}

// I was expecting this too to work, but it does not, I get back "Counter 1" for the second request as well.
// #[test]
// fn test_counter() {
//     let client = Client::tracked(super::rocket()).unwrap();
//     let client = client;

//     let response = client.get("/").dispatch();

//     assert_eq!(response.status(), Status::Ok);

//     // "counter=C3YSnaksM52BVeYOPjvcOp7pNRAez5ZB8aq+a+A%3D; HttpOnly; SameSite=Strict; Path=/; Expires=Mon, 15 Jan 2024 06:44:15 GMT"
//     let cookie = response.headers().get_one("set-cookie").unwrap();
//     assert!(cookie.contains("counter="));
//     assert!(cookie.contains("; HttpOnly; SameSite=Strict; Path=/; Expires="));

//     let (head, _tail) = cookie.split_once(';').unwrap();
//     let (_head, tail) = head.split_once('=').unwrap();
//     let cookie_str = tail.to_string();

//     assert_eq!(response.into_string(), Some("Counter: 1".into()));

//     //assert_eq!(cookie_str, "");
//     let response = client.get("/").cookie(("counter", cookie_str)).dispatch();
//     assert_eq!(response.into_string(), Some("Counter: 2".into()));
// }

At first I extracted the encrypted cookie string from the cookie and sent that back to the server, but that was not recognized as a correctly encrypted cookie. I am not sure why.

I found out that the correct way in Rocket to to send encrypted cookies in a test is by calling the private_cookie method and providing the real value there.

Because we are in the same environment, the test also has access to the Rocket.toml file where we have the secret_key.

Conclusion

Using private cookies is not more difficult than plain text cookies and they are more secure.

Related Pages

Rocket - web development with Rust

Author

Gabor Szabo (szabgab)

Gabor Szabo, the author of the Rust Maven web site maintains several Open source projects in Rust and while he still feels he has tons of new things to learn about Rust he already offers training courses in Rust and still teaches Python, Perl, git, GitHub, GitLab, CI, and testing.

Gabor Szabo