mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-15 08:02:53 +00:00
* fix(security): a WM_TOKEN job token can never be a global superadmin (GHSA-hfh4-cx4h-3fcr)
Privilege escalation: an app/flow/schedule/trigger execution policy's `on_behalf_of`
(which a `wm_deployers` member can set) could point at a superadmin email. The
resulting job `WM_TOKEN` then passed the email-based superadmin checks, granting
instance superadmin. `forbid_superadmin_job_token` only guarded ~15 of ~75 routes.
Fix at the token layer: a WM_TOKEN must never satisfy a superadmin gate,
regardless of whose email it runs as (sentinel OR a real superadmin).
- `ApiAuthed` gains a `job_id` field, stamped once in `AuthCache::get_opt_job_authed`
from the resolved token's job_id (correct even on cache hits).
- `require_super_admin(db, email)` -> `require_super_admin(db, &ApiAuthed)`, rejects
`authed.job_id.is_some()`. `require_super_admin_email` kept for the few internal
callers without an ApiAuthed.
- `is_super_admin_authed(db, &ApiAuthed)` for the boolean `is_super_admin_email`
authorization branches on request handlers (workspace deletion, fork drops,
dev-workspace attach/archive, object-storage SSRF exemption, custom dbname, EE GHES
+ connected repositories, ...). Migrate ~75 sites (OSS + EE).
- CUSTOM_INSTANCE_DB reads the *authenticated* job_id, not the caller-supplied
`?job_id` query param. Worker-tag check takes a precomputed job-aware `is_super_admin`
on the request path.
Execution-time on-behalf checks (scheduled/flow worker-tag, Cloud enqueue quota,
is_devops_email) are hardened in a follow-up — see
docs/followup-onbehalf-execution-privilege-hardening.md.
Regression tests: a superadmin-email WM_TOKEN is rejected on `require_super_admin`
routes, on `DELETE /workspaces/delete/{w}` (403, workspace preserved), and on the
CUSTOM_INSTANCE_DB lookup with no `?job_id` (401); real superadmin tokens still succeed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: cap devops role at workspace admin and reject reserved on_behalf_of identities
Extends the job-token cap with three pieces:
- `require_devops_role` takes `&ApiAuthed` and rejects job tokens.
`is_devops_email` is true for superadmin emails, so every worker-management,
instance-config and service-log route was reachable by the same superadmin
`WM_TOKEN` that `require_super_admin` already rejects.
- A `job_id` claim that does not parse as a uuid rejects the token rather than
resolving to `None`, which would clear the job provenance and uncap it. Applies
to the internal JWT and the external `jwt_ext_` path.
- Defense in depth at store time: `validate_on_behalf_of` refuses the reserved
internal sentinels as an `on_behalf_of` on apps/flows/scripts/schedules/triggers,
and app execution refuses a policy carrying one — covering already-persisted and
forked-app rows that predate the cap. Deploying on behalf of a real user,
including a real superadmin, stays allowed; the cap handles that at execution.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(mcp): preserve job-token provenance when minting the proxy JWT
The MCP endpoint-tool proxy re-mints a JWT from the caller's ApiAuthed to
forward the proxied request, but passed job_id: None. A job's WM_TOKEN is
capped at workspace admin (GHSA-hfh4-cx4h-3fcr); dropping the job_id here
re-minted an uncapped token that satisfies require_super_admin /
require_devops_role on the proxied route (e.g. listWorkers exposing worker
IPs, job/workspace IDs, and sensitive tags).
Carry api_authed.job_id into create_jwt_token. Adds an in-module regression
that decodes the forwarded JWT and asserts the job_id is preserved for a job
caller and absent for a non-job caller.
Reported by Codex CI review (P1) on #10124.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: cap the admin-or-devops gate at workspace admin for job tokens
require_admin_or_devops (the EE critical-alerts endpoints) grants when the
caller is a workspace admin OR an instance devops. is_devops_email is true
for superadmins, so a WM_TOKEN running on-behalf of a superadmin who is not a
member of the target workspace could clear the devops branch and read/ack that
workspace's critical alerts (GHSA-hfh4-cx4h-3fcr). This gate takes a bare
email, not an ApiAuthed, so the token-layer cap could not see it.
Thread the caller's job-token provenance and reject the devops branch for job
tokens, matching require_devops_role. The workspace-admin branch stays allowed
— that is the cap ceiling. Adds an enterprise-gated regression proving the
bypass is closed and a real superadmin token still clears the gate.
Found while auditing the PR for bare-email gates the choke-point cap misses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix: cap instance-global is_admin gates at workspace admin for job tokens
Three instance-global routes gate on the caller's own `is_admin` claim, which
`ApiAuthed.is_admin` carries into a WM_TOKEN (it is a workspace-admin claim,
true for superadmins too). A job token is capped at workspace admin
(GHSA-hfh4-cx4h-3fcr), so its is_admin claim must not authorize instance
actions on a route with no workspace binding:
- `unarchive_workspace` — unarchive an arbitrary workspace by id
- `prune_concurrency_group` — delete a global concurrency group
- `list_worker_groups` — return unobfuscated `env_vars_static` (may hold secrets)
Add job-token-aware `is_instance_admin` / `require_instance_admin` helpers (the
same shape as `require_super_admin` / `require_devops_role`) and use them at
these three sites. Workspace-scoped `require_admin(authed.is_admin, ...)` gates
are intentionally left unchanged — a workspace-admin job token is within the
cap there. Regression added covering all three; verified it lets a WM_TOKEN
unarchive/leak without the fix and is blocked with it.
Reported by Codex CI review (P1) on #10124.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(mcp): drop orphaned path_field_renames from EndpointTool test helper
The merge with main adopted main's mcp path-substitution refactor (#10162),
which removed the `path_field_renames` field from `EndpointTool` and its
consumer (`substitute_path_params` no longer takes per-field path renames).
main's `runner.rs` `ep` test helper still constructed the struct with
`path_field_renames: None`, so the workspace test build (cargo test --all,
which compiles windmill-mcp's own #[cfg(test)] module under the `server`
feature) failed with E0560. A plain `cargo check` does not compile that test
module, so it only surfaced in CI's cargo_test.
Remove the orphaned field to match the struct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: describe the sentinel-rejection policy the forged-identity test asserts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: complete ApiAuthed initializers in feature-gated tests after merge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: stop job tokens minting credentials that shed their provenance
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: cap the MCP OAuth approval mint at the same elevated-job-token gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: cap the self-service password reset at the elevated-job-token gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: cap app embed/SDK mints and scope widening at the elevated-job-token gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: keep job tokens from destroying the account they run on behalf of
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: deny job tokens a foreign-workspace admin claim and workspace ejection
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: keep the follow-up inventory in the PR instead of the repo
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: make the session workspace status gate job-token aware
session_workspace_status derived its superadmin branch from a bare email
check, so a job token carrying a superadmin identity resolved the existence
of workspaces it has no relationship with rather than seeing them as
deleted. Switch to is_super_admin_authed, matching every other instance
gate reached from a request ApiAuthed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* revert: leave the global concurrency-group listing on the plain admin gate
The listing exposes concurrency keys across workspaces, which is metadata
rather than a capability, and it 401s rather than degrading. Keep the guard
on the prune route next to it, which is the destructive one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: keep the instance-admin gate on the global concurrency listing
The listing spans every workspace's concurrency keys, and the gate rejects
only job tokens: the !is_admin branch is the pre-existing check, so
workspaced tokens and interactive admins are unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore: update ee-repo-ref to d30af67d38954f9012f7bad08da23e347344b4c6
This commit updates the EE repository reference after PR #664 was merged in windmill-ee-private.
Previous ee-repo-ref: 7870573dbc3360f99bada143f094c67dce0d9e9c
New ee-repo-ref: d30af67d38954f9012f7bad08da23e347344b4c6
Automated by sync-ee-ref workflow.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: hugocasa <hugo@casademont.ch>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
858 lines
28 KiB
Rust
858 lines
28 KiB
Rust
//! A WM_TOKEN (job JWT) running as a superadmin must not be able to perform
|
|
//! global user/token management — promotion, password reset, user creation,
|
|
//! token creation/impersonation, offboarding, or exporting the user table.
|
|
//! A non-admin `wm_deployers` member can mint
|
|
//! such a token implicitly via an app/flow `on_behalf_of`, so trusting it would
|
|
//! let them establish *persistent* superadmin. A real superadmin who needs this
|
|
//! from a script must use a dedicated superadmin API token (which only a real
|
|
//! superadmin can create), not `$WM_TOKEN`.
|
|
//!
|
|
//! The fixture provides `test@windmill.dev` (instance superadmin, token
|
|
//! `SECRET_TOKEN`) and `test2@windmill.dev` (non-superadmin, `SECRET_TOKEN_2`).
|
|
|
|
use serde_json::json;
|
|
use sqlx::{Pool, Postgres};
|
|
use windmill_common::auth::create_jwt_token;
|
|
use windmill_common::db::Authed;
|
|
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))
|
|
}
|
|
|
|
/// Mint a WM_TOKEN: an internally-signed job JWT (note the `job_id` claim) for
|
|
/// `email`, exactly as a running app/flow job is issued.
|
|
async fn wm_token(email: &str, is_admin: bool) -> String {
|
|
let authed = Authed {
|
|
email: email.to_string(),
|
|
username: "runner".to_string(),
|
|
is_admin,
|
|
is_operator: false,
|
|
groups: vec![],
|
|
folders: vec![],
|
|
scopes: None,
|
|
token_prefix: None,
|
|
};
|
|
create_jwt_token(
|
|
authed,
|
|
"test-workspace",
|
|
3600,
|
|
Some(uuid::Uuid::new_v4()),
|
|
Some("app".to_string()),
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("mint wm_token")
|
|
}
|
|
|
|
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
|
|
async fn test_wm_token_cannot_manage_superadmin_users(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
// The server decodes WM_TOKENs with the same in-process JWT secret, so
|
|
// setting it once lets us mint a valid one below.
|
|
set_jwt_secret().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api/users");
|
|
|
|
// A superadmin-capable WM_TOKEN — the exact thing a deployer obtains via an
|
|
// app on_behalf_of pointed at a superadmin.
|
|
let sa_wm = wm_token("test@windmill.dev", true).await;
|
|
|
|
// 1. Cannot mint a (superadmin) token.
|
|
let resp = authed(client().post(format!("{base}/tokens/create")), &sa_wm)
|
|
.json(&json!({}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not create tokens: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// 2. Cannot impersonate (mint a token as another user).
|
|
let resp = authed(client().post(format!("{base}/tokens/impersonate")), &sa_wm)
|
|
.json(&json!({ "impersonate_email": "test2@windmill.dev" }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not impersonate: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// 3. Cannot promote a user to superadmin.
|
|
let resp = authed(
|
|
client().post(format!("{base}/update/test2@windmill.dev")),
|
|
&sa_wm,
|
|
)
|
|
.json(&json!({ "is_super_admin": true }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not promote users: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// 4. Cannot reset its own (the superadmin's) password.
|
|
let resp = authed(client().post(format!("{base}/setpassword")), &sa_wm)
|
|
.json(&json!({ "password": "hunter2" }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not reset passwords: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// 4b. Cannot delete a user.
|
|
let resp = authed(
|
|
client().delete(format!("{base}/delete/test2@windmill.dev")),
|
|
&sa_wm,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not delete users: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// 4c. Cannot change a user's login type.
|
|
let resp = authed(
|
|
client().post(format!("{base}/set_login_type/test2@windmill.dev")),
|
|
&sa_wm,
|
|
)
|
|
.json(&json!({ "login_type": "password" }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not change login type: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// 4d. Cannot offboard a global user (deletes user, tokens, password, invites,
|
|
// instance-group membership and reassigns their assets).
|
|
let resp = authed(
|
|
client().post(format!("{base}/offboard/test2@windmill.dev")),
|
|
&sa_wm,
|
|
)
|
|
.json(&json!({}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not offboard users: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// 4e. Cannot export the global user table (leaks every user's password_hash).
|
|
let resp = authed(client().get(format!("{base}/export")), &sa_wm)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not export global users: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// 5. Escape hatch / no false positive: a real superadmin API token
|
|
// (SECRET_TOKEN, no job_id) can still create tokens.
|
|
let resp = authed(
|
|
client().post(format!("{base}/tokens/create")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.json(&json!({ "label": "ci" }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
201,
|
|
"a real superadmin token must still create tokens: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// 6. No collateral: a non-superadmin WM_TOKEN can still create its own
|
|
// token — the guard only fires for superadmin-capable job tokens.
|
|
let user_wm = wm_token("test2@windmill.dev", false).await;
|
|
let resp = authed(client().post(format!("{base}/tokens/create")), &user_wm)
|
|
.json(&json!({ "label": "from-script" }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
201,
|
|
"non-superadmin WM_TOKEN must still create its own token: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A WM_TOKEN running as a superadmin must be rejected by *any* `require_super_admin`
|
|
/// route, not just the handful that call `forbid_superadmin_job_token`. `GET
|
|
/// /api/settings/list_global` is gated solely by `require_super_admin`, so it
|
|
/// exercises the token-layer guard (GHSA-hfh4-cx4h-3fcr).
|
|
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
|
|
async fn test_wm_token_rejected_by_require_super_admin(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
set_jwt_secret().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api");
|
|
|
|
// The exact token a deployer obtains via an app on_behalf_of pointed at a superadmin.
|
|
let sa_wm = wm_token("test@windmill.dev", true).await;
|
|
let resp = authed(client().get(format!("{base}/settings/list_global")), &sa_wm)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not reach a require_super_admin route: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// No false positive: a real superadmin API token (no job_id) still reaches it.
|
|
let resp = authed(
|
|
client().get(format!("{base}/settings/list_global")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"a real superadmin token must still reach the route: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Direct `is_super_admin_email` authorization gates (not routed through
|
|
/// `require_super_admin`) must also reject a superadmin `WM_TOKEN`. Covers the two
|
|
/// bypass classes the CI review flagged: destructive `delete_workspace`, and the
|
|
/// `CUSTOM_INSTANCE_DB` credential lookup whose guard must read the *authenticated*
|
|
/// `job_id`, not the caller-supplied `?job_id` query param (GHSA-hfh4-cx4h-3fcr).
|
|
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
|
|
async fn test_wm_token_rejected_by_direct_super_admin_gates(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
set_jwt_secret().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api");
|
|
|
|
let sa_wm = wm_token("test@windmill.dev", true).await;
|
|
|
|
// 1. Global workspace deletion (destructive) — must be forbidden.
|
|
let resp = authed(
|
|
client().delete(format!("{base}/workspaces/delete/test-workspace")),
|
|
&sa_wm,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
403,
|
|
"superadmin WM_TOKEN must not delete a workspace: {}",
|
|
resp.text().await?
|
|
);
|
|
// The workspace must still exist.
|
|
let exists: bool =
|
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM workspace WHERE id = 'test-workspace')")
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert!(
|
|
exists,
|
|
"rejected delete must not have removed the workspace"
|
|
);
|
|
|
|
// 2. CUSTOM_INSTANCE_DB credential lookup, WITHOUT the ?job_id query param —
|
|
// the guard must reject based on the authenticated token's job_id.
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"{base}/w/test-workspace/resources/get_value_interpolated/CUSTOM_INSTANCE_DB/anydb"
|
|
)),
|
|
&sa_wm,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not resolve CUSTOM_INSTANCE_DB (no creds leak): {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The instance-level `devops` role must be capped like superadmin.
|
|
/// `is_devops_email` returns true for superadmin emails, so every
|
|
/// `require_devops_role` route (worker management, instance config, service logs)
|
|
/// is reachable by exactly the same superadmin `WM_TOKEN` unless it is capped too
|
|
/// (GHSA-hfh4-cx4h-3fcr).
|
|
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
|
|
async fn test_wm_token_rejected_by_require_devops_role(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
set_jwt_secret().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api");
|
|
|
|
let sa_wm = wm_token("test@windmill.dev", true).await;
|
|
let resp = authed(
|
|
client().get(format!("{base}/service_logs/list_files")),
|
|
&sa_wm,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not reach a require_devops_role route: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// No false positive: a real superadmin API token (no job_id) still reaches it.
|
|
let resp = authed(
|
|
client().get(format!("{base}/service_logs/list_files")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"a real superadmin token must still reach the devops route: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// The advisory's own PoC route: the full user directory, gated solely by
|
|
// `require_super_admin` with no per-route job-token denylist.
|
|
let resp = authed(
|
|
client().get(format!("{base}/users/list_as_super_admin")),
|
|
&sa_wm,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not list all users: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A job token must not clear an *admin-or-devops* gate via the devops branch.
|
|
/// `require_admin_or_devops` (the EE critical-alerts endpoints) grants when the
|
|
/// caller is a workspace admin OR an instance `devops`; since `is_devops_email`
|
|
/// is true for superadmins, a WM_TOKEN running on-behalf of a superadmin who is
|
|
/// NOT a member of the target workspace would otherwise gain workspace-scoped
|
|
/// devops access to a workspace it has no admin rights in (GHSA-hfh4-cx4h-3fcr).
|
|
/// The workspace-admin branch stays allowed — that is the cap ceiling.
|
|
#[cfg(feature = "enterprise")]
|
|
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
|
|
async fn test_wm_token_rejected_by_admin_or_devops_gate(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
set_jwt_secret().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
|
|
|
|
// superadmin-external is a superadmin but not a member of test-workspace, so
|
|
// its workspace-level is_admin is false — the exact exploit precondition.
|
|
let sa_wm = wm_token("superadmin-external@windmill.dev", false).await;
|
|
let resp = authed(client().get(format!("{base}/critical_alerts")), &sa_wm)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
403,
|
|
"superadmin WM_TOKEN must not clear the admin-or-devops gate on a workspace it isn't admin of: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// No false positive: the same superadmin's real API token (not a job token)
|
|
// still clears the gate via the devops branch.
|
|
let resp = authed(
|
|
client().get(format!("{base}/critical_alerts")),
|
|
"EXTERNAL_SUPERADMIN_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_ne!(
|
|
resp.status(),
|
|
403,
|
|
"a real superadmin token must still clear the admin-or-devops gate: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Instance-global routes with no workspace binding that gate on the caller's own
|
|
/// `is_admin` claim must reject a WM_TOKEN — `is_admin` is a workspace-admin claim
|
|
/// (also true for superadmins), and a job token is capped at workspace admin, so it
|
|
/// must not wield that claim as instance authorization (GHSA-hfh4-cx4h-3fcr).
|
|
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
|
|
async fn test_wm_token_rejected_by_instance_admin_gates(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
set_jwt_secret().await;
|
|
|
|
// A worker-group config carrying a static env value that must stay masked.
|
|
sqlx::query("INSERT INTO config (name, config) VALUES ('worker__wm2082grp', $1)")
|
|
.bind(json!({ "env_vars_static": { "LEAKY": "supersecretvalue" } }))
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api");
|
|
|
|
// The exact token a deployer obtains via an app on_behalf_of pointed at a
|
|
// superadmin: is_admin=true, but carrying a job_id.
|
|
let sa_wm = wm_token("test@windmill.dev", true).await;
|
|
|
|
// 1. Arbitrary workspace unarchive (mutation on any workspace by id).
|
|
let resp = authed(
|
|
client().post(format!("{base}/workspaces/unarchive/test-workspace")),
|
|
&sa_wm,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not unarchive an arbitrary workspace: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// 2. Global concurrency-group pruning.
|
|
let resp = authed(
|
|
client().delete(format!("{base}/concurrency_groups/prune/anykey")),
|
|
&sa_wm,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
403,
|
|
"superadmin WM_TOKEN must not prune a global concurrency group: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// 3. The sibling listing spans every workspace's concurrency keys, so it is
|
|
// gated the same way as the prune above.
|
|
let resp = authed(
|
|
client().get(format!("{base}/concurrency_groups/list")),
|
|
&sa_wm,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not list global concurrency groups: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// 4. Worker-group config: the static env value must be masked for a job token.
|
|
let body = authed(
|
|
client().get(format!("{base}/configs/list_worker_groups")),
|
|
&sa_wm,
|
|
)
|
|
.send()
|
|
.await?
|
|
.text()
|
|
.await?;
|
|
assert!(
|
|
!body.contains("supersecretvalue"),
|
|
"superadmin WM_TOKEN must get the obfuscated worker-group view: {body}"
|
|
);
|
|
|
|
// No false positive: a real superadmin API token (no job_id) still sees the
|
|
// unobfuscated value — the cap keys off the job token, not the identity.
|
|
let body = authed(
|
|
client().get(format!("{base}/configs/list_worker_groups")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?
|
|
.text()
|
|
.await?;
|
|
assert!(
|
|
body.contains("supersecretvalue"),
|
|
"a real superadmin token must still see the unobfuscated worker-group config: {body}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Capping a `WM_TOKEN` at the gates is only durable if the token cannot trade
|
|
/// itself for one without the `job_id` those gates key off. Both credential-minting
|
|
/// routes must therefore refuse an elevated job token: `refresh_token` (which mints
|
|
/// a database-backed session token and returns it in `Set-Cookie`) and
|
|
/// `tokens/create` for the `devops` tier, whose routes are capped just like
|
|
/// superadmin's (GHSA-hfh4-cx4h-3fcr).
|
|
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
|
|
async fn test_wm_token_cannot_mint_a_provenance_free_credential(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
set_jwt_secret().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api/users");
|
|
|
|
// 1. Session refresh: a superadmin-identity job token must not obtain a session
|
|
// token, which would authenticate with no job provenance at all.
|
|
let sa_wm = wm_token("test@windmill.dev", true).await;
|
|
let resp = authed(client().get(format!("{base}/refresh_token")), &sa_wm)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not refresh into a session token: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// No false positive: a real superadmin API token still refreshes.
|
|
let resp = authed(
|
|
client().get(format!("{base}/refresh_token")),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"a real superadmin token must still refresh: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// 2. Token mint, devops tier: `require_devops_role` rejects this job token, so
|
|
// minting one that would pass it by email must be refused too.
|
|
let devops_wm = wm_token("devops@windmill.dev", false).await;
|
|
let resp = authed(client().post(format!("{base}/tokens/create")), &devops_wm)
|
|
.json(&json!({ "label": "from-script" }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"devops WM_TOKEN must not mint a token: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// 3. Choosing the password of the elevated account it runs as would let the
|
|
// holder log in for a session that carries no job provenance at all.
|
|
let resp = authed(client().post(format!("{base}/setpassword")), &devops_wm)
|
|
.json(&json!({ "password": "hunter2" }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"devops WM_TOKEN must not set its account password: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The MCP OAuth approval is a third credential mint: the code it stores is
|
|
/// exchanged for a database token holding only an email, so an elevated job token
|
|
/// approving a client would obtain a credential with no `job_id` and re-enter the
|
|
/// API through the gateway uncapped (GHSA-hfh4-cx4h-3fcr). The guard sits in the
|
|
/// shared inner fn, ahead of client validation, so it fires without a registered
|
|
/// client.
|
|
#[cfg(feature = "mcp")]
|
|
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
|
|
async fn test_wm_token_cannot_mint_via_mcp_oauth_approval(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
set_jwt_secret().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api");
|
|
|
|
let approval = json!({
|
|
"client_id": "wm2082-client",
|
|
"redirect_uri": "http://localhost/callback",
|
|
"scope": "mcp:all",
|
|
"state": "s",
|
|
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
|
"code_challenge_method": "S256",
|
|
});
|
|
|
|
let sa_wm = wm_token("test@windmill.dev", true).await;
|
|
let resp = authed(
|
|
client().post(format!("{base}/w/test-workspace/mcp/oauth/server/approve")),
|
|
&sa_wm,
|
|
)
|
|
.json(&approval)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not approve an MCP OAuth client: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// The gateway route reaches the same mint and must be capped identically.
|
|
let mut gateway_approval = approval.clone();
|
|
gateway_approval["workspace_id"] = json!("test-workspace");
|
|
let resp = authed(
|
|
client().post(format!("{base}/mcp/gateway/oauth/server/approve")),
|
|
&sa_wm,
|
|
)
|
|
.json(&gateway_approval)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not approve through the MCP gateway: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The two links that let a narrowly-scoped mint become a general credential: the
|
|
/// sandboxed app embed mint (a 12h database token with no job provenance) and
|
|
/// `tokens/update_scopes`, which an unscoped job token could use to clear the
|
|
/// scopes of any token sharing its email (GHSA-hfh4-cx4h-3fcr).
|
|
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
|
|
async fn test_wm_token_cannot_mint_or_widen_an_app_embed_token(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
set_jwt_secret().await;
|
|
|
|
// A sandboxed app the superadmin identity can read — the mint's precondition.
|
|
sqlx::query(
|
|
"INSERT INTO app (id, workspace_id, path, summary, versions, policy, extra_perms)
|
|
VALUES (9001, 'test-workspace', 'u/test-user/embedded', 'Embedded', '{}',
|
|
'{\"execution_mode\": \"viewer\", \"sandbox\": true}', '{}')",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query(
|
|
"INSERT INTO app_version (id, app_id, value, created_by, created_at)
|
|
VALUES (9001, 9001, '{\"grid\": []}', 'test-user', NOW())",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
sqlx::query("UPDATE app SET versions = ARRAY[9001::bigint] WHERE id = 9001")
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api");
|
|
|
|
let sa_wm = wm_token("test@windmill.dev", true).await;
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"{base}/w/test-workspace/apps/embed_token/p/u/test-user/embedded"
|
|
)),
|
|
&sa_wm,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not mint an app embed token: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// Even a token minted some other way must stay narrow: widening is refused.
|
|
let resp = authed(
|
|
client().post(format!("{base}/users/tokens/update_scopes/SECRET_T")),
|
|
&sa_wm,
|
|
)
|
|
.json(&json!({ "scopes": serde_json::Value::Null }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"superadmin WM_TOKEN must not widen a token's scopes: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Destroying the account or credentials of the identity a job runs as is never the
|
|
/// runnable's work, and a `wm_deployers` member may point `on_behalf_of` at any real
|
|
/// user — so these reject every job token, elevated or not (GHSA-hfh4-cx4h-3fcr).
|
|
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
|
|
async fn test_wm_token_cannot_destroy_its_on_behalf_account(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
set_jwt_secret().await;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api/users");
|
|
|
|
// An ordinary member's identity: the cap here does not depend on elevation.
|
|
let user_wm = wm_token("test2@windmill.dev", false).await;
|
|
|
|
let resp = authed(client().post(format!("{base}/leave_instance")), &user_wm)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"WM_TOKEN must not delete the account it runs as: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// The prefix of that identity's real fixture token, so without the guard the
|
|
// delete would land rather than silently match nothing.
|
|
let resp = authed(
|
|
client().delete(format!("{base}/tokens/delete/SECRET_TOK")),
|
|
&user_wm,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"WM_TOKEN must not revoke that identity's tokens: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
// Ejecting the identity from a workspace is the same primitive, on both routes
|
|
// that expose it (one keyed by username, one by email).
|
|
for route in [
|
|
"w/test-workspace/users/leave",
|
|
"w/test-workspace/workspaces/leave",
|
|
] {
|
|
let resp = authed(
|
|
client().post(format!("http://localhost:{port}/api/{route}")),
|
|
&user_wm,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"WM_TOKEN must not leave a workspace as {route}: {}",
|
|
resp.text().await?
|
|
);
|
|
}
|
|
let membership: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'",
|
|
)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(membership, 1, "the workspace membership must survive");
|
|
|
|
// The account and its credentials are untouched, not merely the response refused.
|
|
let account_rows: i64 =
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM password WHERE email = 'test2@windmill.dev'")
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert_eq!(account_rows, 1, "the password row must survive");
|
|
let token_rows: i64 =
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM token WHERE email = 'test2@windmill.dev'")
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert!(token_rows > 0, "the identity's tokens must survive");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// `load_workspace_authed` grants an admin claim in a workspace the caller may have
|
|
/// no relationship with, and carries `job_id` into the result — so deriving it from
|
|
/// the on-behalf email would hand a WM_TOKEN admin over every workspace on the
|
|
/// instance, and with it the cross-workspace diff (GHSA-hfh4-cx4h-3fcr).
|
|
#[sqlx::test(fixtures("preserve_on_behalf_of"))]
|
|
async fn test_wm_token_gets_no_admin_claim_in_a_foreign_workspace(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
set_jwt_secret().await;
|
|
|
|
// A workspace the superadmin identity is not a member of.
|
|
sqlx::query(
|
|
"INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'Other', 'test-user')",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api");
|
|
|
|
let sa_wm = wm_token("test@windmill.dev", true).await;
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"{base}/w/test-workspace/workspaces/compare/other-workspace"
|
|
)),
|
|
&sa_wm,
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_ne!(
|
|
resp.status(),
|
|
200,
|
|
"superadmin WM_TOKEN must not diff a workspace it does not belong to"
|
|
);
|
|
|
|
// No false positive: a real superadmin token still holds the claim.
|
|
let resp = authed(
|
|
client().get(format!(
|
|
"{base}/w/test-workspace/workspaces/compare/other-workspace"
|
|
)),
|
|
"SECRET_TOKEN",
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"a real superadmin token must still diff across workspaces: {}",
|
|
resp.text().await?
|
|
);
|
|
|
|
Ok(())
|
|
}
|