diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | Cargo.toml | 2 | ||||
-rw-r--r-- | README.md | 19 | ||||
-rw-r--r-- | examples/csrf/Cargo.toml | 14 | ||||
-rw-r--r-- | examples/csrf/src/main.rs | 59 | ||||
-rw-r--r-- | src/lib.rs | 202 | ||||
-rw-r--r-- | src/request.rs | 159 | ||||
-rw-r--r-- | src/response.rs | 60 | ||||
-rw-r--r-- | src/security.rs | 6 |
9 files changed, 310 insertions, 213 deletions
@@ -1,4 +1,4 @@ -/target +target/ # See https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html Cargo.lock @@ -1,6 +1,6 @@ [package] name = "sputnik" -version = "0.1.1" +version = "0.2.0" authors = ["Martin Fischer <martin@push-f.com>"] license = "MIT" description = "A lightweight layer on top of hyper to facilitate building web applications." @@ -32,19 +32,19 @@ use hyper::service::{service_fn, make_service_fn}; use hyper::{Method, Server}; use serde::Deserialize; use sputnik::security::CsrfToken; -use sputnik::{Request, Response, Error}; +use sputnik::{Error, request::{Parts, Body}, response::Response}; -async fn route(req: &mut Request) -> Result<Response,Error> { +async fn route(req: &mut Parts, body: Body) -> Result<Response,Error> { match (req.method(), req.uri().path()) { (&Method::GET, "/form") => get_form(req).await, - (&Method::POST, "/form") => post_form(req).await, + (&Method::POST, "/form") => post_form(req, body).await, _ => return Err(Error::not_found("page not found".to_owned())) } } -async fn get_form(req: &mut Request) -> Result<Response, Error> { +async fn get_form(req: &mut Parts) -> Result<Response, Error> { let mut response = Response::new(); - let csrf_token = CsrfToken::from_request(req, &mut response); + let csrf_token = CsrfToken::from_parts(req, &mut response); *response.body() = format!("<form method=post> <input name=text>{}<button>Submit</button></form>", csrf_token.html_input()).into(); Ok(response) @@ -53,17 +53,18 @@ async fn get_form(req: &mut Request) -> Result<Response, Error> { #[derive(Deserialize)] struct FormData {text: String} -async fn post_form(req: &mut Request) -> Result<Response, Error> { +async fn post_form(req: &mut Parts, body: Body) -> 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?; + let csrf_token = CsrfToken::from_parts(req, &mut response); + let msg: FormData = body.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 { + let (mut parts, body) = sputnik::request::adapt(req); + match route(&mut parts, body).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 diff --git a/examples/csrf/Cargo.toml b/examples/csrf/Cargo.toml new file mode 100644 index 0000000..1f0066b --- /dev/null +++ b/examples/csrf/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "csrf" +version = "0.1.0" +authors = ["Martin Fischer <martin@push-f.com>"] +edition = "2018" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +hyper = "0.13" +sputnik = {path = "../../"} +serde = { version = "1.0", features = ["derive"] } +tokio = { version = "0.2", features = ["full"] }
\ No newline at end of file diff --git a/examples/csrf/src/main.rs b/examples/csrf/src/main.rs new file mode 100644 index 0000000..16b31a1 --- /dev/null +++ b/examples/csrf/src/main.rs @@ -0,0 +1,59 @@ +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::{Error, request::{Parts, Body}, response::Response}; + +async fn route(req: &mut Parts, body: Body) -> Result<Response,Error> { + match (req.method(), req.uri().path()) { + (&Method::GET, "/form") => get_form(req).await, + (&Method::POST, "/form") => post_form(req, body).await, + _ => return Err(Error::not_found("page not found".to_owned())) + } +} + +async fn get_form(req: &mut Parts) -> Result<Response, Error> { + let mut response = Response::new(); + let csrf_token = CsrfToken::from_parts(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 Parts, body: Body) -> Result<Response, Error> { + let mut response = Response::new(); + let csrf_token = CsrfToken::from_parts(req, &mut response); + let msg: FormData = body.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> { + let (mut parts, body) = sputnik::request::adapt(req); + match route(&mut parts, body).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; +}
\ No newline at end of file @@ -1,207 +1,11 @@ //! 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; +pub mod request; +pub mod response; 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 +mod signed;
\ No newline at end of file diff --git a/src/request.rs b/src/request.rs new file mode 100644 index 0000000..0c25e67 --- /dev/null +++ b/src/request.rs @@ -0,0 +1,159 @@ +//! Provides the [`Parts`] and [`Body`] convenience wrappers. + +use cookie::Cookie; +use header::CONTENT_TYPE; +use mime::{APPLICATION_WWW_FORM_URLENCODED, Mime}; +use serde::{Deserialize, de::DeserializeOwned}; +use hyper::{body::Bytes, header}; +use hyper::http::request::Parts as ReqParts; +use std::collections::HashMap; + +use crate::{Error, security}; + +type HyperRequest = hyper::Request<hyper::Body>; + +/// Convenience wrapper around [`hyper::Body`]. +pub struct Body { + body: hyper::Body, + content_type: Option<header::HeaderValue>, +} + +/// Convert [`hyper::Request`] to ([`Parts`], [`Body`]) +pub fn adapt<'a>(req: HyperRequest) -> (Parts, Body) { + let (parts, body) = req.into_parts(); + let body = Body{body, content_type: parts.headers.get(CONTENT_TYPE).map(|x| x.to_owned())}; + let parts = Parts{parts, cookies: None}; + (parts, body) +} + +/// Convenience wrapper around [`hyper::http::request::Parts`]. +pub struct Parts { + parts: ReqParts, + cookies: Option<HashMap<String,Cookie<'static>>>, +} + +#[derive(Deserialize)] +struct CsrfData { + csrf: String, +} + +impl Parts { + 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 headers(&self) -> &hyper::HeaderMap<header::HeaderValue> { + &self.parts.headers + } + + pub fn uri(&self) -> &hyper::Uri { + &self.parts.uri + } + + /// 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())) + } +} + +impl Body { + pub async fn into_bytes(self) -> Result<Bytes,Error> { + hyper::body::to_bytes(self.body).await.map_err(|_|Error::internal("failed to read body".to_string())) + } + + /// 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}; + /// use sputnik::{request::Body, Error}; + /// use serde::Deserialize; + /// + /// #[derive(Deserialize)] + /// struct Message {text: String, year: i64} + /// + /// async fn greet(body: Body) -> Result<Response<hyper::Body>, Error> { + /// let msg: Message = body.into_form().await?; + /// Ok(Response::new(format!("hello {}", msg.text).into())) + /// } + /// ``` + pub async fn into_form<T: DeserializeOwned>(self) -> Result<T,Error> { + self.enforce_content_type(APPLICATION_WWW_FORM_URLENCODED)?; + let full_body = self.into_bytes().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::{Parts, Body}, response::Response, Error}; + /// use sputnik::security::CsrfToken; + /// use serde::Deserialize; + /// + /// #[derive(Deserialize)] + /// struct Message {text: String} + /// + /// async fn greet(req: &mut Parts, body: Body) -> Result<Response, Error> { + /// let mut response = Response::new(); + /// let csrf_token = CsrfToken::from_parts(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 = body.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>(self, csrf_token: &security::CsrfToken) -> Result<T,Error> { + self.enforce_content_type(APPLICATION_WWW_FORM_URLENCODED)?; + let full_body = self.into_bytes().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())) + } + + fn enforce_content_type(&self, mime: Mime) -> Result<(),Error> { + if let Some(content_type) = &self.content_type { + if *content_type == mime.to_string() { + return Ok(()) + } + } + Err(Error::bad_request(format!("expected content-type: {}", mime))) + } +}
\ No newline at end of file diff --git a/src/response.rs b/src/response.rs new file mode 100644 index 0000000..39e6c98 --- /dev/null +++ b/src/response.rs @@ -0,0 +1,60 @@ +//! Provides the [`Response`] convenience wrapper. + +use cookie::Cookie; +use hyper::{StatusCode, header::{self, HeaderName, HeaderValue}}; +use time::{Duration, OffsetDateTime}; + +type HyperResponse = hyper::Response<hyper::Body>; + +/// Convenience wrapper around [`hyper::Response`]. +pub struct Response { + res: HyperResponse +} + +impl Into<HyperResponse> for Response { + fn into(self) -> HyperResponse { + self.res + } +} + +impl Response { + pub fn new() -> Self { + Response{res: HyperResponse::new(hyper::Body::empty())} + } + + pub fn status(&mut self) -> &mut StatusCode { + self.res.status_mut() + } + + pub fn body(&mut self) -> &mut hyper::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 index fe9c26b..4a17fe3 100644 --- a/src/security.rs +++ b/src/security.rs @@ -6,9 +6,9 @@ use time::{Duration, OffsetDateTime}; pub use crate::signed::Key; -use crate::{Error, Request, Response}; +use crate::{Error, request::Parts, response::Response}; -/// A cookie-based CSRF token to be used with [`crate::Request::into_form_csrf`]. +/// A cookie-based CSRF token to be used with [`crate::request::Body::into_form_csrf`]. pub struct CsrfToken { token: String, from_client: bool, @@ -17,7 +17,7 @@ pub struct CsrfToken { 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 { + pub fn from_parts(request: &mut Parts, response: &mut Response) -> Self { if let Some(cookie) = request.cookies().get("csrf") { return CsrfToken{token: cookie.value().to_string(), from_client: true} } |