diff options
Diffstat (limited to 'src')
-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 |
4 files changed, 388 insertions, 0 deletions
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 |