aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Fischer <martin@push-f.com>2021-01-17 19:06:58 +0100
committerMartin Fischer <martin@push-f.com>2021-01-17 20:02:05 +0100
commit61efc35b0a9c62c18a7a98419bf2ddbc0a52a42d (patch)
treeff0ef6604da7b19c0bee5785e93df4844fcc9c17
publishv0.1.0
-rw-r--r--.gitignore4
-rw-r--r--Cargo.toml25
-rw-r--r--README.md119
-rw-r--r--src/error.rs44
-rw-r--r--src/lib.rs207
-rw-r--r--src/security.rs65
-rw-r--r--src/signed.rs72
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