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
-
Include the
secrets
feature. -
Set the
secret_key
in theRocket.toml
file. See explanation about the secret_key and the about the Rocket configuration for more options. -
Use
get_private
andadd_private
instead ofget
andadd
.
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.