Skip to content

Commit 195aa57

Browse files
committed
refactor: move valitation into another crate
1 parent 197b41e commit 195aa57

File tree

10 files changed

+250
-106
lines changed

10 files changed

+250
-106
lines changed

Cargo.lock

Lines changed: 14 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ license = "MIT OR Apache-2.0"
1515
repository = "https://github.com/RustLangES/grhooks"
1616

1717
[workspace]
18-
members = ["crates/core", "crates/config"]
18+
members = ["crates/core", "crates/config", "crates/origin"]
1919

2020
[workspace.dependencies]
2121
serde_json = "1"
@@ -30,15 +30,10 @@ axum = { version = "0.8.3", default-features = false, features = [
3030
"tokio",
3131
"matched-path",
3232
] }
33-
constant_time_eq = "0.4.2"
3433
grhooks-config = { version = "0.1.0", path = "crates/config" }
3534
grhooks-core = { version = "0.1.0", path = "crates/core" }
36-
hex = "0.4.3"
37-
hmac = "0.12.1"
3835
notify = "8.0.0"
3936
serde_json.workspace = true
40-
sha1 = "0.10.6"
41-
sha2 = "0.10.8"
4237
tokio = { version = "1.44.1", default-features = false, features = ["full"] }
4338
tracing.workspace = true
4439
tracing-subscriber = "0.3.19"

crates/config/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ repository.workspace = true
99

1010
[dependencies]
1111
clap = { version = "4.5", features = ["env"] }
12+
grhooks-origin = { version = "0.1.0", path = "../origin" }
1213
serde = { version = "1", features = ["derive"] }
1314
serde_json.workspace = true
1415
serde_yaml = "0.9"

crates/config/src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@ use std::collections::HashSet;
22
use std::path::PathBuf;
33

44
use clap::{Arg, Command};
5+
use grhooks_origin::Origin;
56
use serde::Deserialize;
67

8+
pub use grhooks_origin;
9+
710
#[derive(Clone, Debug, Deserialize)]
811
pub struct WebhookConfig {
912
pub path: String,
13+
#[serde(default = "Origin::default")]
14+
pub origin: Origin,
1015
pub secret: Option<String>,
1116
pub events: HashSet<String>,
1217
pub shell: Option<Vec<String>>,
@@ -16,12 +21,17 @@ pub struct WebhookConfig {
1621

1722
#[derive(Clone, Debug, Deserialize)]
1823
pub struct Config {
24+
#[serde(default = "default_port")]
1925
pub port: u16,
2026
#[serde(skip)]
2127
pub verbose: String,
2228
pub webhooks: Vec<WebhookConfig>,
2329
}
2430

31+
const fn default_port() -> u16 {
32+
8080
33+
}
34+
2535
impl Default for Config {
2636
fn default() -> Self {
2737
Self {

crates/origin/Cargo.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "grhooks-origin"
3+
version = "0.1.0"
4+
edition.workspace = true
5+
authors.workspace = true
6+
description.workspace = true
7+
license.workspace = true
8+
repository.workspace = true
9+
10+
[dependencies]
11+
axum = { version = "0.8.3", default-features = false }
12+
serde = { version = "1", default-features = false, features = ["derive"] }
13+
constant_time_eq = "0.4"
14+
hex = "0.4"
15+
hmac = "0.12"
16+
sha1 = "0.10"
17+
sha2 = "0.10"

crates/origin/src/errors.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use std::fmt::Debug;
2+
3+
use axum::http::StatusCode;
4+
use axum::response::IntoResponse;
5+
6+
pub enum Error {
7+
MissingHeader(&'static str),
8+
InvalidSignature,
9+
InvalidUserAgent,
10+
UnsupportedEvent,
11+
}
12+
13+
impl Debug for Error {
14+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15+
match self {
16+
Error::MissingHeader(header) => write!(f, "Missing required header: {}", header),
17+
Error::InvalidSignature => write!(f, "Invalid signature"),
18+
Error::InvalidUserAgent => write!(f, "Invalid user agent"),
19+
Error::UnsupportedEvent => write!(f, "Unsupported event type"),
20+
}
21+
}
22+
}
23+
24+
impl IntoResponse for Error {
25+
fn into_response(self) -> axum::response::Response {
26+
match self {
27+
Error::MissingHeader(header) => (
28+
StatusCode::BAD_REQUEST,
29+
format!("Missing required header: {}", header),
30+
)
31+
.into_response(),
32+
Error::InvalidSignature => {
33+
(StatusCode::BAD_REQUEST, "Invalid signature").into_response()
34+
}
35+
Error::InvalidUserAgent => {
36+
(StatusCode::BAD_REQUEST, "Invalid user agent").into_response()
37+
}
38+
Error::UnsupportedEvent => {
39+
(StatusCode::BAD_REQUEST, "Unsupported event type").into_response()
40+
}
41+
}
42+
}
43+
}

crates/origin/src/github.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
use axum::http::HeaderMap;
2+
use hmac::{Hmac, Mac};
3+
use sha1::Sha1;
4+
use sha2::Sha256;
5+
6+
use crate::{Error, WebhookOrigin};
7+
8+
pub struct GitHubValidator;
9+
10+
impl WebhookOrigin for GitHubValidator {
11+
fn validate_headers(&self, headers: &HeaderMap) -> Result<(), Error> {
12+
const REQUIRED_HEADERS: [&str; 4] = [
13+
"X-GitHub-Hook-ID",
14+
"X-GitHub-Event",
15+
"X-GitHub-Delivery",
16+
"User-Agent",
17+
];
18+
19+
for header in REQUIRED_HEADERS {
20+
if !headers.contains_key(header) {
21+
return Err(Error::MissingHeader(header));
22+
}
23+
}
24+
25+
let user_agent = headers
26+
.get("User-Agent")
27+
.and_then(|v| v.to_str().ok())
28+
.ok_or(Error::MissingHeader("User-Agent"))?;
29+
30+
if !user_agent.starts_with("GitHub-Hookshot/") {
31+
return Err(Error::InvalidUserAgent);
32+
}
33+
34+
Ok(())
35+
}
36+
37+
fn extract_event_type(&self, headers: &HeaderMap) -> Result<String, Error> {
38+
headers
39+
.get("X-GitHub-Event")
40+
.and_then(|v| v.to_str().ok())
41+
.map(|s| s.to_string())
42+
.ok_or(Error::MissingHeader("X-GitHub-Event"))
43+
}
44+
45+
fn validate_signature(
46+
&self,
47+
headers: &HeaderMap,
48+
secret: &str,
49+
body: &[u8],
50+
) -> Result<(), Error> {
51+
let (expected_signature, signature) = match headers
52+
.get("X-Hub-Signature-256")
53+
.and_then(|v| v.to_str().ok())
54+
{
55+
Some(signature) => {
56+
let signature = signature.trim_start_matches("sha256=");
57+
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
58+
.map_err(|_| Error::InvalidSignature)?;
59+
mac.update(body);
60+
let expected_signature = hex::encode(mac.finalize().into_bytes());
61+
(expected_signature, signature)
62+
}
63+
None => {
64+
let signature = headers
65+
.get("X-Hub-Signature")
66+
.and_then(|v| v.to_str().ok())
67+
.ok_or(Error::MissingHeader("X-Hub-Signature"))?;
68+
let signature = signature.trim_start_matches("sha1=");
69+
let mut mac = Hmac::<Sha1>::new_from_slice(secret.as_bytes())
70+
.map_err(|_| Error::InvalidSignature)?;
71+
mac.update(body);
72+
let expected_signature = hex::encode(mac.finalize().into_bytes());
73+
(expected_signature, signature)
74+
}
75+
};
76+
77+
if !constant_time_eq::constant_time_eq(signature.as_bytes(), expected_signature.as_bytes())
78+
{
79+
return Err(Error::InvalidSignature);
80+
}
81+
82+
Ok(())
83+
}
84+
}

crates/origin/src/lib.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
mod errors;
2+
mod github;
3+
4+
use axum::http::HeaderMap;
5+
use serde::Deserialize;
6+
7+
pub use crate::errors::Error;
8+
9+
#[derive(Clone, Copy, Debug, Default, Deserialize)]
10+
#[serde(rename_all = "lowercase")]
11+
pub enum Origin {
12+
#[default]
13+
GitHub,
14+
}
15+
16+
pub trait WebhookOrigin {
17+
fn validate_headers(&self, headers: &HeaderMap) -> Result<(), Error>;
18+
fn extract_event_type(&self, headers: &HeaderMap) -> Result<String, Error>;
19+
fn validate_signature(
20+
&self,
21+
headers: &HeaderMap,
22+
secret: &str,
23+
body: &[u8],
24+
) -> Result<(), Error>;
25+
}
26+
27+
impl WebhookOrigin for Origin {
28+
fn validate_headers(&self, headers: &HeaderMap) -> Result<(), Error> {
29+
match self {
30+
Origin::GitHub => github::GitHubValidator.validate_headers(headers),
31+
}
32+
}
33+
34+
fn extract_event_type(&self, headers: &HeaderMap) -> Result<String, Error> {
35+
match self {
36+
Origin::GitHub => github::GitHubValidator.extract_event_type(headers),
37+
}
38+
}
39+
40+
fn validate_signature(
41+
&self,
42+
headers: &HeaderMap,
43+
secret: &str,
44+
body: &[u8],
45+
) -> Result<(), Error> {
46+
match self {
47+
Origin::GitHub => github::GitHubValidator.validate_signature(headers, secret, body),
48+
}
49+
}
50+
}

src/errors.rs

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,29 @@
11
use axum::http::StatusCode;
22
use axum::response::{IntoResponse, Response};
3+
use grhooks_config::grhooks_origin;
34

45
#[derive(Debug)]
56
pub enum HeaderValidationError {
6-
MissingHeader(&'static str),
7-
InvalidSignature,
8-
InvalidUserAgent,
97
WebhookNotFound,
8+
OriginValidation(grhooks_origin::Error),
109
AxumError(axum::Error),
1110
}
1211

1312
impl IntoResponse for HeaderValidationError {
1413
fn into_response(self) -> Response {
1514
let (status, message) = match self {
16-
HeaderValidationError::MissingHeader(header) => (
17-
StatusCode::BAD_REQUEST,
18-
format!("Missing required header: {header}"),
19-
),
20-
HeaderValidationError::InvalidSignature => {
21-
(StatusCode::UNAUTHORIZED, "Invalid signature".to_string())
22-
}
23-
HeaderValidationError::InvalidUserAgent => {
24-
(StatusCode::BAD_REQUEST, "Invalid User-Agent".to_string())
25-
}
2615
HeaderValidationError::WebhookNotFound => {
2716
(StatusCode::NOT_FOUND, "Webhook not configured".to_string())
2817
}
2918
HeaderValidationError::AxumError(error) => (StatusCode::BAD_REQUEST, error.to_string()),
19+
HeaderValidationError::OriginValidation(error) => return error.into_response(),
3020
};
3121
(status, message).into_response()
3222
}
3323
}
24+
25+
impl From<grhooks_origin::Error> for HeaderValidationError {
26+
fn from(error: grhooks_origin::Error) -> Self {
27+
HeaderValidationError::OriginValidation(error)
28+
}
29+
}

0 commit comments

Comments
 (0)