From d19d58ebc4300073214ec75689558570e42ec3f8 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sat, 20 Jun 2026 14:51:23 +0200 Subject: [PATCH] fix: validate datatable migration name and datatable at the API boundary --- .../windmill-api-workspaces/src/workspaces.rs | 39 +++++++++++++++++++ .../NewDataTableMigrationModal.svelte | 4 ++ 2 files changed, 43 insertions(+) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 44f686ad58..466a9f44a0 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -2844,6 +2844,11 @@ async fn update_datatable_migrations( ) -> Result { require_admin(authed.is_admin, &authed.username)?; + for m in &payload.migrations { + validate_datatable_path_segment(&m.datatable)?; + validate_migration_name(&m.name)?; + } + let datatables: Vec = payload .migrations .iter() @@ -3014,6 +3019,36 @@ pub struct CreateDatatableMigration { pub code_down: Option, } +/// Migration names map onto on-disk file names and the `_wm_migrations` record, +/// so keep them to a safe path-segment charset (matches the CLI scaffold). +fn validate_migration_name(name: &str) -> Result<()> { + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(Error::BadRequest(format!( + "Invalid migration name '{name}': use only letters, digits, '_' and '-'" + ))); + } + Ok(()) +} + +/// The data table name becomes a directory segment in the sync export +/// (`migrations/datatable//...`); reject anything that could escape it. +fn validate_datatable_path_segment(datatable: &str) -> Result<()> { + if datatable.is_empty() + || datatable.contains('/') + || datatable.contains('\\') + || datatable.contains("..") + { + return Err(Error::BadRequest(format!( + "Invalid data table name '{datatable}': must not contain '/', '\\' or '..'" + ))); + } + Ok(()) +} + /// Create a single migration for a data table. The version is generated /// server-side (current UTC `YYYYMMDDHHMMSS`), bumped past any existing version /// so it stays unique and monotonically increasing. @@ -3024,6 +3059,8 @@ async fn create_datatable_migration( Json(payload): Json, ) -> JsonResult { require_admin(authed.is_admin, &authed.username)?; + validate_datatable_path_segment(&datatable_name)?; + validate_migration_name(&payload.name)?; let now_ts: i64 = Utc::now() .format("%Y%m%d%H%M%S") @@ -3129,6 +3166,8 @@ async fn upsert_datatable_migration( Json(payload): Json, ) -> Result { require_admin(authed.is_admin, &authed.username)?; + validate_datatable_path_segment(&datatable_name)?; + validate_migration_name(&payload.name)?; sqlx::query!( "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down) \ diff --git a/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte b/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte index e376981036..28fb0819e1 100644 --- a/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte +++ b/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte @@ -59,6 +59,10 @@ sendUserToast('Migration name is required', true) return } + if (!/^[a-zA-Z0-9_-]+$/.test(name.trim())) { + sendUserToast("Invalid migration name: use only letters, digits, '_' and '-'", true) + return + } creating = true try { const created = await WorkspaceService.createDatatableMigration({