Skip to content

Commit

Permalink
lohr: validate webhook signature
Browse files Browse the repository at this point in the history
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!
  • Loading branch information
alarsyo committed Mar 30, 2021
1 parent 7134b77 commit 7e3c8b8
Show file tree
Hide file tree
Showing 5 changed files with 156 additions and 5 deletions.
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
14 changes: 11 additions & 3 deletions README.org
Original file line number Diff line number Diff line change
Expand Up @@ -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
[email protected]:you/your_repo
Expand Down
11 changes: 9 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -23,10 +22,14 @@ use job::Job;
mod settings;
use settings::GlobalSettings;

mod signature;
use signature::SignedJson;

struct JobSender(Mutex<Sender<Job>>);
struct Secret(String);

#[post("/", data = "<payload>")]
fn gitea_webhook(payload: Json<GiteaWebHook>, sender: State<JobSender>) -> Status {
fn gitea_webhook(payload: SignedJson<GiteaWebHook>, sender: State<JobSender>) -> Status {
// TODO: validate Gitea signature

{
Expand Down Expand Up @@ -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 || {
Expand All @@ -75,6 +81,7 @@ fn main() -> anyhow::Result<()> {
rocket::ignite()
.mount("/", routes![gitea_webhook])
.manage(JobSender(Mutex::new(sender)))
.manage(Secret(secret))
.launch();

Ok(())
Expand Down
122 changes: 122 additions & 0 deletions src/signature.rs
Original file line number Diff line number Diff line change
@@ -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<Sha256>;

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<T>(pub T);

impl<T> Deref for SignedJson<T> {
type Target = T;

fn deref(&self) -> &T {
&self.0
}
}

impl<T> DerefMut for SignedJson<T> {
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<T>
where
T: Deserialize<'a>,
{
type Error = anyhow::Error;
type Owned = String;
type Borrowed = str;

fn transform(
request: &Request,
data: Data,
) -> rocket::data::Transform<Outcome<Self::Owned, Self::Error>> {
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<Self, Self::Error> {
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::<Vec<_>>();
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::<State<Secret>>().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))
}
}

0 comments on commit 7e3c8b8

Please sign in to comment.