From 7e3c8b8f28caa75d9f19843ac0ef7ad9b045f8a1 Mon Sep 17 00:00:00 2001 From: Antoine Martin Date: Tue, 30 Mar 2021 11:23:09 +0200 Subject: [PATCH] lohr: validate webhook signature Previously lohr was unusable in a production setting, anyone could forge a malicious webhook and either: - mirror a private repo of yours to another remote they own - wipe a repo of yours by forcing mirroring from an empty mirror This is no longer the case! --- Cargo.lock | 10 ++++ Cargo.toml | 4 ++ README.org | 14 ++++-- src/main.rs | 11 ++++- src/signature.rs | 122 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 src/signature.rs diff --git a/Cargo.lock b/Cargo.lock index 4d7e32b..4623823 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,6 +336,12 @@ dependencies = [ "libc", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.10.0" @@ -494,11 +500,15 @@ name = "lohr" version = "0.2.1" dependencies = [ "anyhow", + "hex", + "hmac", "log 0.4.14", "rocket", "rocket_contrib", "serde", + "serde_json", "serde_yaml", + "sha2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 17fbb0d..fac1516 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,12 @@ repository = "https://github.com/alarsyo/lohr" [dependencies] anyhow = "1.0.40" +hex = "0.4.3" +hmac = "0.10.1" log = "0.4.14" rocket = "0.4.7" rocket_contrib = { version = "0.4.7", features = [ "json" ] } serde = { version = "1.0.125", features = [ "derive" ] } +serde_json = "1.0.64" serde_yaml = "0.8.17" +sha2 = "0.9.3" diff --git a/README.org b/README.org index cbe07d6..f1b102f 100644 --- a/README.org +++ b/README.org @@ -29,20 +29,28 @@ Setting up =lohr= should be quite simple: 1. Create a =Rocket.toml= file and [[https://rocket.rs/v0.4/guide/configuration/][add your configuration]]. -2. Run =lohr=: +2. Export a secret variable: + + #+begin_src sh + $ export LOHR_SECRET=42 # please don't use this secret + #+end_src + +3. Run =lohr=: #+begin_src sh $ cargo run # or `cargo run --release` for production usage #+end_src -3. Configure your favorite git server to send a webhook to =lohr='s address on +4. Configure your favorite git server to send a webhook to =lohr='s address on every push event. I used [[https://docs.gitea.io/en-us/webhooks/][Gitea's webhooks format]], but I *think* they're similar to GitHub and GitLab's webhooks, so these should work too! (If they don't, *please* file an issue!) -4. Add a =.lohr= file containing the remotes you want to mirror this repo to: + Don't forget to set the webhook secret to the one you chose above. + +5. Add a =.lohr= file containing the remotes you want to mirror this repo to: #+begin_example git@github.com:you/your_repo diff --git a/src/main.rs b/src/main.rs index 6bb3613..4c7fc25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,6 @@ use std::sync::{ use std::thread; use rocket::{http::Status, post, routes, State}; -use rocket_contrib::json::Json; use log::error; @@ -23,10 +22,14 @@ use job::Job; mod settings; use settings::GlobalSettings; +mod signature; +use signature::SignedJson; + struct JobSender(Mutex>); +struct Secret(String); #[post("/", data = "")] -fn gitea_webhook(payload: Json, sender: State) -> Status { +fn gitea_webhook(payload: SignedJson, sender: State) -> Status { // TODO: validate Gitea signature { @@ -66,6 +69,9 @@ fn main() -> anyhow::Result<()> { let homedir: PathBuf = homedir.into(); let homedir = homedir.canonicalize().expect("LOHR_HOME isn't valid!"); + let secret = env::var("LOHR_SECRET") + .expect("please provide a secret, otherwise anyone can send you a malicious webhook"); + let config = parse_config(homedir.clone())?; thread::spawn(move || { @@ -75,6 +81,7 @@ fn main() -> anyhow::Result<()> { rocket::ignite() .mount("/", routes![gitea_webhook]) .manage(JobSender(Mutex::new(sender))) + .manage(Secret(secret)) .launch(); Ok(()) diff --git a/src/signature.rs b/src/signature.rs new file mode 100644 index 0000000..48129de --- /dev/null +++ b/src/signature.rs @@ -0,0 +1,122 @@ +use std::{ + io::{Read, Write}, + ops::{Deref, DerefMut}, +}; + +use rocket::{ + data::{FromData, Outcome}, + http::ContentType, + State, +}; +use rocket::{ + data::{Transform, Transformed}, + http::Status, +}; +use rocket::{Data, Request}; + +use anyhow::anyhow; +use serde::Deserialize; + +use crate::Secret; + +const X_GITEA_SIGNATURE: &str = "X-Gitea-Signature"; + +fn validate_signature(secret: &str, signature: &str, data: &str) -> bool { + use hmac::{Hmac, Mac, NewMac}; + use sha2::Sha256; + + type HmacSha256 = Hmac; + + let mut mac = HmacSha256::new_varkey(secret.as_bytes()).expect("this should never fail"); + + mac.update(data.as_bytes()); + + match hex::decode(signature) { + Ok(bytes) => mac.verify(&bytes).is_ok(), + Err(_) => false, + } +} + +pub struct SignedJson(pub T); + +impl Deref for SignedJson { + type Target = T; + + fn deref(&self) -> &T { + &self.0 + } +} + +impl DerefMut for SignedJson { + fn deref_mut(&mut self) -> &mut T { + &mut self.0 + } +} + +const LIMIT: u64 = 1 << 20; + +// This is a one to one implementation of request_contrib::Json's FromData, but with HMAC +// validation. +// +// Tracking issue for chaining Data guards to avoid this: +// https://github.com/SergioBenitez/Rocket/issues/775 +impl<'a, T> FromData<'a> for SignedJson +where + T: Deserialize<'a>, +{ + type Error = anyhow::Error; + type Owned = String; + type Borrowed = str; + + fn transform( + request: &Request, + data: Data, + ) -> rocket::data::Transform> { + let size_limit = request.limits().get("json").unwrap_or(LIMIT); + let mut s = String::with_capacity(512); + match data.open().take(size_limit).read_to_string(&mut s) { + Ok(_) => Transform::Borrowed(Outcome::Success(s)), + Err(e) => Transform::Borrowed(Outcome::Failure(( + Status::BadRequest, + anyhow!("couldn't read json: {}", e), + ))), + } + } + + fn from_data(request: &Request, o: Transformed<'a, Self>) -> Outcome { + let json_ct = ContentType::new("application", "json"); + if request.content_type() != Some(&json_ct) { + return Outcome::Failure((Status::BadRequest, anyhow!("wrong content type"))); + } + + let signatures = request.headers().get(X_GITEA_SIGNATURE).collect::>(); + if signatures.len() != 1 { + return Outcome::Failure(( + Status::BadRequest, + anyhow!("request header needs exactly one signature"), + )); + } + + let signature = signatures[0]; + + let content = o.borrowed()?; + + let secret = request.guard::>().unwrap(); + + if !validate_signature(&secret.0, &signature, content) { + return Outcome::Failure((Status::BadRequest, anyhow!("couldn't verify signature"))); + } + + let content = match serde_json::from_str(content) { + Ok(content) => content, + Err(e) => { + return Outcome::Failure(( + Status::BadRequest, + anyhow!("couldn't parse json: {}", e), + )) + } + }; + + Outcome::Success(SignedJson(content)) + } +}