feat(server/webhook): only accept authorised messages
All checks were successful
ci/woodpecker/push/cron-docker-builder Pipeline was successful
ci/woodpecker/push/tag-created Pipeline was successful
ci/woodpecker/push/push-next Pipeline was successful

Closes kemitix/git-next#47
This commit is contained in:
Paul Campbell 2024-04-14 19:12:51 +01:00
parent b398ac3fd3
commit 64a6b84ee4
4 changed files with 66 additions and 15 deletions

View file

@ -1,13 +1,14 @@
mod branch; mod branch;
mod config; mod config;
pub mod status; pub mod status;
mod webhook; pub mod webhook;
use actix::prelude::*; use actix::prelude::*;
use kxio::network::Network; use kxio::network::Network;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::server::{ use crate::server::{
actors::repo::webhook::WebhookAuth,
config::{RepoConfig, RepoDetails, Webhook}, config::{RepoConfig, RepoDetails, Webhook},
forge, forge,
git::Git, git::Git,
@ -19,7 +20,7 @@ pub struct RepoActor {
details: RepoDetails, details: RepoDetails,
webhook: Webhook, webhook: Webhook,
webhook_id: Option<WebhookId>, // INFO: if [None] then no webhook is configured webhook_id: Option<WebhookId>, // INFO: if [None] then no webhook is configured
webhook_auth: Option<ulid::Ulid>, webhook_auth: Option<WebhookAuth>, // INFO: if [None] then no webhook is configured
last_main_commit: Option<forge::Commit>, last_main_commit: Option<forge::Commit>,
last_next_commit: Option<forge::Commit>, last_next_commit: Option<forge::Commit>,
last_dev_commit: Option<forge::Commit>, last_dev_commit: Option<forge::Commit>,
@ -168,7 +169,7 @@ impl Handler<StartMonitoring> for RepoActor {
#[derive(Message)] #[derive(Message)]
#[rtype(result = "()")] #[rtype(result = "()")]
pub struct WebhookRegistered(pub WebhookId, pub ulid::Ulid); pub struct WebhookRegistered(pub WebhookId, pub WebhookAuth);
impl Handler<WebhookRegistered> for RepoActor { impl Handler<WebhookRegistered> for RepoActor {
type Result = (); type Result = ();
fn handle(&mut self, msg: WebhookRegistered, _ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, msg: WebhookRegistered, _ctx: &mut Self::Context) -> Self::Result {

View file

@ -1,8 +1,9 @@
use actix::prelude::*; use actix::prelude::*;
use kxio::network::{self, json}; use kxio::network::{self, json};
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
use ulid::DecodeError;
use std::{fmt::Display, ops::Deref}; use std::{fmt::Display, ops::Deref, str::FromStr};
use crate::server::{ use crate::server::{
actors::{ actors::{
@ -33,6 +34,29 @@ impl Display for WebhookId {
} }
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WebhookAuth(ulid::Ulid);
impl WebhookAuth {
pub fn from_str(authorisation: &str) -> Result<Self, DecodeError> {
let id = ulid::Ulid::from_str(authorisation);
Ok(Self(id?))
}
fn generate() -> Self {
Self(ulid::Ulid::new())
}
fn header_value(&self) -> String {
format!("Basic {}", self.0.to_string())
}
}
impl Deref for WebhookAuth {
type Target = ulid::Ulid;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub async fn unregister( pub async fn unregister(
webhook_id: WebhookId, webhook_id: WebhookId,
repo_details: crate::server::config::RepoDetails, repo_details: crate::server::config::RepoDetails,
@ -82,10 +106,10 @@ pub async fn register(
let webhook_url = webhook.url(); let webhook_url = webhook.url();
let repo_alias = repo_details.name; let repo_alias = repo_details.name;
let headers = network::NetRequestHeaders::new().with("Content-Type", "application/json"); let headers = network::NetRequestHeaders::new().with("Content-Type", "application/json");
let authorisation = ulid::Ulid::new(); let authorisation = WebhookAuth::generate();
let body = json!({ let body = json!({
"active": true, "active": true,
"authorization_header": format!("Basic {}", authorisation), "authorization_header": authorisation.header_value(),
"branch_filter": format!("{{{},{},{}}}", repo_config.branches().main(), repo_config.branches().next(), repo_config.branches().dev()), "branch_filter": format!("{{{},{},{}}}", repo_config.branches().main(), repo_config.branches().next(), repo_config.branches().dev()),
"config": { "config": {
"content_type": "json", "content_type": "json",
@ -130,6 +154,9 @@ impl Handler<WebhookMessage> for RepoActor {
#[allow(clippy::cognitive_complexity)] // TODO: (#49) reduce complexity #[allow(clippy::cognitive_complexity)] // TODO: (#49) reduce complexity
fn handle(&mut self, msg: WebhookMessage, ctx: &mut Self::Context) -> Self::Result { fn handle(&mut self, msg: WebhookMessage, ctx: &mut Self::Context) -> Self::Result {
if msg.authorisation() != self.webhook_auth {
return; // invalid auth
}
let id = msg.id(); let id = msg.id();
let body = msg.body(); let body = msg.body();
debug!(?id, "RepoActor received message"); debug!(?id, "RepoActor received message");

View file

@ -1,11 +1,14 @@
// //
use actix::prelude::*; use actix::prelude::*;
use crate::server::actors::repo::webhook::WebhookAuth;
#[derive(Message, Debug, Clone)] #[derive(Message, Debug, Clone)]
#[rtype(result = "()")] #[rtype(result = "()")]
pub struct WebhookMessage { pub struct WebhookMessage {
id: String, id: String,
path: String, path: String,
authorisation: String,
// query: String, // query: String,
// headers: warp::http::HeaderMap, // headers: warp::http::HeaderMap,
body: String, body: String,
@ -17,6 +20,7 @@ impl WebhookMessage {
// query: String, // query: String,
// headers: warp::http::HeaderMap, // headers: warp::http::HeaderMap,
body: String, body: String,
authorisation: String,
) -> Self { ) -> Self {
Self { Self {
id, id,
@ -24,6 +28,7 @@ impl WebhookMessage {
// query, // query,
// headers, // headers,
body, body,
authorisation,
} }
} }
pub const fn id(&self) -> &String { pub const fn id(&self) -> &String {
@ -41,4 +46,7 @@ impl WebhookMessage {
pub const fn body(&self) -> &String { pub const fn body(&self) -> &String {
&self.body &self.body
} }
pub fn authorisation(&self) -> Option<WebhookAuth> {
WebhookAuth::from_str(&self.authorisation).ok()
}
} }

View file

@ -12,24 +12,39 @@ pub async fn start(address: actix::prelude::Recipient<super::message::WebhookMes
.map(move || address.clone()) .map(move || address.clone())
.and(warp::path::param()) .and(warp::path::param())
// .and(warp::query::raw()) // .and(warp::query::raw())
// .and(warp::header::headers_cloned()) .and(warp::header::headers_cloned())
.and(warp::body::bytes()) .and(warp::body::bytes())
.and_then( .and_then(
|recipient: Recipient<WebhookMessage>, |recipient: Recipient<WebhookMessage>,
path, path,
// query: String, // query: String,
// headers: warp::http::HeaderMap, headers: warp::http::HeaderMap,
body: bytes::Bytes| async move { body: bytes::Bytes| async move {
let bytes = body.to_vec(); let bytes = body.to_vec();
let request_data = String::from_utf8_lossy(&bytes).to_string(); let request_data = String::from_utf8_lossy(&bytes).to_string();
let id = ulid::Ulid::new().to_string(); let id = ulid::Ulid::new().to_string();
match headers.get("Authorization") {
Some(auhorisation) => {
debug!(id, path, "Received webhook"); debug!(id, path, "Received webhook");
let message = let authorisation = auhorisation
WebhookMessage::new(id, path, /* query, headers, */ request_data); .to_str()
.map_err(|_| warp::reject())? // valid characters
.strip_prefix("Basic ")
.ok_or_else(warp::reject)? // must start with "Basic "
.to_string();
let message = WebhookMessage::new(
id,
path,
/* query, headers, */ request_data,
authorisation,
);
recipient recipient
.try_send(message) .try_send(message)
.map(|_| warp::reply::with_status("OK", warp::http::StatusCode::OK)) .map(|_| warp::reply::with_status("OK", warp::http::StatusCode::OK))
.map_err(|_| warp::reject::reject()) .map_err(|_| warp::reject())
}
_ => Err(warp::reject()),
}
}, },
); );