mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
fix: enforce folder ACL on flow run-by-version routes (#9202)
* fix: enforce folder ACL on flow run-by-version routes (GHSA-8mv7-hmrg-96xv) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: don't echo resolved flow path in version-route NotAuthorized (cubic P2) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: remove GHSA-8mv7-hmrg-96xv regression test (verified locally pre-removal) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT flow_version.path FROM flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE flow_version.id = $1 AND flow_version.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0476ae2245aa678a50c5fd04cdee32cc151e29b177cc85caa088430b16336373"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM flow_version WHERE id = $1 AND workspace_id = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e70cbc2a48bfc5c7d2018b9367eadc104e87126c57230c9ed8eb3987e54b53a1"
|
||||
}
|
||||
@@ -112,9 +112,9 @@ use windmill_common::{
|
||||
};
|
||||
|
||||
use windmill_common::{
|
||||
get_flow_version_info_from_version, get_latest_deployed_hash_for_path,
|
||||
get_latest_flow_version_info_for_path, get_script_info_for_hash, utils::empty_as_none,
|
||||
ScriptHashInfo, BASE_URL,
|
||||
get_flow_path_for_version_authed, get_flow_version_info_from_version,
|
||||
get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path,
|
||||
get_script_info_for_hash, utils::empty_as_none, ScriptHashInfo, BASE_URL,
|
||||
};
|
||||
use windmill_queue::{
|
||||
get_result_and_success_by_id_from_flow, job_is_complete, push, PushArgs, PushArgsOwned,
|
||||
@@ -4048,21 +4048,8 @@ pub async fn run_flow_by_version_inner(
|
||||
#[cfg(feature = "enterprise")]
|
||||
check_license_key_valid().await?;
|
||||
|
||||
let flow_path = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT
|
||||
path
|
||||
FROM
|
||||
flow_version
|
||||
WHERE
|
||||
id = $1 AND
|
||||
workspace_id = $2
|
||||
"#,
|
||||
version,
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
|
||||
let flow_path = get_flow_path_for_version_authed(&userdb_authed, &db, version, &w_id).await?;
|
||||
|
||||
check_scopes(&authed, || format!("jobs:run:flows:{flow_path}"))?;
|
||||
|
||||
@@ -5550,13 +5537,8 @@ pub async fn run_wait_result_flow_by_version_get(
|
||||
#[cfg(feature = "enterprise")]
|
||||
check_license_key_valid().await?;
|
||||
|
||||
let flow_path = sqlx::query_scalar!(
|
||||
"SELECT path FROM flow_version WHERE id = $1 AND workspace_id = $2",
|
||||
version,
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
|
||||
let flow_path = get_flow_path_for_version_authed(&userdb_authed, &db, version, &w_id).await?;
|
||||
|
||||
check_scopes(&authed, || format!("jobs:run:flows:{flow_path}"))?;
|
||||
|
||||
@@ -5606,21 +5588,8 @@ pub async fn run_wait_result_flow_by_version(
|
||||
#[cfg(feature = "enterprise")]
|
||||
check_license_key_valid().await?;
|
||||
|
||||
let flow_path = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT
|
||||
path
|
||||
FROM
|
||||
flow_version
|
||||
WHERE
|
||||
id = $1 AND
|
||||
workspace_id = $2
|
||||
"#,
|
||||
version,
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
let userdb_authed = UserDbWithAuthed { db: user_db.clone(), authed: &authed.to_authed_ref() };
|
||||
let flow_path = get_flow_path_for_version_authed(&userdb_authed, &db, version, &w_id).await?;
|
||||
|
||||
check_scopes(&authed, || format!("jobs:run:flows:{flow_path}"))?;
|
||||
|
||||
|
||||
@@ -1543,6 +1543,59 @@ pub fn get_flow_version_info_from_version<
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a `flow_version.id` to its flow path while enforcing the caller's
|
||||
/// folder-level ACL. The `flow_version` table has no row-level security, so the
|
||||
/// authorization gate is an RLS-filtered lookup against the `flow` table through
|
||||
/// `user_db`. Mirrors the "exists but not authorized -> NotAuthorized" semantics
|
||||
/// of [`get_latest_flow_version_id_for_path`] so version-keyed run routes are
|
||||
/// gated identically to their path-keyed siblings.
|
||||
pub async fn get_flow_path_for_version_authed(
|
||||
db_authed: &UserDbWithAuthed<'_, AuthedRef<'_>>,
|
||||
db: &DB,
|
||||
version: i64,
|
||||
w_id: &str,
|
||||
) -> error::Result<String> {
|
||||
let mut conn = db_authed.acquire().await?;
|
||||
let authed_path = sqlx::query_scalar!(
|
||||
"SELECT flow_version.path FROM flow_version
|
||||
INNER JOIN flow
|
||||
ON flow.path = flow_version.path AND
|
||||
flow.workspace_id = flow_version.workspace_id
|
||||
WHERE flow_version.id = $1 AND flow_version.workspace_id = $2",
|
||||
version,
|
||||
w_id,
|
||||
)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?;
|
||||
|
||||
if let Some(path) = authed_path {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM flow_version WHERE id = $1 AND workspace_id = $2)",
|
||||
version,
|
||||
w_id,
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
// Unlike the path-keyed sibling (where the caller already supplied the
|
||||
// path), here the caller only supplied an opaque version id. Echoing
|
||||
// back the resolved path would disclose an id->path mapping for a flow
|
||||
// they cannot access, so the message is intentionally generic.
|
||||
return Err(Error::NotAuthorized(
|
||||
"You are not authorized to run this flow version".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Err(Error::NotFound(format!(
|
||||
"flow_version not found at id {version}"
|
||||
)))
|
||||
}
|
||||
|
||||
pub async fn get_latest_flow_version_info_for_path<'e>(
|
||||
db_authed: Option<UserDbWithAuthed<'e, AuthedRef<'e>>>,
|
||||
db: &DB,
|
||||
|
||||
Reference in New Issue
Block a user