1use axum::Json;
2use axum::extract::{Path, State};
3use axum::http::StatusCode;
4use kellnr_common::webhook::Webhook;
5use kellnr_http::authz::require_admin;
6use kellnr_http::error::{ApiError, ApiResult};
7use kellnr_http::identity::TokenUser;
8use kellnr_http::state::{DbState, SettingsState};
9use tracing::{trace, warn};
10use uuid::Uuid;
11
12use crate::client::{Delivery, DeliveryError, WebhookClient, truncated_body};
13use crate::signature::decode_secret;
14use crate::target::{is_blocked_url, validate_callback_url};
15use crate::types;
16
17#[utoipa::path(
19 post,
20 path = "/",
21 tag = "webhooks",
22 request_body = types::RegisterWebhookRequest,
23 responses(
24 (status = 200, description = "Webhook registered successfully", body = types::RegisterWebhookResponse),
25 (status = 400, description = "Invalid callback URL, target address not permitted or invalid secret"),
26 (status = 401, description = "Admin access required")
27 ),
28 security(("cargo_token" = []))
29)]
30pub async fn register_webhook(
31 user: TokenUser,
32 State(db): DbState,
33 State(settings): SettingsState,
34 Json(input): Json<types::RegisterWebhookRequest>,
35) -> ApiResult<Json<types::RegisterWebhookResponse>> {
36 trace!(user = %user.name, event = ?input.event, "Registering webhook");
37 require_admin(&user)?;
38
39 let url = validate_callback_url(&input.callback_url)
40 .map_err(|e| ApiError::new(&e, StatusCode::BAD_REQUEST))?;
41 if is_blocked_url(&url, settings.webhooks.block_private_targets) {
42 return Err(ApiError::new(
43 "Callback URL target address is not permitted",
44 StatusCode::BAD_REQUEST,
45 ));
46 }
47 if let Some(secret) = &input.secret {
48 decode_secret(secret).map_err(|e| ApiError::new(&e, StatusCode::BAD_REQUEST))?;
49 }
50
51 let id = db
52 .register_webhook(Webhook {
53 id: None,
54 event: input.event,
55 callback_url: input.callback_url,
56 name: input.name,
57 secret: input.secret,
58 })
59 .await?;
60
61 Ok(Json(types::RegisterWebhookResponse { id }))
62}
63
64#[utoipa::path(
66 get,
67 path = "/{id}",
68 tag = "webhooks",
69 params(
70 ("id" = String, Path, description = "Webhook ID")
71 ),
72 responses(
73 (status = 200, description = "Webhook details", body = types::GetWebhookResponse),
74 (status = 401, description = "Admin access required")
75 ),
76 security(("cargo_token" = []))
77)]
78pub async fn get_webhook(
79 user: TokenUser,
80 Path(id): Path<String>,
81 State(db): DbState,
82) -> ApiResult<Json<types::GetWebhookResponse>> {
83 trace!(user = %user.name, webhook_id = %id, "Getting webhook");
84 require_admin(&user)?;
85
86 Ok(Json(db.get_webhook(&id).await?.into()))
87}
88
89#[utoipa::path(
91 get,
92 path = "/",
93 tag = "webhooks",
94 responses(
95 (status = 200, description = "List of all webhooks", body = types::GetAllWebhooksResponse),
96 (status = 401, description = "Admin access required")
97 ),
98 security(("cargo_token" = []))
99)]
100pub async fn get_all_webhooks(
101 user: TokenUser,
102 State(db): DbState,
103) -> ApiResult<Json<types::GetAllWebhooksResponse>> {
104 trace!(user = %user.name, "Listing all webhooks");
105 require_admin(&user)?;
106
107 let w = db.get_all_webhooks().await?;
108 Ok(Json(types::GetAllWebhooksResponse(
109 w.into_iter().map(Into::into).collect(),
110 )))
111}
112
113#[utoipa::path(
115 delete,
116 path = "/{id}",
117 tag = "webhooks",
118 params(
119 ("id" = String, Path, description = "Webhook ID")
120 ),
121 responses(
122 (status = 200, description = "Webhook deleted successfully"),
123 (status = 401, description = "Admin access required")
124 ),
125 security(("cargo_token" = []))
126)]
127pub async fn delete_webhook(
128 user: TokenUser,
129 Path(id): Path<String>,
130 State(db): DbState,
131) -> ApiResult<()> {
132 trace!(user = %user.name, webhook_id = %id, "Deleting webhook");
133 require_admin(&user)?;
134
135 db.delete_webhook(&id).await?;
136 Ok(())
137}
138
139#[utoipa::path(
141 post,
142 path = "/{id}/test",
143 tag = "webhooks",
144 params(
145 ("id" = String, Path, description = "Webhook ID")
146 ),
147 responses(
148 (status = 200, description = "Test payload sent successfully"),
149 (status = 400, description = "Invalid callback URL or target address not permitted"),
150 (status = 401, description = "Admin access required"),
151 (status = 500, description = "Webhook callback request failed"),
152 (status = 502, description = "Webhook callback returned a status other than 2xx, which is contained in the error message")
153 ),
154 security(("cargo_token" = []))
155)]
156pub async fn test_webhook(
157 user: TokenUser,
158 Path(id): Path<String>,
159 State(db): DbState,
160 State(settings): SettingsState,
161) -> ApiResult<()> {
162 trace!(user = %user.name, webhook_id = %id, "Testing webhook");
163 require_admin(&user)?;
164
165 let w = db.get_webhook(&id).await?;
166 let client = WebhookClient::new(&settings.webhooks)
167 .map_err(|e| ApiError::from_err(&e, StatusCode::INTERNAL_SERVER_ERROR))?;
168 let msg_id = format!("msg_test_{}", Uuid::new_v4().simple());
169 let delivery = Delivery {
170 id: &msg_id,
171 payload: "Test Payload",
172 secret: w.secret.as_deref(),
173 };
174 let resp = client
175 .post(&w.callback_url, &delivery)
176 .await
177 .map_err(|e| delivery_error_to_api_error(&id, &e))?;
178
179 let status = resp.status();
180 if status.as_u16() < 300 {
181 return Ok(());
182 }
183
184 warn!(
185 webhook_id = %id,
186 "Webhook test callback returned status {status}. Body: {:?}",
187 truncated_body(resp).await
188 );
189 Err(ApiError::new(
194 &format!("Webhook callback returned status {}", status.as_u16()),
195 StatusCode::BAD_GATEWAY,
196 ))
197}
198
199fn delivery_error_to_api_error(webhook_id: &str, err: &DeliveryError) -> ApiError {
200 match err {
201 DeliveryError::InvalidUrl(_)
202 | DeliveryError::BlockedTarget
203 | DeliveryError::InvalidSecret(_) => {
204 ApiError::new(&err.to_string(), StatusCode::BAD_REQUEST)
205 }
206 DeliveryError::Request(_) | DeliveryError::Payload(_) => {
207 warn!(webhook_id, "Webhook test callback failed: {err}");
208 ApiError::new(
209 "Webhook callback request failed",
210 StatusCode::INTERNAL_SERVER_ERROR,
211 )
212 }
213 }
214}
215
216#[cfg(test)]
217mod endpoint_tests {
218 use std::sync::Arc;
219
220 use axum::Router;
221 use axum::body::{Body, to_bytes};
222 use axum::http::{HeaderMap, Request};
223 use axum::response::Response;
224 use axum::routing::{delete, get, post};
225 use hyper::header;
226 use kellnr_common::account_name::UserName;
227 use kellnr_common::plain_token::PlainToken;
228 use kellnr_common::webhook::WebhookEvent;
229 use kellnr_db::{ConString, Database, DbProvider, SqliteConString, UserFlags};
230 use kellnr_http::state::AppStateData;
231 use kellnr_http::test_support::closed_port_url;
232 use serde::de::DeserializeOwned;
233 use tower::ServiceExt;
234
235 use super::*;
236 use crate::tests::{get_test_listener, get_test_listener_with_response};
237 use crate::types::{GetAllWebhooksResponse, GetWebhookResponse, RegisterWebhookResponse};
238
239 const ADMIN_TOKEN: &str = "jkjkashd09128u3019283o1i3j";
240 const NON_ADMIN_TOKEN: &str = "kjas09ed8o1i23k1jh";
241
242 #[tokio::test]
243 async fn test_register_webhook() {
244 let (router, db) = get_app().await;
245
246 let payload = "{\"type\": \"crate_add\", \"callback_url\": \"http://my-service:8000\"}";
247
248 let response = router
249 .clone()
250 .oneshot(
251 Request::post("/api/v1/webhook")
252 .header(header::CONTENT_TYPE, "application/json")
253 .header(header::AUTHORIZATION, ADMIN_TOKEN)
254 .body(Body::from(payload))
255 .unwrap(),
256 )
257 .await
258 .unwrap();
259
260 assert_eq!(200, response.status().as_u16());
261
262 let response: RegisterWebhookResponse = parse_response(response).await;
263
264 let webhook = db.get_webhook(&response.id).await.unwrap();
265 assert_eq!(webhook.event, WebhookEvent::CrateAdd);
266 assert_eq!(webhook.callback_url, "http://my-service:8000".to_string());
267 }
268
269 #[tokio::test]
270 async fn test_register_webhook_invalid_callback_url() {
271 let (router, db) = get_app().await;
272
273 for url in [
274 "not a url",
275 "file:///etc/passwd",
276 "ftp://my-service",
277 "http://169.254.169.254/latest/meta-data",
278 ] {
279 let payload = format!("{{\"type\": \"crate_add\", \"callback_url\": \"{url}\"}}");
280
281 let response = router
282 .clone()
283 .oneshot(
284 Request::post("/api/v1/webhook")
285 .header(header::CONTENT_TYPE, "application/json")
286 .header(header::AUTHORIZATION, ADMIN_TOKEN)
287 .body(Body::from(payload))
288 .unwrap(),
289 )
290 .await
291 .unwrap();
292
293 assert_eq!(400, response.status().as_u16(), "{url}");
294 }
295
296 assert!(db.get_all_webhooks().await.unwrap().is_empty());
297 }
298
299 #[tokio::test]
300 async fn test_register_webhook_with_secret_is_never_returned() {
301 let (router, db) = get_app().await;
302 let secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
303
304 let payload = format!(
305 "{{\"type\": \"crate_add\", \"callback_url\": \"http://my-service:8000\", \"secret\": \"{secret}\"}}"
306 );
307 let response = router
308 .clone()
309 .oneshot(
310 Request::post("/api/v1/webhook")
311 .header(header::CONTENT_TYPE, "application/json")
312 .header(header::AUTHORIZATION, ADMIN_TOKEN)
313 .body(Body::from(payload))
314 .unwrap(),
315 )
316 .await
317 .unwrap();
318 assert_eq!(200, response.status().as_u16());
319 let response: RegisterWebhookResponse = parse_response(response).await;
320 let id = response.id;
321 assert_eq!(
322 Some(secret.to_string()),
323 db.get_webhook(&id).await.unwrap().secret
324 );
325
326 for uri in [
327 format!("/api/v1/webhook/{id}"),
328 "/api/v1/webhook".to_string(),
329 ] {
330 let response = router
331 .clone()
332 .oneshot(
333 Request::get(uri)
334 .header(header::AUTHORIZATION, ADMIN_TOKEN)
335 .body(Body::empty())
336 .unwrap(),
337 )
338 .await
339 .unwrap();
340 assert_eq!(200, response.status().as_u16());
341 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
342 let body = String::from_utf8_lossy(&body);
343 assert!(!body.contains("MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"));
344 assert!(!body.contains("\"secret\""));
345 assert!(body.contains("\"has_secret\":true"));
346 }
347 }
348
349 #[tokio::test]
350 async fn test_register_webhook_invalid_secret() {
351 let (router, db) = get_app().await;
352
353 let payload = "{\"type\": \"crate_add\", \"callback_url\": \"http://my-service:8000\", \"secret\": \"too-short\"}";
354 let response = router
355 .clone()
356 .oneshot(
357 Request::post("/api/v1/webhook")
358 .header(header::CONTENT_TYPE, "application/json")
359 .header(header::AUTHORIZATION, ADMIN_TOKEN)
360 .body(Body::from(payload))
361 .unwrap(),
362 )
363 .await
364 .unwrap();
365
366 assert_eq!(400, response.status().as_u16());
367 assert!(db.get_all_webhooks().await.unwrap().is_empty());
368 }
369
370 #[tokio::test]
371 async fn test_register_webhook_non_admin() {
372 let (router, _) = get_app().await;
373
374 let payload = "{\"type\": \"crate_add\", \"callback_url\": \"http://my-service:8000\"}";
375
376 let response = router
377 .clone()
378 .oneshot(
379 Request::post("/api/v1/webhook")
380 .header(header::CONTENT_TYPE, "application/json")
381 .header(header::AUTHORIZATION, NON_ADMIN_TOKEN)
382 .body(Body::from(payload))
383 .unwrap(),
384 )
385 .await
386 .unwrap();
387
388 assert_eq!(403, response.status().as_u16());
389 }
390
391 #[tokio::test]
392 async fn test_get_webhook() {
393 let (router, db) = get_app().await;
394
395 let id = db.register_webhook(sample_webhook()).await.unwrap();
396
397 let response = router
398 .clone()
399 .oneshot(
400 Request::get(format!("/api/v1/webhook/{id}"))
401 .header(header::AUTHORIZATION, ADMIN_TOKEN)
402 .body(Body::empty())
403 .unwrap(),
404 )
405 .await
406 .unwrap();
407
408 assert_eq!(200, response.status().as_u16());
409
410 let response: GetWebhookResponse = parse_response(response).await;
411
412 assert_eq!(response.event, WebhookEvent::CrateUpdate);
413 assert_eq!(response.callback_url, sample_webhook().callback_url);
414 assert_eq!(response.name, sample_webhook().name);
415 }
416
417 #[tokio::test]
418 async fn test_get_webhook_non_admin() {
419 let (router, db) = get_app().await;
420
421 let id = db.register_webhook(sample_webhook()).await.unwrap();
422
423 let response = router
424 .clone()
425 .oneshot(
426 Request::get(format!("/api/v1/webhook/{id}"))
427 .header(header::AUTHORIZATION, NON_ADMIN_TOKEN)
428 .body(Body::empty())
429 .unwrap(),
430 )
431 .await
432 .unwrap();
433
434 assert_eq!(403, response.status().as_u16());
435 }
436
437 #[tokio::test]
438 async fn test_get_all_webhooks() {
439 let (router, db) = get_app().await;
440
441 let mut ids = vec![];
442 for _ in 0..5 {
443 ids.push(db.register_webhook(sample_webhook()).await.unwrap());
444 }
445
446 let response = router
447 .clone()
448 .oneshot(
449 Request::get("/api/v1/webhook")
450 .header(header::AUTHORIZATION, ADMIN_TOKEN)
451 .body(Body::empty())
452 .unwrap(),
453 )
454 .await
455 .unwrap();
456
457 assert_eq!(200, response.status().as_u16());
458 let response: GetAllWebhooksResponse = parse_response(response).await;
459
460 assert_eq!(5, response.0.len());
461 }
462
463 #[tokio::test]
464 async fn test_get_all_webhooks_non_admin() {
465 let (router, db) = get_app().await;
466
467 let mut ids = vec![];
468 for _ in 0..2 {
469 ids.push(db.register_webhook(sample_webhook()).await.unwrap());
470 }
471
472 let response = router
473 .clone()
474 .oneshot(
475 Request::get("/api/v1/webhook")
476 .header(header::AUTHORIZATION, NON_ADMIN_TOKEN)
477 .body(Body::empty())
478 .unwrap(),
479 )
480 .await
481 .unwrap();
482
483 assert_eq!(403, response.status().as_u16());
484 }
485
486 #[tokio::test]
487 async fn test_delete_webhook() {
488 let (router, db) = get_app().await;
489
490 let id = db.register_webhook(sample_webhook()).await.unwrap();
491
492 let response = router
493 .clone()
494 .oneshot(
495 Request::delete(format!("/api/v1/webhook/{id}"))
496 .header(header::AUTHORIZATION, ADMIN_TOKEN)
497 .body(Body::empty())
498 .unwrap(),
499 )
500 .await
501 .unwrap();
502
503 assert_eq!(200, response.status().as_u16());
504
505 let result = db.get_webhook(&id).await;
506 assert!(result.is_err());
507 }
508
509 #[tokio::test]
510 async fn test_delete_webhook_non_admin() {
511 let (router, db) = get_app().await;
512
513 let id = db.register_webhook(sample_webhook()).await.unwrap();
514
515 let response = router
516 .clone()
517 .oneshot(
518 Request::delete(format!("/api/v1/webhook/{id}"))
519 .header(header::AUTHORIZATION, NON_ADMIN_TOKEN)
520 .body(Body::empty())
521 .unwrap(),
522 )
523 .await
524 .unwrap();
525
526 assert_eq!(403, response.status().as_u16());
527
528 let result = db.get_webhook(&id).await;
529 assert!(result.is_ok());
530 }
531
532 #[tokio::test]
533 async fn test_test_webhook() {
534 let (router, db) = get_app().await;
535 let mut listener = get_test_listener(200).await;
536
537 let mut webhook = sample_webhook();
538 webhook.callback_url = listener.url();
539 let id = db.register_webhook(webhook).await.unwrap();
540
541 let response = router
542 .clone()
543 .oneshot(
544 Request::get(format!("/api/v1/webhook/{id}/test"))
545 .header(header::AUTHORIZATION, ADMIN_TOKEN)
546 .body(Body::empty())
547 .unwrap(),
548 )
549 .await
550 .unwrap();
551
552 assert_eq!(200, response.status().as_u16());
553
554 listener.rx.recv().await.unwrap();
555 }
556
557 #[tokio::test]
558 async fn test_test_webhook_does_not_return_callback_body() {
559 let (router, db) = get_app().await;
560 let body = "internal-callback-details";
561 let listener =
562 get_test_listener_with_response(503, HeaderMap::new(), body.to_string()).await;
563
564 let mut webhook = sample_webhook();
565 webhook.callback_url = listener.url();
566 let id = db.register_webhook(webhook).await.unwrap();
567
568 let response = router
569 .clone()
570 .oneshot(
571 Request::get(format!("/api/v1/webhook/{id}/test"))
572 .header(header::AUTHORIZATION, ADMIN_TOKEN)
573 .body(Body::empty())
574 .unwrap(),
575 )
576 .await
577 .unwrap();
578
579 assert_eq!(502, response.status().as_u16());
580 let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
581 let response_body = String::from_utf8_lossy(&response_body);
582 assert!(!response_body.contains(body));
583 assert!(response_body.contains("503"));
584 }
585
586 #[tokio::test]
587 async fn test_test_webhook_returns_502_for_unauthorized_callback() {
588 let (router, db) = get_app().await;
589 let listener = get_test_listener(401).await;
590 let mut webhook = sample_webhook();
591 webhook.callback_url = listener.url();
592 let id = db.register_webhook(webhook).await.unwrap();
593
594 let response = router
595 .clone()
596 .oneshot(
597 Request::get(format!("/api/v1/webhook/{id}/test"))
598 .header(header::AUTHORIZATION, ADMIN_TOKEN)
599 .body(Body::empty())
600 .unwrap(),
601 )
602 .await
603 .unwrap();
604
605 assert_eq!(502, response.status().as_u16());
606 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
607 assert!(String::from_utf8_lossy(&body).contains("401"));
608 }
609
610 #[tokio::test]
611 async fn test_test_webhook_does_not_return_request_error() {
612 let (router, db) = get_app().await;
613
614 let mut webhook = sample_webhook();
615 webhook.callback_url = closed_port_url().await;
616 let id = db.register_webhook(webhook).await.unwrap();
617
618 let response = router
619 .clone()
620 .oneshot(
621 Request::get(format!("/api/v1/webhook/{id}/test"))
622 .header(header::AUTHORIZATION, ADMIN_TOKEN)
623 .body(Body::empty())
624 .unwrap(),
625 )
626 .await
627 .unwrap();
628
629 assert_eq!(500, response.status().as_u16());
630 let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
631 let response_body = String::from_utf8_lossy(&response_body);
632 assert!(!response_body.contains("127.0.0.1"));
633 assert!(response_body.contains("Webhook callback request failed"));
634 }
635
636 #[tokio::test]
637 async fn test_test_webhook_non_admin() {
638 let (router, db) = get_app().await;
639 let mut listener = get_test_listener(200).await;
640
641 let mut webhook = sample_webhook();
642 webhook.callback_url = listener.url();
643 let id = db.register_webhook(webhook).await.unwrap();
644
645 let response = router
646 .clone()
647 .oneshot(
648 Request::get(format!("/api/v1/webhook/{id}/test"))
649 .header(header::AUTHORIZATION, NON_ADMIN_TOKEN)
650 .body(Body::empty())
651 .unwrap(),
652 )
653 .await
654 .unwrap();
655
656 assert_eq!(403, response.status().as_u16());
657
658 assert!(listener.rx.try_recv().is_err());
659 }
660
661 async fn get_app() -> (Router, Arc<Database>) {
662 let con_string = ConString::Sqlite(SqliteConString::new(
663 std::path::Path::new(":memory:"),
664 "admin",
665 Some("token".to_string()),
666 kellnr_settings::SessionAge::from_secs(10),
667 ));
668 let db = Arc::new(Database::new(&con_string, 1).await.unwrap());
669
670 db.add_auth_token(
671 "wh_test_admin",
672 &PlainToken::from(ADMIN_TOKEN),
673 &UserName::new("admin").unwrap(),
674 )
675 .await
676 .unwrap();
677 db.add_user(
678 &UserName::new("wh_non_admin").unwrap(),
679 "na",
680 UserFlags::normal(),
681 )
682 .await
683 .unwrap();
684 db.add_auth_token(
685 "wh_non_admin",
686 &PlainToken::from(NON_ADMIN_TOKEN),
687 &UserName::new("wh_non_admin").unwrap(),
688 )
689 .await
690 .unwrap();
691
692 let state = AppStateData {
693 db: db.clone(),
694 ..kellnr_http::test_support::test_state()
695 };
696
697 let routes = Router::new()
698 .route("/", get(get_all_webhooks))
699 .route("/", post(register_webhook))
700 .route("/{id}", get(get_webhook))
701 .route("/{id}", delete(delete_webhook))
702 .route("/{id}/test", get(test_webhook));
703
704 (
705 Router::new()
706 .nest("/api/v1/webhook", routes)
707 .with_state(state),
708 db,
709 )
710 }
711
712 async fn parse_response<T: DeserializeOwned>(response: Response<Body>) -> T {
713 serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap()
714 }
715
716 fn sample_webhook() -> Webhook {
717 Webhook {
718 id: None,
719 event: WebhookEvent::CrateUpdate,
720 callback_url: "https://some-callback:8000".to_string(),
721 name: Some("My callback".to_string()),
722 secret: None,
723 }
724 }
725}