#![feature(proc_macro_hygiene, decl_macro)] use anyhow::{anyhow, bail, Result}; use clap::{crate_authors, crate_version, AppSettings, Clap}; use hmac::{Hmac, Mac, NewMac}; use ipnet::IpNet; use log::{debug, error, info, trace, warn}; use nom::{ branch::alt, bytes::complete::{tag, take_until}, combinator::map_res, multi::many0, sequence::delimited, Finish, IResult, }; use regex::Regex; use rocket::{ data::{self, FromDataSimple}, fairing::AdHoc, http::{HeaderMap, Status}, post, routes, Data, Outcome::{Failure, Success}, Request, Response, State, }; use run_script::ScriptOptions; use serde::{Deserialize, Serialize}; use sha2::Sha256; use thiserror::Error; use std::{ collections::HashMap, fs::File, io::{BufReader, Read}, 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(Clap, Debug)] enum Command { /// Verifies if the configuration can be parsed without errors Configtest, } #[derive(Clap, Debug)] #[clap( version = crate_version!(), author = crate_authors!(", "), global_setting = AppSettings::InferSubcommands, global_setting = AppSettings::PropagateVersion, )] struct Opts { /// Provide a path to the configuration file #[clap(short, long, value_name = "FILE")] config: Option, #[clap(subcommand)] command: Option, } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields, untagged)] enum AddrType { IpAddr(IpAddr), IpNet(IpNet), } impl AddrType { fn matches(&self, client_ip: &IpAddr) -> bool { match self { AddrType::IpAddr(addr) => addr == client_ip, AddrType::IpNet(net) => net.contains(client_ip), } } } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields, rename_all = "lowercase")] enum IpFilter { Allow(Vec), Deny(Vec), } impl IpFilter { fn validate(&self, client_ip: &IpAddr) -> bool { match self { IpFilter::Allow(list) => list.iter().any(|i| i.matches(client_ip)), IpFilter::Deny(list) => !list.iter().any(|i| i.matches(client_ip)), } } } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct Config { hooks: HashMap, } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct JsonFilter { pointer: String, regex: String, } impl JsonFilter { fn evaluate(&self, data: &serde_json::Value) -> Result { trace!( "Matching `{}` on `{}` from received json", &self.regex, &self.pointer, ); let regex = Regex::new(&self.regex).map_err(WebhookeyError::Regex)?; if let Some(value) = data.pointer(&self.pointer) { let value = get_string(value)?; if regex.is_match(&value) { debug!("Regex `{}` for `{}` matches", &self.regex, &self.pointer); return Ok(true); } } debug!( "Regex `{}` for `{}` does not match", &self.regex, &self.pointer ); Ok(false) } } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields, rename_all = "lowercase")] enum FilterType { And(Vec), Or(Vec), #[serde(rename = "json")] JsonFilter(JsonFilter), } impl FilterType { fn evaluate( &self, request: &Request, data: &serde_json::Value, ) -> Result { match self { FilterType::And(filters) => { let (results, errors): (Vec<_>, Vec<_>) = filters .iter() .map(|filter| filter.evaluate(request, data)) .partition(Result::is_ok); if errors.is_empty() { Ok(results.iter().all(|r| *r.as_ref().unwrap())) // should never fail } else { errors.iter().for_each(|e| { error!("Could not evaluate Filter: {}", e.as_ref().unwrap_err()) }); Err(WebhookeyError::InvalidFilter) } } FilterType::Or(filters) => { let (results, errors): (Vec<_>, Vec<_>) = filters .iter() .map(|filter| filter.evaluate(request, data)) .partition(Result::is_ok); if errors.is_empty() { Ok(results.iter().any(|r| *r.as_ref().unwrap())) // should never fail } else { errors.iter().for_each(|e| { error!("Could not evaluate Filter: {}", e.as_ref().unwrap_err()) }); Err(WebhookeyError::InvalidFilter) } } FilterType::JsonFilter(filter) => filter.evaluate(data), } } } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct Hook { command: String, signature: String, ip_filter: Option, secrets: Vec, filter: FilterType, } 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)] struct Hooks { inner: HashMap, } 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 { accept_ip(name, client_ip, ip) } else { 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), } } } 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)) } } } } 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))?; mac.update(data); let raw_signature = hex::decode(signature.as_bytes())?; mac.verify(&raw_signature).map_err(|e| anyhow!("{}", e)) } fn get_parameter(input: &str) -> Result> { let parse: IResult<&str, Vec<&str>> = many0(alt(( delimited(tag("{{"), take_until("}}"), tag("}}")), take_until("{{"), )))(input); let (_last, result) = parse .finish() .map_err(|e| anyhow!("Could not get parameters from command: {}", e))?; Ok(result) } fn get_header_field<'a>(headers: &'a HeaderMap, param: &[&str]) -> Result<&'a str> { headers .get_one( param .get(1) .ok_or_else(|| anyhow!("Missing parameter for `header` expression"))?, ) .ok_or_else(|| anyhow!("Could not extract event parameter from header")) } fn get_value_from_pointer<'a>(data: &'a serde_json::Value, pointer: &'a str) -> Result<&'a str> { let value = data .pointer(pointer) .ok_or_else(|| anyhow!("Could not get field from pointer {}", pointer))?; value .as_str() .ok_or_else(|| anyhow!("Could not convert value `{}` to string", value)) } fn replace_parameters( input: &str, headers: &HeaderMap, data: &serde_json::Value, ) -> Result { let parse: IResult<&str, Vec<&str>> = many0(alt(( map_res( delimited(tag("{{"), take_until("}}"), tag("}}")), |param: &str| { let expr = param.trim().split(' ').collect::>(); match expr.get(0) { Some(&"header") => get_header_field(headers, &expr), Some(pointer) => get_value_from_pointer(data, pointer), None => bail!("Missing expression in `{}`", input), } }, ), take_until("{{"), )))(input); let (last, mut result) = parse .finish() .map_err(|e| anyhow!("Could not parse command: {}", e))?; result.push(last); Ok(result.join("")) } fn get_string(value: &serde_json::Value) -> Result { match &value { serde_json::Value::Bool(bool) => Ok(bool.to_string()), serde_json::Value::Number(number) => Ok(number.to_string()), serde_json::Value::String(string) => Ok(string.as_str().to_string()), x => { error!("Could not get string from: {:?}", x); unimplemented!() } } } #[post("/", format = "json", data = "")] fn receive_hook<'a>(address: SocketAddr, hooks: Hooks) -> Result> { info!("Post request received from: {}", address); hooks.inner.iter().for_each(|(name, command)| { info!("Execute `{}` from hook `{}`", &command, &name); match run_script::run(command, &vec![], &ScriptOptions::new()) { Ok((status, stdout, stderr)) => { info!("Command `{}` exited with return code: {}", &command, status); trace!("Output of command `{}` on stdout: {:?}", &command, &stdout); debug!("Output of command `{}` on stderr: {:?}", &command, &stderr); } Err(e) => { error!("Execution of `{}` failed: {}", &command, e); } } }); Ok(Response::new()) } fn get_config() -> Result { // Look for config in CWD.. if let Ok(config) = File::open("config.yml") { info!("Loading configuration from `./config.yml`"); return Ok(config); } // ..look for user path config.. if let Some(mut path) = dirs::config_dir() { path.push("webhookey/config.yml"); if let Ok(config) = File::open(&path) { info!( "Loading configuration from `{}`", path.to_str().unwrap_or(""), ); return Ok(config); } } // ..look for systemwide config.. if let Ok(config) = File::open("/etc/webhookey/config.yml") { info!("Loading configuration from `/etc/webhookey/config.yml`"); return Ok(config); } // ..you had your chance. bail!("No configuration file found."); } fn main() -> Result<()> { env_logger::init(); let cli: Opts = Opts::parse(); let config: Config = match cli.config { Some(config) => serde_yaml::from_reader(BufReader::new(File::open(config)?))?, _ => serde_yaml::from_reader(BufReader::new(get_config()?))?, }; trace!("Parsed configuration:\n{}", serde_yaml::to_string(&config)?); if cli.command.is_some() { debug!("Configtest succeded."); println!("Config is OK"); return Ok(()); } rocket::ignite() .mount("/", routes![receive_hook]) .attach(AdHoc::on_attach("webhookey config", move |rocket| { Ok(rocket.manage(config)) })) .launch(); Ok(()) } #[cfg(test)] mod tests { use super::*; use rocket::{ http::{ContentType, Header}, local::Client, }; use serde_json::json; #[test] fn secret() { let mut hooks = HashMap::new(); hooks.insert( "test_hook".to_string(), Hook { command: "".to_string(), signature: "X-Gitea-Signature".to_string(), ip_filter: None, secrets: vec!["valid".to_string()], filter: FilterType::JsonFilter(JsonFilter { pointer: "*".to_string(), regex: "*".to_string(), }), }, ); let config = Config { hooks: hooks }; let rocket = rocket::ignite() .mount("/", routes![receive_hook]) .attach(AdHoc::on_attach("webhookey config", move |rocket| { Ok(rocket.manage(config)) })); let client = Client::new(rocket).unwrap(); let response = client .post("/") .header(Header::new( "X-Gitea-Signature", "28175a0035f637f3cbb85afee9f9d319631580e7621cf790cd16ca063a2f820e", )) .header(ContentType::JSON) .remote("127.0.0.1:8000".parse().unwrap()) .body(&serde_json::to_string(&json!({ "foo": "bar" })).unwrap()) .dispatch(); assert_eq!(response.status(), Status::NotFound); let response = client .post("/") .header(Header::new("X-Gitea-Signature", "beef")) .header(ContentType::JSON) .remote("127.0.0.1:8000".parse().unwrap()) .body(&serde_json::to_string(&json!({ "foo": "bar" })).unwrap()) .dispatch(); assert_eq!(response.status(), Status::Unauthorized); let response = client .post("/") .header(Header::new( "X-Gitea-Signature", "c5c315d76318362ec129ca629b50b626bba09ad3d7ba4cc0f4c0afe4a90537a0", )) .header(ContentType::JSON) .remote("127.0.0.1:8000".parse().unwrap()) .body(r#"{ "not_secret": "invalid" "#) .dispatch(); assert_eq!(response.status(), Status::BadRequest); let response = client .post("/") .header(Header::new("X-Gitea-Signature", "foobar")) .header(ContentType::JSON) .remote("127.0.0.1:8000".parse().unwrap()) .dispatch(); assert_eq!(response.status(), Status::Unauthorized); } #[test] fn parse_command() { let mut map = HeaderMap::new(); map.add_raw("X-Gitea-Event", "something"); assert_eq!( replace_parameters("command", &map, &serde_json::Value::Null).unwrap(), "command" ); assert_eq!( replace_parameters(" command", &map, &serde_json::Value::Null).unwrap(), " command" ); assert_eq!( replace_parameters("command ", &map, &serde_json::Value::Null).unwrap(), "command " ); assert_eq!( replace_parameters(" command ", &map, &serde_json::Value::Null).unwrap(), " command " ); assert_eq!( replace_parameters("command command ", &map, &serde_json::Value::Null).unwrap(), "command command " ); assert_eq!( replace_parameters("{{ /foo }} command", &map, &json!({ "foo": "bar" })).unwrap(), "bar command" ); assert_eq!( replace_parameters(" command {{ /foo }} ", &map, &json!({ "foo": "bar" })).unwrap(), " command bar " ); assert_eq!( replace_parameters( "{{ /foo }} command{{/field1/foo}}", &map, &json!({ "foo": "bar", "field1": { "foo": "baz" } }) ) .unwrap(), "bar commandbaz" ); assert_eq!( replace_parameters(" command {{ /foo }} ", &map, &json!({ "foo": "bar" })).unwrap(), " command bar " ); assert_eq!( replace_parameters( " {{ /field1/foo }} command", &map, &json!({ "field1": { "foo": "bar" } }) ) .unwrap(), " bar command" ); assert_eq!( replace_parameters( " {{ header X-Gitea-Event }} command", &map, &json!({ "field1": { "foo": "bar" } }) ) .unwrap(), " something command" ); } }