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  | 
