Add optional ip_filter to hook config

In order to allow or deny sources of requests the possibility to
configure a list of allowed or denied IP addresses was added as
described by the readme.

Closes #3
This commit is contained in:
finga 2021-04-02 00:25:39 +02:00
parent 8099bf773f
commit 8314214e06
5 changed files with 239 additions and 96 deletions

34
Cargo.lock generated
View file

@ -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",

View file

@ -24,3 +24,4 @@ nom = "6"
hmac = "0.10"
sha2 = "0.9"
hex = "0.4"
ipnet = { version = "2.3", features = ["serde"] }

119
README.md
View file

@ -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 `<config_dir>` 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

View file

@ -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:

View file

@ -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<AddrType>),
Deny(Vec<AddrType>),
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Config {
hooks: HashMap<String, Hook>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct Hook {
command: String,
signature: String,
ip_filter: Option<IpFilter>,
secrets: Vec<String>,
filters: HashMap<String, Filter>,
}
#[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<String, String>);
fn accepted_ip(hook_name: &str, client_ip: &IpAddr, ip_filter: &Option<IpFilter>) -> 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::<Sha256>::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::<State<Config>>().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(),
},