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
Cookie
header is a comma-separated list ofname=value
pairs - Each
Set-Cookie
header, instead, can specify a lot of additional attributes on top of thename=value
pair. 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 Cookie
s 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:
RequestCookie
represents a cookie parsed from aCookie
header. It has aname
and avalue
, nothing more.ResponseCookie
, instead, represents a cookie that you want to send to the client via aSet-Cookie
header. It has aname
, avalue
and 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-Cookie
header is telling the client to remove the existingname
cookie withPath=/
attribute, using anExpires
attribute set to the past. - The second
Set-Cookie
header is telling the client to create a newname
cookie set tovalue
withPath=/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
Path
attribute of the cookie - The
Domain
attribute 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.