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!
This commit is contained in:
parent
7134b7700f
commit
7e3c8b8f28
10
Cargo.lock
generated
10
Cargo.lock
generated
|
@ -336,6 +336,12 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hkdf"
|
name = "hkdf"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
|
@ -494,11 +500,15 @@ name = "lohr"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"hex",
|
||||||
|
"hmac",
|
||||||
"log 0.4.14",
|
"log 0.4.14",
|
||||||
"rocket",
|
"rocket",
|
||||||
"rocket_contrib",
|
"rocket_contrib",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
"sha2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
@ -12,8 +12,12 @@ repository = "https://github.com/alarsyo/lohr"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.40"
|
anyhow = "1.0.40"
|
||||||
|
hex = "0.4.3"
|
||||||
|
hmac = "0.10.1"
|
||||||
log = "0.4.14"
|
log = "0.4.14"
|
||||||
rocket = "0.4.7"
|
rocket = "0.4.7"
|
||||||
rocket_contrib = { version = "0.4.7", features = [ "json" ] }
|
rocket_contrib = { version = "0.4.7", features = [ "json" ] }
|
||||||
serde = { version = "1.0.125", features = [ "derive" ] }
|
serde = { version = "1.0.125", features = [ "derive" ] }
|
||||||
|
serde_json = "1.0.64"
|
||||||
serde_yaml = "0.8.17"
|
serde_yaml = "0.8.17"
|
||||||
|
sha2 = "0.9.3"
|
||||||
|
|
14
README.org
14
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]].
|
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
|
#+begin_src sh
|
||||||
$ cargo run # or `cargo run --release` for production usage
|
$ cargo run # or `cargo run --release` for production usage
|
||||||
#+end_src
|
#+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.
|
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
|
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
|
GitLab's webhooks, so these should work too! (If they don't, *please* file an
|
||||||
issue!)
|
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
|
#+begin_example
|
||||||
git@github.com:you/your_repo
|
git@github.com:you/your_repo
|
||||||
|
|
11
src/main.rs
11
src/main.rs
|
@ -10,7 +10,6 @@ use std::sync::{
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
use rocket::{http::Status, post, routes, State};
|
use rocket::{http::Status, post, routes, State};
|
||||||
use rocket_contrib::json::Json;
|
|
||||||
|
|
||||||
use log::error;
|
use log::error;
|
||||||
|
|
||||||
|
@ -23,10 +22,14 @@ use job::Job;
|
||||||
mod settings;
|
mod settings;
|
||||||
use settings::GlobalSettings;
|
use settings::GlobalSettings;
|
||||||
|
|
||||||
|
mod signature;
|
||||||
|
use signature::SignedJson;
|
||||||
|
|
||||||
struct JobSender(Mutex<Sender<Job>>);
|
struct JobSender(Mutex<Sender<Job>>);
|
||||||
|
struct Secret(String);
|
||||||
|
|
||||||
#[post("/", data = "<payload>")]
|
#[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
|
// TODO: validate Gitea signature
|
||||||
|
|
||||||
{
|
{
|
||||||
|
@ -66,6 +69,9 @@ fn main() -> anyhow::Result<()> {
|
||||||
let homedir: PathBuf = homedir.into();
|
let homedir: PathBuf = homedir.into();
|
||||||
let homedir = homedir.canonicalize().expect("LOHR_HOME isn't valid!");
|
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())?;
|
let config = parse_config(homedir.clone())?;
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
|
@ -75,6 +81,7 @@ fn main() -> anyhow::Result<()> {
|
||||||
rocket::ignite()
|
rocket::ignite()
|
||||||
.mount("/", routes![gitea_webhook])
|
.mount("/", routes![gitea_webhook])
|
||||||
.manage(JobSender(Mutex::new(sender)))
|
.manage(JobSender(Mutex::new(sender)))
|
||||||
|
.manage(Secret(secret))
|
||||||
.launch();
|
.launch();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
122
src/signature.rs
Normal file
122
src/signature.rs
Normal file
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue