diff --git a/Cargo.lock b/Cargo.lock index 5b46026..fee3e4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,9 +314,9 @@ dependencies = [ [[package]] name = "dtoa" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d7ed2934d741c6b37e33e3832298e8850b53fd2d2bea03873375596c7cea4e" +checksum = "56899898ce76aaf4a0f24d914c97ea6ed976d42fec6ad33fcbb0a1103e07b2b0" [[package]] name = "env_logger" @@ -490,6 +490,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "ipnet" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47be2f14c678be2fdcab04ab1171db51b2762ce6f0a8ee87c8dd4a04ed216135" +dependencies = [ + "serde", +] + [[package]] name = "itoa" version = "0.4.7" @@ -523,9 +532,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.91" +version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8916b1f6ca17130ec6568feccee27c156ad12037880833a3b842a823236502e7" +checksum = "56d855069fafbb9b344c0f962150cd2c1187975cb1c22c1522c240d8c4986714" [[package]] name = "linked-hash-map" @@ -663,9 +672,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" +checksum = "a152013215dca273577e18d2bf00fa862b89b24169fb78c4c95aeb07992c9cec" dependencies = [ "unicode-xid 0.2.1", ] @@ -685,7 +694,7 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" dependencies = [ - "proc-macro2 1.0.24", + "proc-macro2 1.0.26", ] [[package]] @@ -898,9 +907,9 @@ version = "1.0.125" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d" dependencies = [ - "proc-macro2 1.0.24", + "proc-macro2 1.0.26", "quote 1.0.9", - "syn 1.0.65", + "syn 1.0.68", ] [[package]] @@ -976,11 +985,11 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.65" +version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a1d708c221c5a612956ef9f75b37e454e88d1f7b899fbd3a18d4252012d663" +checksum = "3ce15dd3ed8aa2f8eeac4716d6ef5ab58b6b9256db41d7e1a0224c2788e8fd87" dependencies = [ - "proc-macro2 1.0.24", + "proc-macro2 1.0.26", "quote 1.0.9", "unicode-xid 0.2.1", ] @@ -1151,6 +1160,7 @@ dependencies = [ "env_logger", "hex", "hmac", + "ipnet", "log 0.4.14", "nom", "regex", diff --git a/Cargo.toml b/Cargo.toml index 87d8d65..8f3a559 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,4 @@ nom = "6" hmac = "0.10" sha2 = "0.9" hex = "0.4" +ipnet = { version = "2.3", features = ["serde"] } diff --git a/README.md b/README.md index b6030a5..acf82de 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,7 @@ actions. ## Build ### Install Rust -The Rust toolchain needs to be installed: -``` sh - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -``` +Install the Rust toolchain from [rustup.rs](https://rustup.rs) Further, for Rocket we need to have the nightly toolchain installed: ``` sh @@ -51,35 +48,8 @@ or you can copy the produced binary somewhere else from you built. ## Configuration -Configuration syntax is YAML and has to be done in following order: - -Right now there is only the configuration parameter for hooks, here -each hook has to be configured, It contains following fields: -- command: A command to be executed if a filter matches -- signature: Name of the HTTP header field containing the signature. -- secrets: List of secrets. -- filters: List of filters. - -### Command -To pass data to a command following two different methods can be used. - -#### JSON Pointers -Use JSON pointers ([RFC 6901](https://tools.ietf.org/html/rfc6901)) -point to values of a JSON field from the JSON data. - -Example: `{{ /field/pointed/to }}`. - -#### Header -Use values from header fields sent with the HTTP request. - -Example: `{{ header X-Gitea-Event }}`. - -### Filter -Each filter must have following fields: -- 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 +Configuration syntax is YAML and it's paths as well as it's +configuration format is described in the following sections. ### Configuration paths Following locations are checked for a configuration file: @@ -92,13 +62,92 @@ Whereas `` depends on the platform: - macOS: `$HOME/Library/Application Support` - Windows: `{FOLDERID_RoamingAppData}` +### Configuration parameters + +#### 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. +- filters: List of filters. + +Example: +```yaml +hooks: + hook1: + command: /usr/bin/local/script_xy.sh {{ /repository/name }} + signature: X-Gitea-Signature + ip_filter: + allow: + - 127.0.0.1 + - 127.0.0.1/31 + secrets: + - secret_key_01 + - secret_key_02 + filters: + match_ref: + pointer: /ref + regex: refs/heads/master +``` + +##### Command +To pass data to a command following two different methods can be used. + +Example: `script_foo {{ header X-Gitea-Event }} {{ /field/foo }}` + +###### JSON Pointers +Use JSON pointers ([RFC 6901](https://tools.ietf.org/html/rfc6901)) +point to values of a JSON field from the JSON data. + +Example: `{{ /field/pointed/to }}`. + +###### Header +Use values from header fields sent with the HTTP request. + +Example: `{{ header X-Gitea-Event }}`. + +##### Allow and Deny +To allow or deny specific network ranges source is an optional +configuration parameter which either contains an allow or a deny field +with sequences containing networks. Note that IPv6 addresses have to +be put in single quotes due to the colons. + +Example: +```yaml +allow: + - 127.0.0.1 + - 127.0.0.1/31 + - "::1" +``` + +```yaml +deny: + - 127.0.0.1 + - 127.0.0.1/31 + - "::1" +``` + +##### Signature +Set the name of the HTTP header field containing the HMAC signature. + +##### Secrets +Configure a list of secrets to validate the hook. + +##### Filter +Each filter must have following fields: +- 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 + # TODOs ## Use `clap` to parse command line arguments -## Implement the functionality to reply to certain webhooks ## Configure rocket via config.yml ## Security ### https support basically supported, but related to "Configure rocket via config.yml". ### Authentication features -### Secure cookies? ## Use proptest or quickcheck for tests of parsers diff --git a/config.yml.example b/config.yml.example index 5f4cd7f..0bb3a42 100644 --- a/config.yml.example +++ b/config.yml.example @@ -2,6 +2,10 @@ hooks: hook1: command: /usr/bin/local/script_xy.sh {{ /field2/foo }} asdfasdf + signature: X-Gitea-Signature + ip_filter: + allow: + - 127.0.0.1/31 secrets: - secret_key_01 - secret_key_02 @@ -11,6 +15,10 @@ hooks: regex: refs/heads/master hook2: command: /usr/bin/local/script_xy.sh asdfasdf + signature: X-Gitea-Signature + ip_filter: + deny: + - 10.10.10.0/22 secrets: - secret_key_01 - secret_key_02 @@ -20,6 +28,7 @@ hooks: regex: refs/heads/master hook3: command: /usr/bin/local/script_xyz.sh + signature: X-Gitea-Signature secrets: - secret_key03 filters: diff --git a/src/main.rs b/src/main.rs index b264a7f..762a0c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use anyhow::{anyhow, bail, Result}; use hmac::{Hmac, Mac, NewMac}; +use ipnet::IpNet; use log::{debug, error, info, trace, warn}; use nom::{ branch::alt, @@ -28,25 +29,43 @@ use std::{ collections::HashMap, fs::File, io::{BufReader, Read}, - net::SocketAddr, + net::{IpAddr, Ipv4Addr, SocketAddr}, process::Command, str::from_utf8, }; #[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields, untagged)] +enum AddrType { + IpAddr(IpAddr), + IpNet(IpNet), +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields, rename_all = "lowercase")] +enum IpFilter { + Allow(Vec), + Deny(Vec), +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct Config { hooks: HashMap, } #[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct Hook { command: String, signature: String, + ip_filter: Option, secrets: Vec, filters: HashMap, } #[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] struct Filter { pointer: String, regex: String, @@ -55,6 +74,58 @@ struct Filter { #[derive(Debug)] struct Hooks(HashMap); +fn accepted_ip(hook_name: &str, client_ip: &IpAddr, ip_filter: &Option) -> bool { + match ip_filter { + Some(IpFilter::Allow(list)) => { + for i in list { + match i { + AddrType::IpAddr(addr) => { + if addr == client_ip { + info!("Allow hook `{}` from {}", &hook_name, &addr); + return true; + } + } + AddrType::IpNet(net) => { + if net.contains(client_ip) { + info!("Allow hook `{}` from {}", &hook_name, &net); + return true; + } + } + } + } + + warn!("Deny hook `{}` from {}", &hook_name, &client_ip); + return false; + } + Some(IpFilter::Deny(list)) => { + for i in list { + match i { + AddrType::IpAddr(addr) => { + if addr == client_ip { + warn!("Deny hook `{}` from {}", &hook_name, &addr); + return false; + } + } + AddrType::IpNet(net) => { + if net.contains(client_ip) { + warn!("Deny hook `{}` from {}", &hook_name, &net); + return false; + } + } + } + } + + info!("Allow hook `{}` from {}", &hook_name, &client_ip) + } + None => info!( + "Allow hook `{}` from {} as no IP filter was configured", + &hook_name, &client_ip + ), + } + + true +} + 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))?; @@ -163,75 +234,77 @@ impl FromDataSimple for Hooks { let config = request.guard::>().unwrap(); // should never fail let mut valid = false; let mut hooks = HashMap::new(); + let client_ip = &request + .client_ip() + .unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)); 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,); + if accepted_ip(&hook_name, &client_ip, &hook.ip_filter) { + 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; + 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; + 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), } - Ok(None) => {} - Err(e) => error!("{}", e), } } - } - Err(e) => { - error!("Could not validate request: {}", e); - return Failure(( - Status::Unauthorized, - anyhow!("Could not validate request: {}", e), - )); + Err(e) => { + warn!("Hook `{}` could not validate request: {}", &hook_name, e); + } } } + } else { + error!("Could not extract signature from header"); + return Failure(( + Status::BadRequest, + anyhow!("Could not extract signature from header"), + )); } - } 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()); + warn!("Unmatched hook from {}", &client_ip); return Failure(( Status::NotFound, - anyhow!("Unmatched hook from {:?}", &request.client_ip()), + anyhow!("Unmatched hook from {}", &client_ip), )); } else { - error!("Unauthorized request from {:?}", &request.client_ip()); + error!("Unauthorized request from {}", &client_ip); return Failure(( Status::Unauthorized, - anyhow!("Unauthorized request from {:?}", &request.client_ip()), + anyhow!("Unauthorized request from {}", &client_ip), )); } } @@ -353,6 +426,7 @@ mod tests { Hook { command: "".to_string(), signature: "X-Gitea-Signature".to_string(), + ip_filter: None, secrets: vec!["valid".to_string()], filters: HashMap::new(), },