Files
Ruben Fiszel 188647a942 fix(security): enforce variables:write scope on resource-delete var cascade (GHSA-xmr2-98m6-cjf7) (#10123)
A token scoped only to resources:write:<path> could delete linked secret
variables it had no variables:write scope for, by embedding $var:<victim>
in an attacker-controlled resource value and triggering the resource-delete
cascade. #9712 re-enforced scoped-token boundaries broadly but missed this path.

Add check_linked_var_delete_scopes, called before the cascade in both
delete_resource and delete_resources_bulk: require variables:write for every
linked variable, failing (and rolling back) the delete otherwise. No-op for
unscoped tokens, so full-token cascade cleanup is unchanged.

No co-located-path exemption: a resource and a variable may share a path, and a
resource-write token can create a resource over an existing standalone variable
and self-reference it, so "same path as the deleted resource" is attacker-
forgeable and cannot stand in for variable scope.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:19:17 +02:00

452 lines
14 KiB
Rust

//! Integration tests for the workspace-specific (ws_specific) feature.
//!
//! Covers three regression-prone areas:
//!
//! 1. **Linked-delete cleanup** — deleting a resource (or variable) must also
//! drop the cross-kind ws_specific row that was auto-inserted by
//! `mark_linked_variables_ws_specific` so a later item recreated at the
//! same path doesn't inherit a stale flag.
//! 2. **list_ws_specific authorization filtering** — the endpoint must hide
//! paths the caller cannot see via the underlying resource/variable RLS.
//! 3. **Resource upsert with `ws_specific: false`** — `create_resource` with
//! `update_if_exists=true` and `ws_specific: false` must clear an
//! existing flag (was previously a silent no-op).
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
/// Helper: does a variable exist at (workspace, path)?
async fn variable_exists(db: &Pool<Postgres>, workspace: &str, path: &str) -> anyhow::Result<bool> {
let n: Option<i64> =
sqlx::query_scalar("SELECT COUNT(*) FROM variable WHERE workspace_id = $1 AND path = $2")
.bind(workspace)
.bind(path)
.fetch_one(db)
.await?;
Ok(n.unwrap_or(0) > 0)
}
/// Helper: mint an API token restricted to `scopes`, using the admin SECRET_TOKEN.
async fn mint_scoped_token(port: u16, scopes: Vec<&str>) -> anyhow::Result<String> {
let resp = authed(
client().post(format!("http://localhost:{port}/api/users/tokens/create")),
"SECRET_TOKEN",
)
.json(&json!({
"label": "scoped",
"scopes": scopes,
"workspace_id": "test-workspace",
}))
.send()
.await?;
assert_eq!(
resp.status(),
201,
"mint scoped token: {}",
resp.text().await?
);
Ok(resp.text().await?)
}
/// Helper: count rows in ws_specific for (workspace, kind, path).
async fn ws_specific_row_count(
db: &Pool<Postgres>,
workspace: &str,
kind: &str,
path: &str,
) -> anyhow::Result<i64> {
let n: Option<i64> = sqlx::query_scalar(
"SELECT COUNT(*) FROM ws_specific
WHERE workspace_id = $1 AND item_kind = $2 AND path = $3",
)
.bind(workspace)
.bind(kind)
.bind(path)
.fetch_one(db)
.await?;
Ok(n.unwrap_or(0))
}
#[sqlx::test(fixtures("ws_specific"))]
async fn test_linked_delete_cleanup(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace");
// Create a referenced variable.
let resp = authed(
client().post(format!("{base}/variables/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/db_pwd",
"value": "hunter2",
"is_secret": false,
"description": ""
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "create var: {}", resp.text().await?);
// Create a ws_specific resource that references the variable via $var:.
let resp = authed(
client().post(format!("{base}/resources/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/db",
"value": { "user": "admin", "password": "$var:u/test-user/db_pwd" },
"description": "",
"resource_type": "object",
"ws_specific": true
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "create res: {}", resp.text().await?);
// The auto-mark on save inserts a ws_specific 'variable' row for the
// linked variable.
assert_eq!(
ws_specific_row_count(&db, "test-workspace", "variable", "u/test-user/db_pwd").await?,
1,
"linked variable should be auto-marked ws_specific"
);
assert_eq!(
ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/db").await?,
1
);
// Delete the resource — it should cascade to the linked variable AND
// the ws_specific row for that variable.
let resp = authed(
client().delete(format!("{base}/resources/delete/u/test-user/db")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "delete res: {}", resp.text().await?);
assert_eq!(
ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/db").await?,
0,
"resource ws_specific row should be gone"
);
assert_eq!(
ws_specific_row_count(&db, "test-workspace", "variable", "u/test-user/db_pwd").await?,
0,
"orphaned linked-variable ws_specific row should also be gone"
);
// The same fix applies in reverse: delete_variable must clean the
// ws_specific 'resource' row at the same path.
let resp = authed(
client().post(format!("{base}/variables/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/twin",
"value": "v",
"is_secret": false,
"description": ""
}))
.send()
.await?;
assert_eq!(resp.status(), 201);
let resp = authed(
client().post(format!("{base}/resources/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/twin",
"value": { "x": 1 },
"resource_type": "object",
"ws_specific": true
}))
.send()
.await?;
assert_eq!(resp.status(), 201);
// ws_specific row exists for resource at u/test-user/twin
assert_eq!(
ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/twin").await?,
1
);
// Delete the variable at the same path: cascades to the resource AND
// its ws_specific row.
let resp = authed(
client().delete(format!("{base}/variables/delete/u/test-user/twin")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "delete var: {}", resp.text().await?);
assert_eq!(
ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/twin").await?,
0,
"ws_specific resource row should be cleaned by variable delete"
);
Ok(())
}
#[sqlx::test(fixtures("ws_specific"))]
async fn test_list_ws_specific_filters_by_rls(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace");
// Admin creates two ws_specific items: one in u/test-user/* (private to
// test-user) and one in u/test-user-2/* (private to test-user-2).
for path in ["u/test-user/admin_only", "u/test-user-2/user2_only"] {
let resp = authed(
client().post(format!("{base}/variables/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": path,
"value": "v",
"is_secret": false,
"description": "",
"ws_specific": true
}))
.send()
.await?;
assert_eq!(
resp.status(),
201,
"create var {path}: {}",
resp.text().await?
);
}
// Admin sees both via list_ws_specific.
let resp = authed(
client().get(format!("{base}/workspaces/list_ws_specific")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200);
let admin_items: Vec<serde_json::Value> = resp.json().await?;
let admin_paths: Vec<&str> = admin_items
.iter()
.filter_map(|i| i.get("path").and_then(|p| p.as_str()))
.collect();
assert!(admin_paths.contains(&"u/test-user/admin_only"));
assert!(admin_paths.contains(&"u/test-user-2/user2_only"));
// Non-admin (test-user-2) only sees their own u/test-user-2/* path —
// u/test-user/admin_only is filtered by RLS see_own (path requires
// SPLIT_PART(path,'/',2) = session.user).
let resp = authed(
client().get(format!("{base}/workspaces/list_ws_specific")),
"SECRET_TOKEN_2",
)
.send()
.await?;
assert_eq!(resp.status(), 200);
let user2_items: Vec<serde_json::Value> = resp.json().await?;
let user2_paths: Vec<&str> = user2_items
.iter()
.filter_map(|i| i.get("path").and_then(|p| p.as_str()))
.collect();
assert!(
user2_paths.contains(&"u/test-user-2/user2_only"),
"user2 should see their own ws_specific item, got: {user2_paths:?}"
);
assert!(
!user2_paths.contains(&"u/test-user/admin_only"),
"user2 should NOT see admin's ws_specific item, got: {user2_paths:?}"
);
Ok(())
}
#[sqlx::test(fixtures("ws_specific"))]
async fn test_create_resource_upsert_clears_ws_specific(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace");
// Step 1: create resource with ws_specific=true.
let resp = authed(
client().post(format!("{base}/resources/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/upsert_target",
"value": { "host": "h" },
"resource_type": "object",
"ws_specific": true
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
assert_eq!(
ws_specific_row_count(
&db,
"test-workspace",
"resource",
"u/test-user/upsert_target"
)
.await?,
1
);
// Step 2: upsert (update_if_exists=true) with ws_specific=false — must
// CLEAR the existing row.
let resp = authed(
client().post(format!("{base}/resources/create?update_if_exists=true")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/upsert_target",
"value": { "host": "h2" },
"resource_type": "object",
"ws_specific": false
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
assert_eq!(
ws_specific_row_count(
&db,
"test-workspace",
"resource",
"u/test-user/upsert_target"
)
.await?,
0,
"ws_specific=false on upsert must clear the existing row"
);
// Step 3: upsert without ws_specific (None) leaves whatever's there
// alone — re-flag it true, then upsert with no field, expect row stays.
let resp = authed(
client().post(format!("{base}/resources/create?update_if_exists=true")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/upsert_target",
"value": { "host": "h3" },
"resource_type": "object",
"ws_specific": true
}))
.send()
.await?;
assert_eq!(resp.status(), 201);
assert_eq!(
ws_specific_row_count(
&db,
"test-workspace",
"resource",
"u/test-user/upsert_target"
)
.await?,
1
);
let resp = authed(
client().post(format!("{base}/resources/create?update_if_exists=true")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/upsert_target",
"value": { "host": "h4" },
"resource_type": "object"
// no ws_specific field
}))
.send()
.await?;
assert_eq!(resp.status(), 201);
assert_eq!(
ws_specific_row_count(
&db,
"test-workspace",
"resource",
"u/test-user/upsert_target"
)
.await?,
1,
"absent ws_specific field must leave the existing flag alone"
);
Ok(())
}
/// Regression for GHSA-xmr2-98m6-cjf7: a token scoped only to `resources:write:<r>`
/// must NOT use the resource-delete cascade to delete a linked secret variable it has
/// no `variables:write` scope for.
#[sqlx::test(fixtures("ws_specific"))]
async fn test_scoped_token_cannot_cascade_delete_linked_variable(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace");
let resp = authed(
client().post(format!("{base}/variables/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/victim_secret",
"value": "hunter2",
"is_secret": true,
"description": ""
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "create victim: {}", resp.text().await?);
let resp = authed(
client().post(format!("{base}/resources/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/db",
"value": { "password": "$var:u/test-user/victim_secret" },
"resource_type": "object"
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "create res: {}", resp.text().await?);
// resources:write only, no variables:write for the linked secret.
let scoped = mint_scoped_token(port, vec!["resources:write:u/test-user/db"]).await?;
let resp = authed(
client().delete(format!("{base}/resources/delete/u/test-user/db")),
&scoped,
)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"scoped resource token must not cascade-delete the linked variable: {}",
resp.text().await?
);
assert!(
variable_exists(&db, "test-workspace", "u/test-user/victim_secret").await?,
"victim variable must survive the denied cascade"
);
Ok(())
}