mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 16:02:59 +00:00
* feat: add provenance claims to job OIDC tokens and mark preview sub Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: require a flow or script job's version to belong to its path for deployed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: derive app script paths server-side and test job provenance in CE Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: count an app script as deployed only when a deployed app run stamped it Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * docs: state the deployed condition for the preview sub prefix Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: keep the plain OIDC sub for previews by users who can write the path Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: refuse OIDC tokens to previews by users who cannot write the path Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor: keep OIDC token issuance unchanged, leaving provenance to the claims Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * refactor: name the root job's trigger claim root_trigger_kind Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to 421cf2a8b4f98b421e93c0fc7c1c378314a66e50 This commit updates the EE repository reference after PR #831 was merged in windmill-ee-private. Previous ee-repo-ref: 7acd384875deba4b01a502e628a153b11c82eecb New ee-repo-ref: 421cf2a8b4f98b421e93c0fc7c1c378314a66e50 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
303 lines
11 KiB
Rust
303 lines
11 KiB
Rust
//! Regression: run mode of `execute_component`'s no-id inline-`raw_code` arm runs
|
|
//! *only* the `rawscript/<sha>`-pinned `content`, dropping the caller `hash`,
|
|
//! `lock`, `modules` and `dedicated_worker` and deriving `path` server-side —
|
|
//! all of which would otherwise run or install unpinned code as the app identity.
|
|
//! Preview mode keeps honoring the caller's fields.
|
|
|
|
use serde_json::json;
|
|
use sha2::{Digest, Sha256};
|
|
use sqlx::{Pool, Postgres};
|
|
use windmill_test_utils::*;
|
|
|
|
fn client() -> reqwest::Client {
|
|
reqwest::Client::new()
|
|
}
|
|
|
|
fn authed(b: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
|
b.header("Authorization", format!("Bearer {}", token))
|
|
}
|
|
|
|
const CONTENT: &str = "print('benign')\n";
|
|
// A caller lock whose presence is the whole point: if it reaches the job, the
|
|
// worker installs it. The value only needs to be recognizable in `v2_job`.
|
|
const CALLER_LOCK: &str = "evilpkg @ file:///tmp/attacker-controlled-sdist";
|
|
// A non-codebase-sentinel hash: if it reaches the job as `runnable_id`, the
|
|
// worker fetches (and runs) a deployed script by hash instead of the pinned
|
|
// content. It need not resolve to a real row — the guard is that it never
|
|
// becomes `runnable_id`.
|
|
const CALLER_HASH: i64 = 123456789;
|
|
// A caller path in someone else's namespace: if it reaches the job as
|
|
// `runnable_path` it redirects where the pinned content's relative imports
|
|
// resolve. Run mode must instead derive the path from `<app_path>/<component>`.
|
|
const CALLER_PATH: &str = "u/attacker/evil/comp";
|
|
|
|
/// The pin key `execute_component` computes for a no-id inline script:
|
|
/// `rawscript/<sha256(content)>`.
|
|
fn rawscript_pin(content: &str) -> String {
|
|
let mut h = Sha256::new();
|
|
h.update(content);
|
|
format!("rawscript/{:x}", h.finalize())
|
|
}
|
|
|
|
fn inline_raw_code(hash: Option<i64>, dedicated: bool) -> serde_json::Value {
|
|
let mut rc = json!({
|
|
"language": "python3",
|
|
"content": CONTENT,
|
|
"path": CALLER_PATH,
|
|
"lock": CALLER_LOCK,
|
|
"modules": {
|
|
"m.py": { "content": "print('x')\n", "language": "python3", "lock": CALLER_LOCK }
|
|
}
|
|
});
|
|
if let Some(h) = hash {
|
|
rc["hash"] = json!(h);
|
|
}
|
|
if dedicated {
|
|
rc["dedicated_worker"] = json!(true);
|
|
}
|
|
rc
|
|
}
|
|
|
|
/// Fetch `(raw_lock, args-has-_MODULES, runnable_id, tag, runnable_path)` for an
|
|
/// enqueued job.
|
|
async fn job_fields(
|
|
db: &Pool<Postgres>,
|
|
uuid: uuid::Uuid,
|
|
) -> anyhow::Result<(Option<String>, bool, Option<i64>, String, Option<String>)> {
|
|
Ok(sqlx::query_as(
|
|
"SELECT raw_lock, (args ? '_MODULES'), runnable_id, tag, runnable_path \
|
|
FROM v2_job WHERE id = $1",
|
|
)
|
|
.bind(uuid)
|
|
.fetch_one(db)
|
|
.await?)
|
|
}
|
|
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_run_mode_strips_caller_lock_and_modules(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
|
|
|
let app_path = "u/test-user/lockstrip";
|
|
let pin = format!("comp:{}", rawscript_pin(CONTENT));
|
|
|
|
// Deployed Viewer-mode app whose only runnable is an inline script pinned by
|
|
// content hash and with no `app_script` row — the legacy `rawscript/<sha>`
|
|
// case that reaches the no-id run-mode arm this fix touches.
|
|
let resp = authed(client().post(format!("{ws}/apps/create")), "SECRET_TOKEN")
|
|
.json(&json!({
|
|
"path": app_path,
|
|
"summary": "",
|
|
"value": {},
|
|
"policy": {
|
|
"execution_mode": "viewer",
|
|
"triggerables_v2": { pin: { "static_inputs": {}, "one_of_inputs": {} } }
|
|
}
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201, "create app: {}", resp.text().await?);
|
|
|
|
// Run mode (no `force_viewer_static_fields`): the pin authorizes the run, but
|
|
// every caller field that selects, installs, or routes code — hash, lock,
|
|
// modules, dedicated_worker — must be dropped.
|
|
let resp = authed(
|
|
client().post(format!("{ws}/apps_u/execute_component/{app_path}")),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.json(&json!({
|
|
// The args map is the other injection channel: an inline run is a
|
|
// `JobKind::Preview` job, so the worker/executors read `_MODULES` and
|
|
// `_TEMP_SCRIPT_REFS` back out of the job args. Both must be stripped.
|
|
"args": {
|
|
"_MODULES": { "m.py": { "content": "print('evil')\n", "language": "python3" } },
|
|
"_TEMP_SCRIPT_REFS": { "../evil": "deadbeef" }
|
|
},
|
|
"component": "comp",
|
|
"raw_code": inline_raw_code(Some(CALLER_HASH), true)
|
|
}))
|
|
.send()
|
|
.await?;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
assert_eq!(
|
|
status, 200,
|
|
"run-mode pinned inline run must be accepted: {body}"
|
|
);
|
|
let uuid = uuid::Uuid::parse_str(body.trim())?;
|
|
let (raw_lock, has_modules, runnable_id, tag, runnable_path) = job_fields(&db, uuid).await?;
|
|
assert_eq!(
|
|
raw_lock, None,
|
|
"run mode must strip the caller-supplied lock"
|
|
);
|
|
assert!(
|
|
!has_modules,
|
|
"run mode must strip caller modules (both `raw_code.modules` and an `_MODULES` arg)"
|
|
);
|
|
let has_temp_refs: bool =
|
|
sqlx::query_scalar("SELECT (args ? '_TEMP_SCRIPT_REFS') FROM v2_job WHERE id = $1")
|
|
.bind(uuid)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
assert!(
|
|
!has_temp_refs,
|
|
"run mode must strip a caller `_TEMP_SCRIPT_REFS` arg (relative-import redirect)"
|
|
);
|
|
assert_eq!(
|
|
runnable_id, None,
|
|
"run mode must strip the caller-supplied hash (no substituting a deployed script by hash)"
|
|
);
|
|
assert!(
|
|
!tag.starts_with("dedi:"),
|
|
"run mode must strip caller `dedicated_worker` (no routing to a path-keyed dedicated worker), got tag {tag:?}"
|
|
);
|
|
assert_eq!(
|
|
runnable_path.as_deref(),
|
|
Some(format!("{app_path}/comp").as_str()),
|
|
"run mode must derive the path server-side, not trust the caller's (relative-import base)"
|
|
);
|
|
|
|
// Preview mode (editor): the caller runs their own code as themselves, so the
|
|
// lock and modules are honored — the `/jobs/run/preview`-equivalent path.
|
|
let resp = authed(
|
|
client().post(format!("{ws}/apps_u/execute_component/{app_path}")),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.json(&json!({
|
|
"args": {},
|
|
"component": "comp",
|
|
"raw_code": inline_raw_code(None, false),
|
|
"force_viewer_static_fields": {}
|
|
}))
|
|
.send()
|
|
.await?;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
assert_eq!(status, 200, "preview must be accepted: {body}");
|
|
let uuid = uuid::Uuid::parse_str(body.trim())?;
|
|
let (raw_lock, has_modules, _, _, _) = job_fields(&db, uuid).await?;
|
|
assert_eq!(
|
|
raw_lock.as_deref(),
|
|
Some(CALLER_LOCK),
|
|
"preview must keep the caller-supplied lock"
|
|
);
|
|
assert!(has_modules, "preview must keep the caller-supplied modules");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A bare `rawscript/<sha>` policy key (no `<component>:` prefix, as `empty_triggerables`
|
|
/// migrates v1 policies) matches for any `component`, so run mode must not let a
|
|
/// path-traversing `component` steer the server-derived `runnable_path`.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_run_mode_rejects_traversal_component(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
|
|
|
let app_path = "u/test-user/lockstrip_bare";
|
|
// Bare key: no `comp:` prefix, so the pin matches regardless of `component`.
|
|
let resp = authed(client().post(format!("{ws}/apps/create")), "SECRET_TOKEN")
|
|
.json(&json!({
|
|
"path": app_path,
|
|
"summary": "",
|
|
"value": {},
|
|
"policy": {
|
|
"execution_mode": "viewer",
|
|
"triggerables_v2": { rawscript_pin(CONTENT): { "static_inputs": {}, "one_of_inputs": {} } }
|
|
}
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201, "create app: {}", resp.text().await?);
|
|
|
|
// A component that isn't a single plain segment steers the derived path's
|
|
// base: separators and `..` traverse, and an empty one shifts it up a level.
|
|
for bad in ["../../u/attacker/evil", "..", "a/b", ""] {
|
|
let resp = authed(
|
|
client().post(format!("{ws}/apps_u/execute_component/{app_path}")),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.json(&json!({
|
|
"args": {},
|
|
"component": bad,
|
|
"raw_code": inline_raw_code(None, false)
|
|
}))
|
|
.send()
|
|
.await?;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
assert_eq!(
|
|
status, 400,
|
|
"run mode must reject component {bad:?}: got {status}: {body}"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A deployed app's inline script (`app_script` id) runs as an `appscript` job whose
|
|
/// path job identity reads (OIDC `sub`), so it is derived server-side there too.
|
|
#[sqlx::test(fixtures("base"))]
|
|
async fn test_app_script_run_derives_path(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let ws = format!("http://localhost:{port}/api/w/test-workspace");
|
|
|
|
let app_path = "u/test-user/appscript_path";
|
|
let pin = rawscript_pin(CONTENT);
|
|
let resp = authed(client().post(format!("{ws}/apps/create")), "SECRET_TOKEN")
|
|
.json(&json!({
|
|
"path": app_path,
|
|
"summary": "",
|
|
"value": {},
|
|
"policy": {
|
|
"execution_mode": "viewer",
|
|
"triggerables_v2": { format!("comp:{pin}"): { "static_inputs": {}, "one_of_inputs": {} } }
|
|
}
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201, "create app: {}", resp.text().await?);
|
|
|
|
let sha = pin.trim_start_matches("rawscript/").to_string();
|
|
let script_id: i64 = sqlx::query_scalar(
|
|
"INSERT INTO app_script (app, hash, code, code_sha256)
|
|
SELECT id, $2, $3, $2 FROM app WHERE path = $1 RETURNING id",
|
|
)
|
|
.bind(app_path)
|
|
.bind(&sha)
|
|
.bind(CONTENT)
|
|
.fetch_one(&db)
|
|
.await?;
|
|
|
|
let resp = authed(
|
|
client().post(format!("{ws}/apps_u/execute_component/{app_path}")),
|
|
"SECRET_TOKEN_2",
|
|
)
|
|
.json(&json!({
|
|
"args": {},
|
|
"component": "comp",
|
|
"id": script_id,
|
|
"raw_code": { "language": "python3", "content": "", "path": CALLER_PATH }
|
|
}))
|
|
.send()
|
|
.await?;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
assert_eq!(status, 200, "deployed app script run must be accepted: {body}");
|
|
let uuid = uuid::Uuid::parse_str(body.trim())?;
|
|
let (_, _, _, _, runnable_path) = job_fields(&db, uuid).await?;
|
|
assert_eq!(
|
|
runnable_path.as_deref(),
|
|
Some(format!("{app_path}/comp").as_str()),
|
|
"an app_script run must not take the caller's path"
|
|
);
|
|
|
|
Ok(())
|
|
}
|