biscotti, a new crate for HTTP cookies
- 2242 words
- 12 min
TL;DR
biscotti ("cookies", but in Italian) is a new Rust crate to
handle HTTP cookies on the server side.
biscotti's API strives to be as faithful as possible to the underlying semantics of HTTP cookies, with a keen eye for
edge cases and security:
- Separate types for request and response cookies
- Support for working with multiple cookies with the same name, in both requests and responses
- Centralized management of cookie's cryptographic guarantees (i.e. what gets signed or encrypted)
- Built-in support for rotating signing/encryption keys over time
- Percent-encoding/decoding cookies enabled by default (but you can opt out)
The first release of biscotti is available on crates.io and its documentation
is hosted on docs.rs.
The rest of this article will cover the motivation behind biscotti and show where it differs (and why) from the
cookie crate, the de-facto standard for cookies in Rust.
You can discuss this article on r/rust.
Table of Contents
- Cookies in Rust
- Why
biscotti? - Design goals
- Cookies crash course
- Request and response cookies are different
- Cookie collections
- Request cookies
- Response cookies
- Signed and encrypted cookies
- Conclusion
Cookies in Rust
The entirety of the Rust web ecosystem has effectively standardized on one crate for HTTP cookies:
cookie.
It was created almost 10 years ago
by Alex Crichton, and it has been taken over and maintained by Sergio Benitez
(author and maintainer of Rocket) for the past 7 years.
cookie is re-exported by every major web framework:
Rocket (obviously),
Actix Web,
axum,
poem and so on.
Why biscotti?
I'm working on a new web framework, Pavex, and I was about to do the same:
grab the cookie crate, re-export it as a pavex::cookie module and call it a day.
Then I paused: in the past I had to fight the cookie crate to handle more "advanced" cookie scenarios.
Could I craft an API simple enough for the 80% of the use cases, but powerful enough to cover the remaining 20%?
That's how biscotti was born!
It started as a fork of the cookie crate, but the API has diverged significantly since I got started.
Design goals
cookie is a general-purpose crate: it can be used to handle cookies both on the server and on the client side.
That's not the case for biscotti. I deliberately focused on the server side.
There is no support, for example, for parsing Set-Cookie headers on the client side in biscotti.
Cookies crash course
Let's start with a quick refresher on how cookies work.
Cookies are a way to attach state to an otherwise stateless protocol, HTTP.
The state is stored on the client side, usually by a browser.
The state is passed back and forth between the client and the server using the Cookie and Set-Cookie headers.
The Cookie header is used by clients to send relevant cookies to the server when they issue requests.
The Set-Cookie header, instead, is used by the server to alter the state on the client-side, either by
creating new cookies, removing existing ones, or updating their attributes.
Request and response cookies are different
The Cookie and Set-Cookie headers have very different semantics:
- The
Cookieheader is a comma-separated list ofname=valuepairs - Each
Set-Cookieheader, instead, can specify a lot of additional attributes on top of thename=valuepair. You havePath,Domain,Expires,Max-Age,Secure,HttpOnly,SameSite, etc.
cookie
You don't see this distinction in the API of the cookie crate.
The same Cookie type is used to represent both incoming and outgoing cookies.
Incoming cookies are Cookies with all optional attributes set to None.
It is us up to the user to remember that this doesn't imply that,
in the cookie jar on the client side, those attributes are actually unset!
biscotti
In biscotti, I chose to encode the distinction in the type system:
RequestCookierepresents a cookie parsed from aCookieheader. It has anameand avalue, nothing more.ResponseCookie, instead, represents a cookie that you want to send to the client via aSet-Cookieheader. It has aname, avalueand it allows you to set all those additional attributes.
You can't mix them up—you can't accidentally send a RequestCookie to the client,
or try to set a Path attribute on a RequestCookie.
Cookie collections
The differences between request and response cookies don't stop there. They also have different semantics when it comes to cookie collections.
Request cookies
In the Cookie header, you can have multiple cookies with the same name.
Consider the following well-formed GET request:
GET /home HTTP/1.1
Host: example.com
Cookie: name=first; name=second;
This may happen, for example, if the two name cookies were set with a different Path attributes
and both paths are relevant for the current request (e.g. / and /home in our example above).
In this scenario, browsers will send both cookies, even if they have the same name.
Depending on what you are trying to do, it might make sense to access the first cookie value, the second cookie value, or all of them!
cookie
The cookie crate gives you a CookieJar type to work with cookie collections, regardless of whether they are
request or response cookies.
For the purpose of this example, you can think of CookieJar as a HashMap<String, Cookie>.
If you insert two cookies with the same name, the second one will overwrite the first one.
Most web frameworks follow this algorithm to parse the Cookie header with cookie:
use cookie::{Cookie, CookieJar};
let cookie_header = "name=first; name=second;";
let mut jar = CookieJar::new();
for cookie in Cookie::split_parse(cookie_header) {
let cookie = cookie.unwrap();
jar.add_original(cookie);
}
In our example above, the jar would only contain the last name cookie, with the value second. You can try this out
in the Rust playground.
You can find this very code snippet, with minor variations, in axum
and poem.
They're not to blame: CookieJar isn't designed to handle multiple cookies with the same name,
you can't "fix it" without abandoning CookieJar altogether.
biscotti
biscotti takes a different approach. We have a dedicated type, RequestCookies, to handle incoming cookies as
a collection.
use biscotti::{Processor, config::Config, RequestCookies};
let header = "name=first; name=second";
let processor: Processor = Config::default().into();
let cookies = RequestCookies::parse_header(header, &processor).unwrap();
RequestCookies is a multivalued map, like HeaderMap in http: you can access the first cookie with a given name,
or opt to fetch all values associated with that cookie name.
let value = cookies.get("name").unwrap().value();
assert_eq!(value, "first");
// With `get_all`, you get an iterator over all the cookies with that name.
let all_values: Vec<_> = cookies.get_all("name").unwrap().values().collect();
assert_eq!(all_values, vec!["first", "second"]);
The 80% of the time you can get by with treating RequestCookies as a HashMap<String, RequestCookie>, but
you have the flexibility to handle the remaining 20% of the cases when you need to.
Response cookies
A similar issue arises when working with response cookies.
The following response is well-formed:
HTTP/1.1 200 OK
Set-Cookie: name=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
Set-Cookie: name=value; Path=/home;
The server is instructing the client to manipulate two cookies with the same name, but different path attributes.
In particular:
- The first
Set-Cookieheader is telling the client to remove the existingnamecookie withPath=/attribute, using anExpiresattribute set to the past. - The second
Set-Cookieheader is telling the client to create a newnamecookie set tovaluewithPath=/home.
This is spec-compliant, and all major browsers will behave as expected here.
cookie
With the cookie crate you need to use CookieJar to handle response cookies as well.
The same semantic issues we saw with request cookies apply here as well:
use cookie::{CookieBuilder, CookieJar};
let mut jar = CookieJar::new();
let mut removal_cookie = CookieBuilder::new("name", "").path("/").finish();
removal_cookie.make_removal();
jar.add(removal_cookie);
let new_cookie = CookieBuilder::new("name", "value").path("/home").finish();
jar.add(new_cookie);
In this case, jar will contain a single name cookie, set to value with Path=/home.
assert_eq!(jar.delta().count(), 1);
let set_cookie = jar.delta().next().unwrap();
assert_eq!(set_cookie.to_string(), "name=value; Path=/home");
The removal cookie is lost and won't be sent to the client (Playground link).
biscotti
biscotti has a dedicated type, ResponseCookies, to handle outgoing cookies as a collection.
You can picture it as a HashMap<ResponseCookieId, ResponseCookie>, where ResponseCookieId captures:
- The name of the cookie
- The
Pathattribute of the cookie - The
Domainattribute of the cookie
As long as the combination of those three values is different, response cookies will be considered to be distinct.
Going back to our example, in biscotti you would do:
use biscotti::{ResponseCookies, RemovalCookie, ResponseCookie};
let mut cookies = ResponseCookies::new();
cookies.insert(RemovalCookie::new("name").set_path("/"));
cookies.insert(ResponseCookie::new("name", "value").set_path("/home"));
We can then obtain the respective Set-Cookie header values:
use maplit::hashset;
use std::collections::HashSet;
use biscotti::{Processor, config::Config};
let processor: Processor = Config::default().into();
let header_values: HashSet<_> = cookies.header_values(&processor).collect();
assert_eq!(header_values, hashset! {
"name=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT".to_string(),
"name=value; Path=/home".to_string(),
});
Both Set-Cookie header values are present, as we wanted.
Signed and encrypted cookies
Both cookie and biscotti provide built-in support for signing and encrypting cookies.
cookie
In cookie, you opt into signing or encrypting cookies by creating a "child" jar from the main CookieJar:
use cookie::{Cookie, CookieJar, Key, Private};
let key = Key::generate()
let mut jar = CookieJar::new();
let mut child_jar = jar.private(&key);
// This cookie will be encrypted!
child_jar.add(Cookie::new("name", "value"));
The same process is used to verify and decrypt cookies:
let mut jar = CookieJar::new();
let mut child_jar = jar.private(&key);
// This cookie will be decrypted!
let cookie = child_jar.get("name").unwrap();
biscotti
In biscotti, signing and encryption are managed centrally.
You populate a single Config with the cryptographic setup you want to use, and then you convert it into a Processor:
use biscotti::{Processor, Key, config::{Config, CryptoType}};
let mut config = Config::default();
let rule = CryptoRule {
cookie_names: vec!["session".to_string()],
r#type: CryptoType::Signing,
key: Key::generate(),
secondary_keys: vec![],
};
config.crypto_rules.push(rule);
let processor: Processor = config.into();
The Processor is a required argument when parsing request cookies or when generating the Set-Cookie header values for response cookies,
as you've seen in the code snippets from the previous sections.
use biscotti::{Processor, config::Config, RequestCookies};
let header = "session=unsigned-value";
// `parse_header` will return an error since `session` is expected to be signed
// and it isn't in this case.
let cookies = RequestCookies::parse_header(header, &processor).unwrap();
The rest of the application doesn't need to know about the cryptographic setup, nor does it need access to the cryptographic keys.
In addition to signing and encryption, biscotti also provides built-in support for rotating signing/encryption keys over time
via the secondary_keys field in CryptoRule.
Cookies will always be signed/encrypted with the primary key, but the Processor will be also accept cookies that were
signed/encrypted with any of the secondary keys.
This allows you to rotate keys over time without invalidating all existing cookies.
Conclusion
biscotti, just like cookie, is a framework-agnostic library.
If you are building a web framework, you can use biscotti to handle cookies in your HTTP server.
If you need biscotti's features in your axum or poem application, you should be able to integrate it seamlessly.
Keep in mind that biscotti is a new crate—it isn't as battle-tested as cookie and it might have some rough edges.
Feel free to open an issue on the GitHub repository
if you find any bugs or if you have any suggestions.
I hope you find biscotti useful!
You can discuss this article on r/rust.