From 31c2dffc8e6b85a77d75b82c585c41ea6be58619 Mon Sep 17 00:00:00 2001 From: tristantr Date: Mon, 8 Jun 2026 17:56:05 +0200 Subject: [PATCH] fix(hub-publish): validate project slug before interpolating into Hub path slug/project_slug are caller-controlled and were interpolated straight into the Hub request path; a crafted value (e.g. ../../admin) could reach an unintended Hub endpoint after URL normalization. Validate against the frontend charset (lowercase alphanumerics + hyphens, 3-50 chars) in the four handlers that put the slug in the path. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/windmill-api/src/hub_publish.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/backend/windmill-api/src/hub_publish.rs b/backend/windmill-api/src/hub_publish.rs index bdf4bf1093..e668c0e655 100644 --- a/backend/windmill-api/src/hub_publish.rs +++ b/backend/windmill-api/src/hub_publish.rs @@ -33,6 +33,21 @@ pub fn workspaced_service() -> Router { .route("/projects/{slug}/export", get(get_project_export)) } +fn validate_project_slug(slug: &str) -> Result<(), Error> { + let ok = slug.len() >= 3 + && slug.len() <= 50 + && !slug.starts_with('-') + && !slug.ends_with('-') + && slug + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + if ok { + Ok(()) + } else { + Err(Error::BadRequest(format!("invalid project slug: {slug}"))) + } +} + #[derive(Deserialize)] struct PublishDraftBody { slug: String, @@ -231,6 +246,7 @@ async fn publish_resource_type( Json(body): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; + validate_project_slug(&body.project_slug)?; forward_to_hub( &format!("/projects/{}/resource_types", body.project_slug), &workspace, @@ -259,6 +275,7 @@ async fn publish_resources( Json(body): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; + validate_project_slug(&body.project_slug)?; forward_to_hub( &format!("/projects/{}/resources", body.project_slug), &workspace, @@ -294,6 +311,7 @@ async fn publish_triggers( Json(body): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; + validate_project_slug(&body.project_slug)?; forward_to_hub( &format!("/projects/{}/triggers", body.project_slug), &workspace, @@ -307,6 +325,7 @@ async fn get_project_export( Path((workspace, slug)): Path<(String, String)>, ) -> Result { require_admin(authed.is_admin, &authed.username)?; + validate_project_slug(&slug)?; get_from_hub(&format!("/projects/{}/export", slug), &workspace).await }