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:
parent
8099bf773f
commit
8314214e06
5 changed files with 239 additions and 96 deletions
34
Cargo.lock
generated
34
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -24,3 +24,4 @@ nom = "6"
|
|||
hmac = "0.10"
|
||||
sha2 = "0.9"
|
||||
hex = "0.4"
|
||||
ipnet = { version = "2.3", features = ["serde"] }
|
||||
|
|
119
README.md
119
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 `<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
|
||||
|
|
|
@ -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:
|
||||
|
|
172
src/main.rs
172
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<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(),
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue