Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
SebRollen committed Jan 27, 2024
0 parents commit a34d89f
Show file tree
Hide file tree
Showing 11 changed files with 535 additions and 0 deletions.
15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<<<<<<< HEAD
# Generated by Cargo
# will have compiled files and executables
debug/
target/

# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock

# These are backup files generated by rustfmt
**/*.rs.bk

# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
33 changes: 33 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
[package]
name = "tower-github-webhook"
version = "0.1.0"
edition = "2021"
authors = ["Sebastian Rollén <[email protected]>"]
license = "MIT"
repository = "https://github.com/SebRollen/tower-github-webhook"
description = "tower-github-webhook is a crate that simplifies validating webhooks received from GitHub "
keywords = ["tower", "layer", "service", "github", "webhook"]
categories = ["authentication", "web-programming"]

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
bytes = "1.5.0"
hex = "0.4.3"
hmac = "0.12.1"
http = "1.0.0"
http-body = "1.0.0"
pin-project = "1.1.3"
sha2 = "0.10.8"
tower = { version = "0.4.13", features = ["util"] }
tracing = "0.1.40"

[dev-dependencies]
axum = { version = "0.7.4", features = ["macros"] }
http-body-util = "0.1.0"
hyper = "1.1.0"
octocrab = "0.33.3"
serde = { version = "1.0.196", features = ["derive"] }
tokio = { version = "1.35.1", features = ["full"] }
tracing = "0.1.40"
tracing-subscriber = "0.3.18"
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2024 Sebastian Rollén

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# tower-github-webhook

`tower-github-webhook` is a crate that simplifies validating webhooks received from GitHub.
84 changes: 84 additions & 0 deletions examples/simple.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
use axum::async_trait;
use axum::body::Bytes;
use axum::debug_handler;
use axum::extract::{FromRequest, Request};
use axum::response::{IntoResponse, Response};
use axum::{extract::Json, routing::post, Router};
use octocrab::models::{
webhook_events::{WebhookEvent, WebhookEventPayload, WebhookEventType},
Author, Repository,
};
use serde::{Deserialize, Serialize};
use tower_github_webhook::ValidateGitHubWebhookLayer;

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Event {
pub kind: WebhookEventType,
pub sender: Option<Author>,
pub repository: Option<Repository>,
pub payload: WebhookEventPayload,
}

impl From<WebhookEvent> for Event {
fn from(e: WebhookEvent) -> Self {
Self {
kind: e.kind,
sender: e.sender,
repository: e.repository,
payload: e.specific,
}
}
}

#[async_trait]
impl<S> FromRequest<S> for Event
where
S: Send + Sync,
{
type Rejection = Response;

async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let headers = req.headers().clone();
let header = headers
.get("x-github-event")
.map(|x| x.to_str())
.unwrap()
.map_err(|_| {
"Failed to convert header to string"
.to_string()
.into_response()
})?;
let bytes = Bytes::from_request(req, state)
.await
.map_err(IntoResponse::into_response)?;
let webhook_event = WebhookEvent::try_from_header_and_body(header, &bytes).unwrap();
Ok(Self::from(webhook_event))
}
}

#[tokio::main]
async fn main() {
// Setup tracing
tracing_subscriber::fmt::init();

// Run our service
let addr = "0.0.0.0:3000";
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
tracing::info!("Listening on {}", addr);
axum::serve(listener, app().into_make_service())
.await
.unwrap();
}

fn app() -> Router {
// Build route service
Router::new().route(
"/github/events",
post(print_body).layer(ValidateGitHubWebhookLayer::new("123")),
)
}

#[debug_handler]
async fn print_body(Json(event): Json<Event>) {
println!("{:#?}", event);
}
172 changes: 172 additions & 0 deletions src/future.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
use bytes::Buf;
use hmac::{Hmac, Mac};
use http::{Request, Response, StatusCode};
use http_body::Body;
use pin_project::pin_project;
use sha2::Sha256;
use std::future::Future;
use std::pin::Pin;
use std::task::{ready, Context, Poll};
use tower::Service;

#[pin_project]
pub struct ValidateGitHubWebhookFuture<
S: Service<Request<ReqBody>, Response = Response<ResBody>>,
ReqBody,
ResBody,
> {
req: Option<Request<ReqBody>>,
signature: Option<Vec<u8>>,
inner: S,
hmac: Option<Hmac<Sha256>>,
#[pin]
state: ValidateGitHubWebhookFutureState<ReqBody, ResBody, S>,
}

impl<S, ReqBody, ResBody> ValidateGitHubWebhookFuture<S, ReqBody, ResBody>
where
S: Service<Request<ReqBody>, Response = Response<ResBody>>,
{
pub fn new(req: Request<ReqBody>, hmac: Hmac<Sha256>, inner: S) -> Self {
Self {
req: Some(req),
signature: None,
inner,
hmac: Some(hmac),
state: ValidateGitHubWebhookFutureState::ExtractSignature,
}
}
}

impl<S, F, ReqBody, ResBody> Future for ValidateGitHubWebhookFuture<S, ReqBody, ResBody>
where
S: Service<Request<ReqBody>, Response = Response<ResBody>, Future = F>,
F: Future<Output = Result<Response<ResBody>, S::Error>>,
ReqBody: Body + Unpin,
ResBody: Body + Default,
{
type Output = Result<Response<ResBody>, S::Error>;

fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.as_mut().project();
let mut curr_state = this.state;
match curr_state.as_mut().project() {
ValidateGitHubProj::ExtractSignature => {
tracing::trace!(
"[tower-github-webhook] ValidateGitHubWebhookFutureState::ExtractSignature"
);
let req = this.req.take().unwrap();
let signature = match req.headers().get("x-hub-signature-256") {
Some(sig) => {
let Some(sig) = sig.as_bytes().splitn(2, |x| x == &b'=').nth(1) else {
tracing::debug!("[tower-github-webhook] Invalid header format");
curr_state.set(ValidateGitHubWebhookFutureState::Unauthorized);
cx.waker().wake_by_ref();
return Poll::Pending;
};
match hex::decode(sig) {
Ok(sig) => sig,
Err(_) => {
tracing::debug!("[tower-github-webhook] Invalid header format");
curr_state.set(ValidateGitHubWebhookFutureState::Unauthorized);
cx.waker().wake_by_ref();
return Poll::Pending;
}
}
}
None => {
tracing::debug!(
"[tower-github-webhook] Missing X-HUB-SIGNATURE-256 header"
);
curr_state.set(ValidateGitHubWebhookFutureState::Unauthorized);
cx.waker().wake_by_ref();
return Poll::Pending;
}
};
curr_state.set(ValidateGitHubWebhookFutureState::ExtractBody);
*this.signature = Some(signature);
*this.req = Some(req);
cx.waker().wake_by_ref();
Poll::Pending
}
ValidateGitHubProj::ExtractBody => {
tracing::trace!(
"[tower-github-webhook] ValidateGitHubWebhookFutureState::ExtractBody"
);
let mut req = this.req.take().unwrap();
let body = Pin::new(req.body_mut());
if body.is_end_stream() {
curr_state.set(ValidateGitHubWebhookFutureState::ValidateSignature);
} else {
let frame = ready!(Pin::new(req.body_mut()).poll_frame(cx));
if let Some(Ok(frame)) = frame {
if let Ok(data) = frame.into_data() {
let mut hmac = this.hmac.take().unwrap();
hmac.update(data.chunk());
*this.hmac = Some(hmac);
}
}
}
*this.req = Some(req);
cx.waker().wake_by_ref();
Poll::Pending
}
ValidateGitHubProj::ValidateSignature => {
tracing::trace!(
"[tower-github-webhook] ValidateGitHubWebhookFutureState::ValidateSignature"
);
let signature = this.signature.take().unwrap();
let hmac = this.hmac.take().unwrap();
if hmac.verify_slice(&signature).is_ok() {
tracing::debug!("[tower-github-webhook] Valid signature");
curr_state.set(ValidateGitHubWebhookFutureState::InnerBefore);
} else {
tracing::debug!("[tower-github-webhook] Invalid signature");
curr_state.set(ValidateGitHubWebhookFutureState::Unauthorized);
}
cx.waker().wake_by_ref();
Poll::Pending
}
ValidateGitHubProj::InnerBefore => {
tracing::trace!(
"[tower-github-webhook] ValidateGitHubWebhookFutureState::InnerBefore"
);
let req = this.req.take().unwrap();
let fut = this.inner.call(req);
curr_state.set(ValidateGitHubWebhookFutureState::Inner { fut });
cx.waker().wake_by_ref();
Poll::Pending
}
ValidateGitHubProj::Inner { fut } => {
tracing::trace!("[tower-github-webhook] ValidateGitHubWebhookFutureState::Inner");
fut.poll(cx)
}
ValidateGitHubProj::Unauthorized => {
tracing::trace!(
"[tower-github-webhook] ValidateGitHubWebhookFutureState::Unauthorized"
);
tracing::warn!("[tower-github-webhook] Request not authorized");
let mut res = Response::new(ResBody::default());
*res.status_mut() = StatusCode::UNAUTHORIZED;
Poll::Ready(Ok(res))
}
}
}
}

#[pin_project(project = ValidateGitHubProj)]
pub(crate) enum ValidateGitHubWebhookFutureState<
ReqBody,
ResBody,
S: Service<http::Request<ReqBody>, Response = http::Response<ResBody>>,
> {
ExtractSignature,
ExtractBody,
ValidateSignature,
InnerBefore,
Inner {
#[pin]
fut: S::Future,
},
Unauthorized,
}
24 changes: 24 additions & 0 deletions src/layer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
use crate::ValidateGitHubWebhook;
use tower::Layer;

#[derive(Clone)]
pub struct ValidateGitHubWebhookLayer<Secret> {
webhook_secret: Secret,
}

impl<Secret> ValidateGitHubWebhookLayer<Secret> {
pub fn new(webhook_secret: Secret) -> Self {
Self { webhook_secret }
}
}

impl<S, Secret> Layer<S> for ValidateGitHubWebhookLayer<Secret>
where
Secret: AsRef<[u8]> + Clone,
{
type Service = ValidateGitHubWebhook<S>;

fn layer(&self, inner: S) -> Self::Service {
ValidateGitHubWebhook::new(self.webhook_secret.clone(), inner)
}
}
13 changes: 13 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! # Overview
//!
//! `tower-github-webhook` is a crate for verifying signed webhooks received from GitHub.
mod future;
mod layer;
mod service;
#[cfg(test)]
mod test_helpers;
#[cfg(test)]
mod tests;

pub use layer::ValidateGitHubWebhookLayer;
pub use service::ValidateGitHubWebhook;
Loading

0 comments on commit a34d89f

Please sign in to comment.