#![feature(proc_macro_hygiene, decl_macro)] use anyhow::{anyhow, bail, Result}; use hmac::{Hmac, Mac, NewMac}; 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, get, http::{HeaderMap, Status}, post, routes, Data, Outcome::{Failure, Success}, Request, Response, State, }; use serde::{Deserialize, Serialize}; use sha2::Sha256; use std::{ collections::HashMap, fs::File, io::{BufReader, Read}, net::SocketAddr, process::Command, str::from_utf8, }; #[derive(Debug, Deserialize, Serialize)] struct Config { hooks: HashMap, } #[derive(Debug, Deserialize, Serialize)] struct Hook { command: String, signature: String, secrets: Vec, filters: HashMap, } #[derive(Debug, Deserialize, Serialize)] struct Filter { pointer: String, regex: String, } #[derive(Debug)] struct Hooks(HashMap); 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 replace_parameter(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") => { if let Some(field) = expr.get(1) { match headers.get_one(field) { Some(value) => Ok(value), _ => bail!("Could not extract event parameter from header"), } } else { bail!("Missing parameter for `header` expression"); } } Some(pointer) => match data.pointer(pointer) { Some(value) => match value.as_str() { Some(value) => Ok(value), _ => bail!("Could not convert value `{}` to string", value), }, _ => bail!("Could not convert field `{}` to string", param.trim()), }, 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 filter_match( hook_name: &str, hook: &Hook, filter_name: &str, filter: &Filter, request: &Request, data: &serde_json::Value, ) -> Result> { trace!("Matching filter `{}` of hook `{}`", filter_name, hook_name); let regex = Regex::new(&filter.regex)?; if let Some(value) = data.pointer(&filter.pointer) { if let Some(value) = value.as_str() { if regex.is_match(value) { debug!("Filter `{}` of hook `{}` matched", filter_name, hook_name); return Ok(Some(replace_parameter( &hook.command.to_string(), &request.headers(), data, )?)); } } else { bail!( "Could not parse pointer in hook `{}` from filter `{}`", hook_name, filter_name ); } } trace!( "Filter `{}` of hook `{}` did not match", filter_name, hook_name ); Ok(None) } impl FromDataSimple for Hooks { type Error = anyhow::Error; fn from_data(request: &Request, data: Data) -> data::Outcome { let mut buffer = Vec::new(); match data.open().read_to_end(&mut buffer) { Ok(size) => info!("Data of size {} received", size), Err(e) => { error!("Could not read to end of data: {}", &e); return Failure(( Status::BadRequest, anyhow!("Could not read to end of data: {}", &e), )); } } let config = request.guard::>().unwrap(); // should never fail let mut valid = false; let mut hooks = HashMap::new(); for (hook_name, hook) in &config.hooks { if let Some(signature) = request.headers().get_one(&hook.signature) { for secret in &hook.secrets { match validate_request(&secret, &signature, &buffer) { Ok(()) => { trace!("Valid signature found for hook `{}`", hook_name,); valid = true; let data: serde_json::Value = match serde_json::from_slice(&buffer) { Ok(data) => data, Err(e) => { error!("Could not parse json: {}", e); return Failure(( Status::BadRequest, anyhow!("Could not parse json: {}", e), )); } }; for (filter_name, filter) in &hook.filters { match filter_match( &hook_name, &hook, &filter_name, &filter, &request, &data, ) { Ok(Some(command)) => { hooks.insert(hook_name.to_string(), command); break; } Ok(None) => {} Err(e) => error!("{}", e), } } } Err(e) => { error!("Could not validate request: {}", e); return Failure(( Status::Unauthorized, anyhow!("Could not validate request: {}", e), )); } } } } else { error!("Could not extract signature from header"); return Failure(( Status::BadRequest, anyhow!("Could not extract signature from header"), )); } } if hooks.is_empty() { if valid { warn!("Unmatched hook from {:?}", &request.client_ip()); return Failure(( Status::NotFound, anyhow!("Unmatched hook from {:?}", &request.client_ip()), )); } else { error!("Unauthorized request from {:?}", &request.client_ip()); return Failure(( Status::Unauthorized, anyhow!("Unauthorized request from {:?}", &request.client_ip()), )); } } Success(Hooks(hooks)) } } #[get("/")] fn index() -> &'static str { "Hello, webhookey!" } #[post("/", format = "json", data = "")] fn receive_hook<'a>(address: SocketAddr, hooks: Hooks) -> Result> { info!("Post request received from: {}", address); for hook in hooks.0 { info!("Execute `{}` from hook `{}`", &hook.1, &hook.0); let command = hook.1.split(' ').collect::>(); match Command::new(&command[0]).args(&command[1..]).output() { Ok(executed) => { info!( "Command `{}` exited with return code: {}", &command[0], &executed.status ); trace!( "Output of command `{}` on stdout: {:?}", &command[0], from_utf8(&executed.stdout)? ); debug!( "Output of command `{}` on stderr: {:?}", &command[0], from_utf8(&executed.stderr)? ); } Err(e) => { error!("Execution of `{}` failed: {}", command[0], e); } } } Ok(Response::new()) } fn get_config() -> Result { if let Ok(config) = File::open("/etc/webhookey/config.yml") { info!("Loading configuration from `/etc/webhookey/config.yml`"); return Ok(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); } } if let Ok(config) = File::open("config.yml") { info!("Loading configuration from `./config.yml`"); return Ok(config); } bail!("No configuration file found."); } fn main() -> Result<()> { env_logger::init(); let config: Config = serde_yaml::from_reader(BufReader::new(get_config()?))?; trace!("Parsed configuration:\n{}", serde_yaml::to_string(&config)?); rocket::ignite() .mount("/", routes![index, 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 index() { let rocket = rocket::ignite().mount("/", routes![index]); let client = Client::new(rocket).unwrap(); let mut response = client.get("/").dispatch(); assert_eq!(response.status(), Status::Ok); assert_eq!(response.body_string(), Some("Hello, webhookey!".into())); } #[test] fn secret() { let mut hooks = HashMap::new(); hooks.insert( "test_hook".to_string(), Hook { command: "".to_string(), signature: "X-Gitea-Signature".to_string(), secrets: vec!["valid".to_string()], filters: HashMap::new(), }, ); 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_parameter("command", &map, &serde_json::Value::Null).unwrap(), "command" ); assert_eq!( replace_parameter(" command", &map, &serde_json::Value::Null).unwrap(), " command" ); assert_eq!( replace_parameter("command ", &map, &serde_json::Value::Null).unwrap(), "command " ); assert_eq!( replace_parameter(" command ", &map, &serde_json::Value::Null).unwrap(), " command " ); assert_eq!( replace_parameter("command command ", &map, &serde_json::Value::Null).unwrap(), "command command " ); assert_eq!( replace_parameter("{{ /foo }} command", &map, &json!({ "foo": "bar" })).unwrap(), "bar command" ); assert_eq!( replace_parameter(" command {{ /foo }} ", &map, &json!({ "foo": "bar" })).unwrap(), " command bar " ); assert_eq!( replace_parameter( "{{ /foo }} command{{/field1/foo}}", &map, &json!({ "foo": "bar", "field1": { "foo": "baz" } }) ) .unwrap(), "bar commandbaz" ); assert_eq!( replace_parameter(" command {{ /foo }} ", &map, &json!({ "foo": "bar" })).unwrap(), " command bar " ); assert_eq!( replace_parameter( " {{ /field1/foo }} command", &map, &json!({ "field1": { "foo": "bar" } }) ) .unwrap(), " bar command" ); assert_eq!( replace_parameter( " {{ header X-Gitea-Event }} command", &map, &json!({ "field1": { "foo": "bar" } }) ) .unwrap(), " something command" ); } }