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:
Ruben Fiszel
2026-05-17 14:36:24 +00:00
committed by GitHub
parent 8bc2295b94
commit ab11c7747a
4 changed files with 108 additions and 40 deletions
@@ -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"
}
@@ -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"
}
+9 -40
View File
@@ -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}"))?;
+53
View File
@@ -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,