diff options
-rw-r--r-- | .gitignore | 4 | ||||
-rw-r--r-- | Cargo.toml | 25 | ||||
-rw-r--r-- | README.md | 119 | ||||
-rw-r--r-- | src/error.rs | 44 | ||||
-rw-r--r-- | src/lib.rs | 207 | ||||
-rw-r--r-- | src/security.rs | 65 | ||||
-rw-r--r-- | src/signed.rs | 72 |
7 files changed, 536 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..436987b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target + +# See https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..03f39a4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "sputnik" +version = "0.1.0" +authors = ["Martin Fischer <martin@push-f.com>"] +license = "MIT" +description = "A lightweight layer on top of hyper to facilitate building web applications." +repository = "https://git.push-f.com/sputnik" +edition = "2018" +categories = ["web-programming::http-server"] +keywords = ["hyper", "web", "cookie", "csrf", "hmac"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +hyper = "0.13" +cookie = { version = "0.14", features = ["percent-encode"] } +serde = { version = "1.0", features = ["derive"] } +serde_urlencoded = "0.7.0" +base64 = "0.8" +hmac = "0.10" +httpdate = "0.3.2" +mime = "0.3" +rand = "0.7.3" +sha2 = "0.9" +time = "0.2"
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b2877e --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# Sputnik + +A lightweight layer on top of [Hyper](https://hyper.rs/) to facilitate +building web applications. + +Sputnik provides: + +* convenience wrappers around hyper's `Request` & `Response` + * parse, set and delete cookies + (powered by the [cookie](https://crates.io/crates/cookie) crate) + * parse query strings and HTML form data (powered by the + [serde_urlencoded](https://crates.io/crates/serde_urlencoded) crate) +* cookie-based [CSRF](https://en.wikipedia.org/wiki/Cross-site_request_forgery) tokens +* `Key`: a convenience wrapper around HMAC (stolen from the cookie crate, so + that you don't have to use `CookieJar`s if you don't need them) +* `decode_expiring_claim` & `encode_expiring_claim`, which can be combined with + `Key` to implement [signed & expiring cookies](#signed--expiring-cookies) + (with the expiry date encoded into the signed cookie value) + +Sputnik does **not**: + +* handle routing: for most web apps `match`ing on (method, path) suffices +* handle configuration: we recommend [toml](https://crates.io/crates/toml) +* handle persistence: we recommend [diesel](https://diesel.rs/) +* handle templating: we recommend [maud](https://maud.lambda.xyz/) + +## CsrfToken example + +```rs +use std::convert::Infallible; +use hyper::service::{service_fn, make_service_fn}; +use hyper::{Method, Server}; +use serde::Deserialize; +use sputnik::security::CsrfToken; +use sputnik::{Request, Response, Error}; + +async fn route(req: &mut Request) -> Result<Response,Error> { + match (req.method(), req.uri().path()) { + (&Method::GET, "/form") => get_form(req).await, + (&Method::POST, "/form") => post_form(req).await, + _ => return Err(Error::not_found("page not found".to_owned())) + } +} + +async fn get_form(req: &mut Request) -> Result<Response, Error> { + let mut response = Response::new(); + let csrf_token = CsrfToken::from_request(req, &mut response); + *response.body() = format!("<form method=post> + <input name=text>{}<button>Submit</button></form>", csrf_token.html_input()).into(); + Ok(response) +} + +#[derive(Deserialize)] +struct FormData {text: String} + +async fn post_form(req: &mut Request) -> Result<Response, Error> { + let mut response = Response::new(); + let csrf_token = CsrfToken::from_request(req, &mut response); + let msg: FormData = req.into_form_csrf(&csrf_token).await?; + *response.body() = format!("hello {}", msg.text).into(); + Ok(response) +} + +/// adapt between Hyper's types and Sputnik's convenience types +async fn service(req: hyper::Request<hyper::Body>) -> Result<hyper::Response<hyper::Body>, Infallible> { + match route(&mut req.into()).await { + Ok(res) => Ok(res.into()), + Err(err) => Ok(err.response_builder().body(err.message.into()).unwrap()) + // you can easily wrap or log errors here + } +} + +#[tokio::main] +async fn main() { + let service = make_service_fn(move |_| { + async move { + Ok::<_, hyper::Error>(service_fn(move |req| { + service(req) + })) + } + }); + + let addr = ([127, 0, 0, 1], 8000).into(); + let server = Server::bind(&addr).serve(service); + println!("Listening on http://{}", addr); + server.await; +} +``` + +## Signed & expiring cookies + +After a successful authentication you can build a session id cookie for +example as follows: + +```rs +let expiry_date = OffsetDateTime::now_utc() + Duration::hours(24); +let mut cookie = Cookie::new("userid", + key.sign( + &encode_expiring_claim(&userid, expiry_date) + )); +cookie.set_secure(Some(true)); +cookie.set_expires(expiry_date); +cookie.set_same_site(SameSite::Lax); +resp.set_cookie(cookie); +``` + +This session id cookie can then be retrieved and verified as follows: + +```rs +let userid = req.cookies().get("userid") + .ok_or_else(|| Error::unauthorized("expected userid cookie".to_owned())) + .and_then(|cookie| key.verify(cookie.value()).map_err(Error::unauthorized)) + .and_then(|value| decode_expiring_claim(value).map_err(|e| Error::unauthorized(format!("failed to decode userid cookie: {}", e))))?; +``` + +Tip: If you want to store multiple claims in the cookie, you can +(de)serialize a struct with [serde_json](https://docs.serde.rs/serde_json/). +This approach can pose a lightweight alternative to JWT, if you don't care +about the standardization aspect.
\ No newline at end of file diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..d40ddf3 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,44 @@ +use std::fmt::Display; + +use hyper::StatusCode; + +/// Encapsulates a status code and an error message. +#[derive(Debug)] +pub struct Error { + pub code: StatusCode, + pub message: String, +} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "error: {}", self.message) + } +} + +impl std::error::Error for Error { +} + +impl Error { + /// Returns an HTTP response builder with the status code set to `self.code`. + pub fn response_builder(&self) -> hyper::http::response::Builder { + hyper::Response::builder().status(self.code) + } + + // some conventient constructors for common errors + + pub fn bad_request(message: String) -> Self { + Error{code: StatusCode::BAD_REQUEST, message} + } + pub fn not_found(message: String) -> Self { + Error{code: StatusCode::NOT_FOUND, message} + } + pub fn unauthorized(message: String) -> Self { + Error{code: StatusCode::UNAUTHORIZED, message} + } + pub fn internal(message: String) -> Self { + Error{code: StatusCode::INTERNAL_SERVER_ERROR, message} + } + pub fn method_not_allowed(message: String) -> Self { + Error{code: StatusCode::METHOD_NOT_ALLOWED, message} + } +}
\ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9ea1c0f --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,207 @@ +//! A lightweight layer on top of [Hyper](https://hyper.rs/) +//! to facilitate building web applications. +use std::collections::HashMap; + +pub use error::Error; +pub use mime; +use cookie::Cookie; +use header::HeaderName; +use mime::{APPLICATION_WWW_FORM_URLENCODED, Mime}; +use serde::{Deserialize, de::DeserializeOwned}; +use hyper::{Body, StatusCode, body::Bytes, header::{self, HeaderValue}, http::request::Parts}; +use time::{Duration, OffsetDateTime}; + +pub use httpdate; + +type HyperRequest = hyper::Request<Body>; +type HyperResponse = hyper::Response<Body>; + +pub mod security; +mod error; +mod signed; + +/// Convenience wrapper around [`hyper::Request`]. +pub struct Request { + body: Body, + parts: Parts, + cookies: Option<HashMap<String,Cookie<'static>>>, +} + +impl From<HyperRequest> for Request { + fn from(req: HyperRequest) -> Self { + let (parts, body) = req.into_parts(); + Request{parts, body, cookies: None} + } +} + +impl Into<HyperResponse> for Response { + fn into(self) -> HyperResponse { + self.res + } +} + +fn enforce_content_type(req: &Parts, mime: Mime) -> Result<(),Error> { + let received_type = req.headers.get(header::CONTENT_TYPE).ok_or(Error::bad_request(format!("expected content-type: {}", mime)))?; + if *received_type != mime.to_string() { + return Err(Error::bad_request(format!("expected content-type: {}", mime))) + } + Ok(()) +} + +#[derive(Deserialize)] +struct CsrfData { + csrf: String, +} + +impl Request { + pub fn cookies(&mut self) -> &HashMap<String,Cookie> { + if let Some(ref cookies) = self.cookies { + return cookies + } + let mut cookies = HashMap::new(); + for header in self.parts.headers.get_all(header::COOKIE) { + let raw_str = match std::str::from_utf8(header.as_bytes()) { + Ok(string) => string, + Err(_) => continue + }; + + for cookie_str in raw_str.split(';').map(|s| s.trim()) { + if let Ok(cookie) = Cookie::parse_encoded(cookie_str) { + cookies.insert(cookie.name().to_string(), cookie.into_owned()); + } + } + } + self.cookies = Some(cookies); + &self.cookies.as_ref().unwrap() + } + + pub fn method(&self) -> &hyper::Method { + &self.parts.method + } + + pub fn uri(&self) -> &hyper::Uri { + &self.parts.uri + } + + /// Parses a `application/x-www-form-urlencoded` request body into a given struct. + /// + /// This does make you vulnerable to CSRF so you normally want to use + /// [`parse_form_csrf()`] instead. + /// + /// # Example + /// + /// ``` + /// use hyper::{Response, Body}; + /// use sputnik::{Request, Error}; + /// use serde::Deserialize; + /// + /// #[derive(Deserialize)] + /// struct Message {text: String, year: i64} + /// + /// async fn greet(req: &mut Request) -> Result<Response<Body>, Error> { + /// let msg: Message = req.into_form().await?; + /// Ok(Response::new(format!("hello {}", msg.text).into())) + /// } + /// ``` + pub async fn into_form<T: DeserializeOwned>(&mut self) -> Result<T,Error> { + enforce_content_type(&self.parts, APPLICATION_WWW_FORM_URLENCODED)?; + let full_body = self.into_body().await?; + serde_urlencoded::from_bytes::<T>(&full_body).map_err(|e|Error::bad_request(e.to_string())) + } + + /// Parses a `application/x-www-form-urlencoded` request body into a given struct. + /// Protects from CSRF by checking that the request body contains the same token retrieved from the cookies. + /// + /// The CSRF parameter is expected as the `csrf` parameter in the request body. + /// This means for HTML forms you need to embed the token as a hidden input. + /// + /// # Example + /// + /// ``` + /// use hyper::{Method}; + /// use sputnik::{Request, Response, Error}; + /// use sputnik::security::CsrfToken; + /// use serde::Deserialize; + /// + /// #[derive(Deserialize)] + /// struct Message {text: String} + /// + /// async fn greet(req: &mut Request) -> Result<Response, Error> { + /// let mut response = Response::new(); + /// let csrf_token = CsrfToken::from_request(req, &mut response); + /// *response.body() = match (req.method()) { + /// &Method::GET => format!("<form method=post> + /// <input name=text>{}<button>Submit</button></form>", csrf_token.html_input()).into(), + /// &Method::POST => { + /// let msg: Message = req.into_form_csrf(&csrf_token).await?; + /// format!("hello {}", msg.text).into() + /// }, + /// _ => return Err(Error::method_not_allowed("only GET and POST allowed".to_owned())), + /// }; + /// Ok(response) + /// } + /// ``` + pub async fn into_form_csrf<T: DeserializeOwned>(&mut self, csrf_token: &security::CsrfToken) -> Result<T,Error> { + enforce_content_type(&self.parts, APPLICATION_WWW_FORM_URLENCODED)?; + let full_body = self.into_body().await?; + let csrf_data = serde_urlencoded::from_bytes::<CsrfData>(&full_body).map_err(|_|Error::bad_request("no csrf token".to_string()))?; + csrf_token.matches(csrf_data.csrf)?; + serde_urlencoded::from_bytes::<T>(&full_body).map_err(|e|Error::bad_request(e.to_string())) + } + + pub async fn into_body(&mut self) -> Result<Bytes,Error> { + hyper::body::to_bytes(&mut self.body).await.map_err(|_|Error::internal("failed to read body".to_string())) + } + + /// Parses the query string of the request into a given struct. + pub fn query<T: DeserializeOwned>(&self) -> Result<T,Error> { + serde_urlencoded::from_str::<T>(self.parts.uri.query().unwrap_or("")).map_err(|e|Error::bad_request(e.to_string())) + } +} + +/// Convenience wrapper around [`hyper::Response`]. +pub struct Response { + res: HyperResponse +} + +impl Response { + pub fn new() -> Self { + Response{res: HyperResponse::new(Body::empty())} + } + + pub fn status(&mut self) -> &mut StatusCode { + self.res.status_mut() + } + + pub fn body(&mut self) -> &mut Body { + self.res.body_mut() + } + + pub fn headers(&mut self) -> &mut hyper::HeaderMap<header::HeaderValue> { + self.res.headers_mut() + } + + pub fn set_header<S: AsRef<str>>(&mut self, header: HeaderName, value: S) { + self.res.headers_mut().insert(header, HeaderValue::from_str(value.as_ref()).unwrap()); + } + + pub fn set_content_type(&mut self, mime: mime::Mime) { + self.res.headers_mut().insert(header::CONTENT_TYPE, mime.to_string().parse().unwrap()); + } + + pub fn redirect<S: AsRef<str>>(&mut self, location: S, code: StatusCode) { + *self.res.status_mut() = code; + self.set_header(header::LOCATION, location); + } + + pub fn set_cookie(&mut self, cookie: Cookie) { + self.res.headers_mut().append(header::SET_COOKIE, cookie.encoded().to_string().parse().unwrap()); + } + + pub fn delete_cookie(&mut self, name: &str) { + let mut cookie = Cookie::new(name, ""); + cookie.set_max_age(Duration::seconds(0)); + cookie.set_expires(OffsetDateTime::now_utc() - Duration::days(365)); + self.set_cookie(cookie); + } +}
\ No newline at end of file diff --git a/src/security.rs b/src/security.rs new file mode 100644 index 0000000..fe9c26b --- /dev/null +++ b/src/security.rs @@ -0,0 +1,65 @@ +//! [`CsrfToken`], [`Key`] and functions to encode & decode expiring claims. + +use rand::{Rng, distributions::Alphanumeric}; +use ::cookie::Cookie; +use time::{Duration, OffsetDateTime}; + +pub use crate::signed::Key; + +use crate::{Error, Request, Response}; + +/// A cookie-based CSRF token to be used with [`crate::Request::into_form_csrf`]. +pub struct CsrfToken { + token: String, + from_client: bool, +} + +impl CsrfToken { + /// Retrieves the CSRF token from a `csrf` cookie or generates + /// a new token and stores it as a cookie if it doesn't exist. + pub fn from_request(request: &mut Request, response: &mut Response) -> Self { + if let Some(cookie) = request.cookies().get("csrf") { + return CsrfToken{token: cookie.value().to_string(), from_client: true} + } + let val: String = rand::thread_rng().sample_iter(Alphanumeric).take(16).collect(); + let mut c = Cookie::new("csrf", val.clone()); + c.set_secure(Some(true)); + c.set_max_age(Some(Duration::hours(1))); + response.set_cookie(c); + CsrfToken{token: val, from_client: false} + } + + /// Wraps the token in a hidden HTML input. + pub fn html_input(&self) -> String { + format!("<input name=csrf type=hidden value=\"{}\">", self.token) + } + + pub(crate) fn matches(&self, str: String) -> Result<(), Error> { + if !self.from_client { + return Err(Error::bad_request("expected csrf cookie".to_string())) + } + if self.token != str { + return Err(Error::bad_request("csrf parameter doesn't match csrf cookie".to_string())) + } + Ok(()) + } +} + +/// Join a string and an expiry date together into a string. +pub fn encode_expiring_claim(claim: &str, expiry_date: OffsetDateTime) -> String { + format!("{}:{}", claim, expiry_date.unix_timestamp()) +} + +/// Extract the string, failing if the expiry date is in the past. +pub fn decode_expiring_claim(value: String) -> Result<String,&'static str> { + let mut parts = value.splitn(2, ':'); + let claim = parts.next().ok_or("expected colon")?; + let expiry_date = parts.next().ok_or("expected colon")?; + let expiry_date: i64 = expiry_date.parse().map_err(|_| "failed to parse timestamp")?; + + if expiry_date > OffsetDateTime::now_utc().unix_timestamp() { + Ok(claim.to_string()) + } else { + Err("token is expired") + } +}
\ No newline at end of file diff --git a/src/signed.rs b/src/signed.rs new file mode 100644 index 0000000..9f0d184 --- /dev/null +++ b/src/signed.rs @@ -0,0 +1,72 @@ +// This code was adapted from the cookie crate which does not make the sign and verify functions public +// forcing the use of CookieJars, which are akward to work with without a high-level framework. +// Thanks to Sergio Benitez for writing the original code and releasing it under MIT! + +use hmac::{Hmac,NewMac,Mac}; +use sha2::Sha256; + +const SIGNED_KEY_LEN: usize = 32; +const BASE64_DIGEST_LEN: usize = 44; + +/// A convenience wrapper around HMAC. +pub struct Key (pub [u8; SIGNED_KEY_LEN]); + +impl Key { + const fn zero() -> Self { + Key ( [0; SIGNED_KEY_LEN]) + } + + /// Attempts to generate signing/encryption keys from a secure, random + /// source. Keys are generated nondeterministically. If randomness cannot be + /// retrieved from the underlying operating system, returns `None`. + pub fn try_generate() -> Option<Key> { + use rand::RngCore; + + let mut rng = rand::thread_rng(); + let mut both_keys = [0; SIGNED_KEY_LEN]; + rng.try_fill_bytes(&mut both_keys).ok()?; + Some(Key::from(&both_keys)) + } + + /// Creates a new Key from a 32 byte cryptographically random string. + pub fn from(key: &[u8]) -> Key { + if key.len() < SIGNED_KEY_LEN { + panic!("bad key length: expected >= 32 bytes, found {}", key.len()); + } + + let mut output = Key::zero(); + output.0.copy_from_slice(&key[..SIGNED_KEY_LEN]); + output + } + + /// Signs the value providing integrity and authenticity. + pub fn sign(&self, value: &str) -> String { + // Compute HMAC-SHA256 of the cookie's value. + let mut mac = Hmac::<Sha256>::new_varkey(&self.0).expect("good key"); + mac.update(value.as_bytes()); + + // Cookie's new value is [MAC | original-value]. + let mut new_value = base64::encode(&mac.finalize().into_bytes()); + new_value.push_str(value); + new_value + } + + /// Extracts the value from a string signed with [`Key::sign`]. + /// Fails if the string is malformed or the signature doesn't match. + pub fn verify(&self, value: &str) -> Result<String, String> { + if value.len() < BASE64_DIGEST_LEN { + return Err("length of value is <= BASE64_DIGEST_LEN".to_string()); + } + + // Split [MAC | original-value] into its two parts. + let (digest_str, value) = value.split_at(BASE64_DIGEST_LEN); + let digest = base64::decode(digest_str).map_err(|_| "bad base64 digest")?; + + // Perform the verification. + let mut mac = Hmac::<Sha256>::new_varkey(&self.0).expect("good key"); + mac.update(value.as_bytes()); + mac.verify(&digest) + .map(|_| value.to_string()) + .map_err(|_| "value did not verify".to_string()) + } +}
\ No newline at end of file |