diff --git a/Cargo.lock b/Cargo.lock index 1f0f72c..114168f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1242,7 +1242,7 @@ checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" [[package]] name = "webhookey" -version = "0.1.0-rc.2" +version = "0.1.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 6506c0e..f123f85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "webhookey" -version = "0.1.0-rc.2" +version = "0.1.0" authors = ["finga "] edition = "2018" license = "GPL-3.0-or-later" diff --git a/README.md b/README.md index ccd79b8..4bad877 100644 --- a/README.md +++ b/README.md @@ -67,12 +67,12 @@ Whereas `` depends on the platform: #### Hooks With `hooks` you can configure a sequence of hooks. A single hook consists of the following fields: -- command: A command to be executed if a filter matches -- allow/deny: An optional parameter to either allow or deny specific - source addresses or ranges. -- signature: Name of the HTTP header field containing the signature. -- secrets: List of secrets. -- filter: Tree of filters. +- `command`: A command to be executed if a filter matches +- `allow`/`deny`: An optional parameter to either allow or deny + specific source addresses or ranges. +- `signature`: Name of the HTTP header field containing the signature. +- `secrets`: List of secrets. +- `filter`: Tree of filters. Example: ```yaml @@ -163,7 +163,7 @@ Conjunction filters contain lists of other filters. The `json` filter matches a regular expression on a field from the received JSON data. - - pointer: Pointer to the JSON field according to [RFC + - `pointer`: Pointer to the JSON field according to [RFC 6901](https://tools.ietf.org/html/rfc6901). - - regex: Regular expression which has to match the field pointed to - by the pointer. + - `regex`: Regular expression which has to match the field pointed + to by the pointer. diff --git a/src/main.rs b/src/main.rs index 3494d91..c352620 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,26 @@ use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, }; +#[derive(Debug, Error)] +enum WebhookeyError { + #[error("Could not extract signature from header")] + InvalidSignature, + #[error("Unauthorized request from `{0}`")] + Unauthorized(IpAddr), + #[error("Unmatched hook from `{0}`")] + UnmatchedHook(IpAddr), + #[error("Could not find field refered to in parameter `{0}`")] + InvalidParameterPointer(String), + #[error("Could not evaluate filter request")] + InvalidFilter, + #[error("IO Error")] + Io(std::io::Error), + #[error("Serde Error")] + Serde(serde_json::Error), + #[error("Regex Error")] + Regex(regex::Error), +} + #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields, untagged)] enum AddrType { @@ -66,12 +86,6 @@ impl IpFilter { } } -#[derive(Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct Config { - hooks: HashMap, -} - #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct JsonFilter { @@ -171,24 +185,27 @@ struct Hook { filter: FilterType, } -#[derive(Debug, Error)] -enum WebhookeyError { - #[error("Could not extract signature from header")] - InvalidSignature, - #[error("Unauthorized request from `{0}`")] - Unauthorized(IpAddr), - #[error("Unmatched hook from `{0}`")] - UnmatchedHook(IpAddr), - #[error("Could not find field refered to in parameter `{0}`")] - InvalidParameterPointer(String), - #[error("Could not evaluate filter request")] - InvalidFilter, - #[error("IO Error")] - Io(std::io::Error), - #[error("Serde Error")] - Serde(serde_json::Error), - #[error("Regex Error")] - Regex(regex::Error), +impl Hook { + fn get_command( + &self, + hook_name: &str, + request: &Request, + data: &mut serde_json::Value, + ) -> Result { + trace!("Replacing parameters for command of hook `{}`", hook_name); + + for parameter in get_parameter(&self.command)? { + let parameter = parameter.trim(); + + if let Some(json_value) = data.pointer(parameter) { + *data.pointer_mut(parameter).ok_or_else(|| { + WebhookeyError::InvalidParameterPointer(parameter.to_string()) + })? = serde_json::Value::String(get_string(json_value)?); + } + } + + replace_parameters(&self.command, &request.headers(), data) + } } #[derive(Debug)] @@ -196,27 +213,128 @@ struct Hooks { inner: HashMap, } -fn accept_ip(hook_name: &str, client_ip: &IpAddr, ip_filter: &Option) -> bool { - match ip_filter { - Some(filter) => { - if filter.validate(client_ip) { - info!("Allow hook `{}` from {}", &hook_name, &client_ip); - true +impl Hooks { + fn get_commands(request: &Request, data: Data) -> Result { + let mut buffer = Vec::new(); + let size = data + .open() + .read_to_end(&mut buffer) + .map_err(WebhookeyError::Io)?; + info!("Data of size {} received", size); + + let config = request.guard::>().unwrap(); // should never fail + let mut valid = false; + let mut result = HashMap::new(); + let client_ip = &request + .client_ip() + .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + + let hooks = config.hooks.iter().filter(|(name, hook)| { + if let Some(ip) = &hook.ip_filter { + return accept_ip(&name, &client_ip, &ip); } else { - warn!("Deny hook `{}` from {}", &hook_name, &client_ip); - false + info!( + "Allow hook `{}` from {}, no IP filter was configured", + &name, &client_ip + ); + true + } + }); + + for (hook_name, hook) in hooks { + let signature = request + .headers() + .get_one(&hook.signature) + .ok_or(WebhookeyError::InvalidSignature)?; + + let secrets = hook + .secrets + .iter() + .map(|secret| validate_request(&secret, &signature, &buffer)); + + for secret in secrets { + match secret { + Ok(()) => { + trace!("Valid signature found for hook `{}`", hook_name); + + valid = true; + + let mut data: serde_json::Value = + serde_json::from_slice(&buffer).map_err(WebhookeyError::Serde)?; + + match hook.filter.evaluate(request, &data) { + Ok(true) => match hook.get_command(&hook_name, &request, &mut data) { + Ok(command) => { + info!("Filter for `{}` matched", &hook_name); + result.insert(hook_name.to_string(), command); + break; + } + Err(e) => error!("{}", e), + }, + Ok(false) => info!("Filter for `{}` did not match", &hook_name), + Err(error) => { + error!("Could not match filter for `{}`: {}", &hook_name, error) + } + } + } + Err(e) => trace!("Hook `{}` could not validate request: {}", &hook_name, e), + } } } - None => { - info!( - "Allow hook `{}` from {}, no IP filter was configured", - &hook_name, &client_ip - ); - true + + if !valid { + return Err(WebhookeyError::Unauthorized(*client_ip)); + } + + Ok(Hooks { inner: result }) + } +} + +impl FromDataSimple for Hooks { + type Error = WebhookeyError; + + fn from_data(request: &Request, data: Data) -> data::Outcome { + match Hooks::get_commands(&request, data) { + Ok(hooks) => { + if hooks.inner.is_empty() { + let client_ip = &request + .client_ip() + .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + + warn!("Unmatched hook from {}", &client_ip); + return Failure((Status::NotFound, WebhookeyError::UnmatchedHook(*client_ip))); + } + + Success(hooks) + } + Err(WebhookeyError::Unauthorized(e)) => { + error!("{}", WebhookeyError::Unauthorized(e)); + Failure((Status::Unauthorized, WebhookeyError::Unauthorized(e))) + } + Err(e) => { + error!("{}", e); + Failure((Status::BadRequest, e)) + } } } } +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Config { + hooks: HashMap, +} + +fn accept_ip(hook_name: &str, client_ip: &IpAddr, ip: &IpFilter) -> bool { + if ip.validate(client_ip) { + info!("Allow hook `{}` from {}", &hook_name, &client_ip); + return true; + } + + warn!("Deny hook `{}` from {}", &hook_name, &client_ip); + false +} + fn validate_request(secret: &str, signature: &str, data: &[u8]) -> Result<()> { let mut mac = Hmac::::new_varkey(&secret.as_bytes()) .map_err(|e| anyhow!("Could not create hasher with secret: {}", e))?; @@ -299,124 +417,6 @@ fn get_string(value: &serde_json::Value) -> Result { } } -fn get_command( - hook_name: &str, - hook: &Hook, - request: &Request, - data: &mut serde_json::Value, -) -> Result { - trace!("Replacing parameters for command of hook `{}`", hook_name); - - for parameter in get_parameter(&hook.command)? { - let parameter = parameter.trim(); - - if let Some(json_value) = data.pointer(parameter) { - *data - .pointer_mut(parameter) - .ok_or_else(|| WebhookeyError::InvalidParameterPointer(parameter.to_string()))? = - serde_json::Value::String(get_string(json_value)?); - } - } - - replace_parameters(&hook.command, &request.headers(), data) -} - -fn get_commands(request: &Request, data: Data) -> Result { - let mut buffer = Vec::new(); - let size = data - .open() - .read_to_end(&mut buffer) - .map_err(WebhookeyError::Io)?; - info!("Data of size {} received", size); - - let config = request.guard::>().unwrap(); // should never fail - let mut valid = false; - let mut result = HashMap::new(); - let client_ip = &request - .client_ip() - .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); - - let hooks = config - .hooks - .iter() - .filter(|(name, hook)| accept_ip(&name, &client_ip, &hook.ip_filter)); - - for (hook_name, hook) in hooks { - let signature = request - .headers() - .get_one(&hook.signature) - .ok_or(WebhookeyError::InvalidSignature)?; - - let secrets = hook - .secrets - .iter() - .map(|secret| validate_request(&secret, &signature, &buffer)); - - for secret in secrets { - match secret { - Ok(()) => { - trace!("Valid signature found for hook `{}`", hook_name); - - valid = true; - - let mut data: serde_json::Value = - serde_json::from_slice(&buffer).map_err(WebhookeyError::Serde)?; - - match hook.filter.evaluate(request, &data) { - Ok(true) => match get_command(&hook_name, &hook, &request, &mut data) { - Ok(command) => { - result.insert(hook_name.to_string(), command); - break; - } - Err(e) => error!("{}", e), - }, - Ok(false) => info!("Filter for `{}` did not match", &hook_name), - Err(error) => { - error!("Could not match filter for `{}`: {}", &hook_name, error) - } - } - } - Err(e) => trace!("Hook `{}` could not validate request: {}", &hook_name, e), - } - } - } - - if !valid { - return Err(WebhookeyError::Unauthorized(*client_ip)); - } - - Ok(Hooks { inner: result }) -} - -impl FromDataSimple for Hooks { - type Error = WebhookeyError; - - fn from_data(request: &Request, data: Data) -> data::Outcome { - match get_commands(&request, data) { - Ok(hooks) => { - if hooks.inner.is_empty() { - let client_ip = &request - .client_ip() - .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); - - warn!("Unmatched hook from {}", &client_ip); - return Failure((Status::NotFound, WebhookeyError::UnmatchedHook(*client_ip))); - } - - Success(hooks) - } - Err(WebhookeyError::Unauthorized(e)) => { - error!("{}", WebhookeyError::Unauthorized(e)); - Failure((Status::Unauthorized, WebhookeyError::Unauthorized(e))) - } - Err(e) => { - error!("{}", e); - Failure((Status::BadRequest, e)) - } - } - } -} - #[post("/", format = "json", data = "")] fn receive_hook<'a>(address: SocketAddr, hooks: Hooks) -> Result> { info!("Post request received from: {}", address); @@ -486,7 +486,7 @@ fn main() -> Result<()> { ) .subcommand( App::new("configtest") - .about("Verifies if the configuration can be parsed without errors."), + .about("Verifies if the configuration can be parsed without errors"), ) .get_matches(); @@ -500,7 +500,7 @@ fn main() -> Result<()> { if let Some(_) = cli.subcommand_matches("configtest") { debug!("Configtest succeded."); println!("Config is OK"); - return Ok(()) + return Ok(()); } rocket::ignite()