Files
windmill/backend/windmill-api-integration-tests/tests/token_label_idor.rs
T
Ruben FiszelandClaude Opus 5 3716a71fd7 fix: credit the token owner instead of the token label in the audit trail (#10423)
* fix: credit the token owner instead of the token label in the audit trail

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address review findings on token-owner audit attribution

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: carry token-label provenance explicitly instead of inferring it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: point ee-repo-ref at the companion branch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: trust only non-forgeable token labels to name the acting entity

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: reject reserved system-token labels at token creation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: narrow the token-label guard to server-minted namespaces

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: add the provenance field to the remaining ApiAuthed literals

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop trusting the email- label, which no mint produces

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:21:14 +02:00

224 lines
8.0 KiB
Rust

//! Regression tests for GHSA-8x8x-88qc-qp4r: token label collision bypassing job read
//! access control (IDOR).
//!
//! `username_override` is derived from a fully user-controlled token label, so a bare
//! `username_override == created_by` match in `require_job_read_access` is forgeable. The fix
//! binds that fast path to a non-forgeable attribute — the job's `permissioned_as_email` (the
//! token owner's email) must equal the caller's email. This:
//! - denies a colliding-label token created by a different principal, while
//! - still allowing a principal to re-read its own labeled-token jobs (incl. when RLS would
//! otherwise hide them), and
//! - leaving user-facing webhook/http/email trigger token creation untouched (those labels
//! are created through the public token API by design).
use serde_json::json;
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn bearer(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {token}"))
}
async fn create_token_with_label(port: u16, caller_token: &str, label: &str) -> reqwest::Response {
bearer(
client().post(format!("http://localhost:{port}/api/users/tokens/create")),
caller_token,
)
.json(&json!({ "label": label }))
.send()
.await
.unwrap()
}
/// Insert a completed job with a labeled-token `created_by`, running as `permissioned_as`
/// (email `permissioned_as_email`) with the given `runnable_path` (which governs RLS).
async fn insert_labeled_job(
db: &Pool<Postgres>,
created_by: &str,
runnable_path: &str,
permissioned_as: &str,
permissioned_as_email: &str,
) -> Uuid {
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, runnable_path, kind, tag, args, visible_to_owner)
VALUES ($1, 'test-workspace', $2, $3, $4, $5, 'script', 'deno', '{}'::jsonb, true)",
)
.bind(id)
.bind(created_by)
.bind(permissioned_as)
.bind(permissioned_as_email)
.bind(runnable_path)
.execute(db)
.await
.unwrap();
sqlx::query(
"INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status)
VALUES ($1, 'test-workspace', 100, '{\"secret\":\"super-secret-value\"}'::jsonb, 'success')",
)
.bind(id)
.execute(db)
.await
.unwrap();
id
}
/// The core IDOR: an operator who mints a token whose label collides with another
/// principal's labeled-token identity must NOT be able to read that principal's job — the
/// `permissioned_as_email` of that job is the victim's, not the attacker's.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_label_collision_does_not_grant_job_read(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/jobs");
// A job submitted with a token labeled "collide", running as the admin (test-user).
let job_id = insert_labeled_job(
&db,
"label-collide",
"u/test-user/secret_script",
"u/test-user",
"test@windmill.dev",
)
.await;
// Sanity: the admin can read it, so the job exists and is otherwise readable.
let resp = bearer(
client().get(format!("{base}/completed/get/{job_id}")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "admin must still read the job");
// The attacker (a different member, test-user-2) mints a colliding-label token.
let resp = create_token_with_label(port, "SECRET_TOKEN_2", "collide").await;
assert_eq!(resp.status(), 201);
let attacker_token = resp.text().await?;
// Reading the admin's job with the colliding token must be denied. Before the fix the
// `username_override == created_by` fast path returned the full result here.
let resp = bearer(
client().get(format!("{base}/completed/get/{job_id}")),
&attacker_token,
)
.send()
.await?;
assert!(
!resp.status().is_success(),
"colliding-label token must not read another principal's job (got {})",
resp.status()
);
let body = resp.text().await?;
assert!(
!body.contains("super-secret-value"),
"job result must not leak to the colliding-label token"
);
Ok(())
}
/// The fix must not regress the legitimate case: a principal re-reading its own
/// labeled-token job is granted via the email-bound fast path, even when RLS would hide the
/// job (the runnable lives in another user's space the caller has no RLS path to).
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_legit_labeled_self_read_still_works(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/jobs");
// Created by test-user-2's labeled token, running as test-user-2, but the runnable lives
// under u/test-user so RLS alone would not reveal it to test-user-2 — the grant must come
// from the email-bound fast path.
let job_id = insert_labeled_job(
&db,
"label-mine",
"u/test-user/shared_script",
"u/test-user-2",
"test2@windmill.dev",
)
.await;
let resp = create_token_with_label(port, "SECRET_TOKEN_2", "mine").await;
assert_eq!(resp.status(), 201);
let token = resp.text().await?;
let resp = bearer(
client().get(format!("{base}/completed/get/{job_id}")),
&token,
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"owner must still read their own labeled-token job via the email-bound fast path"
);
Ok(())
}
/// P1 regression guard: the user-facing token API must keep accepting the labels that the
/// webhook / http-route / email trigger panels mint (e.g. `webhook-<user>-<rand>`). The fix
/// must not reserve those prefixes.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_trigger_token_labels_still_creatable(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
for label in [
"webhook-test-user-2-ab12",
"http-test-user-2-cd34",
"email-test-user-2-ef56",
"my-ci-token",
// Minted client-side by the editor (every TypeScript editor load) and the debugger.
"Ephemeral lsp token",
"debugger-token",
] {
let resp = create_token_with_label(port, "SECRET_TOKEN_2", label).await;
assert_eq!(
resp.status(),
201,
"creating a token with label {label:?} must succeed"
);
}
Ok(())
}
/// The mirror of the above: reserved namespaces must NOT be mintable. `username_override_from_label`
/// trusts these shapes to name the entity acting, so a forged one would stamp an arbitrary
/// name onto `v2_job.created_by` and the audit `end_user` — on an `on_behalf_of` runnable,
/// which also takes the `username`/`email` columns, that leaves no trace of the real caller.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_reserved_token_labels_not_creatable(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
for label in [
"ephemeral-webhook-forged",
"ephemeral-script-end-user-svcaccount",
"ephemeral-script",
"session",
"mcp-oauth-forged",
] {
let resp = create_token_with_label(port, "SECRET_TOKEN_2", label).await;
assert_eq!(
resp.status(),
400,
"creating a token with reserved label {label:?} must be rejected"
);
}
Ok(())
}