fix(auth): require workspace membership for g/ owner tokens (WIN-1986)

WIN-1978's tightening of the `g/<groupname>` owner branch only verified
that the group exists in the target workspace. The default `all` group
is provisioned in every workspace, so any forged `owner=g/all` token
paired with an arbitrary `workspace_id` still passed the existence check
and authenticated as a group user there — including for an email that
was not a member of that workspace.

Additionally require the token holder's email to be a non-disabled
member of the target workspace, mirroring the spirit of the `u/`
branch's `usr` lookup. Adds an integration regression covering both the
forged non-member case and the legitimate member case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-05-25 15:05:40 +00:00
co-authored by Claude Opus 4.7
parent ff685eb2d3
commit 593522033a
3 changed files with 125 additions and 4 deletions
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(\n SELECT 1 FROM group_\n WHERE workspace_id = $1 AND name = $2\n ) AND EXISTS(\n SELECT 1 FROM usr\n WHERE workspace_id = $1\n AND email = $3\n AND disabled = false\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "ce9492ecccdcf4747990a94208dc54efefdeb8261142cc75010a51f209c099a2"
}
+25 -4
View File
@@ -302,13 +302,34 @@ impl AuthCache {
None
}
} else if prefix == "g" {
let group_exists = if super_admin {
// Checking that the group exists in the
// target workspace is necessary but not
// sufficient: the `all` group is
// provisioned in every workspace, so an
// attacker with token-table write access
// could pair owner=g/all with any
// workspace_id and authenticate as a
// group user there without being a
// workspace member. Additionally require
// that the token holder (by email) is a
// non-disabled member of the target
// workspace, mirroring the u/ branch.
let group_check = if super_admin {
true
} else {
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM group_ WHERE workspace_id = $1 AND name = $2)",
"SELECT EXISTS(
SELECT 1 FROM group_
WHERE workspace_id = $1 AND name = $2
) AND EXISTS(
SELECT 1 FROM usr
WHERE workspace_id = $1
AND email = $3
AND disabled = false
)",
&w_id.as_ref().unwrap(),
name,
&email,
)
.fetch_one(&self.db)
.await
@@ -317,7 +338,7 @@ impl AuthCache {
.unwrap_or(false)
};
if group_exists {
if group_check {
let groups = vec![name.to_string()];
let folders = get_folders_for_user(
&w_id.unwrap(),
@@ -345,7 +366,7 @@ impl AuthCache {
})
} else {
tracing::warn!(
"Token owner g/{} is not a group in workspace {}; rejecting auth",
"Token owner g/{} is not a valid group/workspace-member combination for workspace {}; rejecting auth",
name,
w_id.as_deref().unwrap_or("")
);
@@ -854,3 +854,79 @@ async fn test_operator_cannot_create_update(db: Pool<Postgres>) -> anyhow::Resul
Ok(())
}
/// Regression test for WIN-1986: WIN-1978's `g/<groupname>` fix only
/// validated that the group exists in the target workspace, but the
/// default `all` group is provisioned in every workspace. An attacker
/// with token-table write access could therefore pair `owner=g/all` with
/// any workspace_id and authenticate as a group user there without being
/// a workspace member. The fix additionally requires the token holder's
/// email to be a non-disabled member of the target workspace.
#[ignore]
#[cfg(feature = "deno_core")]
#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))]
async fn test_forged_g_all_owner_rejects_non_member(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base_url = format!("http://localhost:{}/api", port);
// The `all` group is provisioned for `test-workspace` by base.sql,
// matching production behaviour. Insert a token whose email is NOT
// a member of `test-workspace`, with owner=g/all and workspace_id
// pointing at the target workspace. This simulates an attacker who
// mutated the `token` table to forge cross-workspace access.
let forged_token = "FORGED_GALL_NONMEMBER";
let forged_hash = windmill_common::utils::calculate_hash(forged_token);
let forged_prefix = &forged_token[..10.min(forged_token.len())];
sqlx::query!(
"INSERT INTO token (token_hash, token_prefix, email, label, super_admin, owner, workspace_id)
VALUES ($1, $2, 'outsider@example.com', 'forged-gall', false, 'g/all', 'test-workspace')",
forged_hash,
forged_prefix,
)
.execute(&db)
.await?;
let client = create_client_for_user(port, forged_token).await;
let resp = client
.get(&format!("{base_url}/w/test-workspace/scripts/list"))
.send()
.await
.unwrap();
assert!(
resp.status() == reqwest::StatusCode::UNAUTHORIZED
|| resp.status() == reqwest::StatusCode::FORBIDDEN,
"Forged g/all owner with non-member email must be rejected against test-workspace; got {}",
resp.status()
);
// Legitimate sanity check: a g/<group> token whose email IS a
// workspace member should still authenticate. Alice is in
// test-workspace per the permissions_test fixture.
let alice_gall_token = "ALICE_GALL_TOKEN_OK";
let alice_gall_hash = windmill_common::utils::calculate_hash(alice_gall_token);
let alice_gall_prefix = &alice_gall_token[..10.min(alice_gall_token.len())];
sqlx::query!(
"INSERT INTO token (token_hash, token_prefix, email, label, super_admin, owner, workspace_id)
VALUES ($1, $2, 'alice@windmill.dev', 'gall-ok', false, 'g/all', 'test-workspace')",
alice_gall_hash,
alice_gall_prefix,
)
.execute(&db)
.await?;
let alice_client = create_client_for_user(port, alice_gall_token).await;
let resp = alice_client
.get(&format!("{base_url}/w/test-workspace/scripts/list"))
.send()
.await
.unwrap();
assert!(
resp.status().is_success(),
"g/all token for a real workspace member should authenticate; got {}",
resp.status()
);
Ok(())
}