fix: enforce token path scope on hash-based script read routes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-24 12:07:13 +02:00
co-authored by Claude Opus 5.5
parent 318f89960e
commit 2e08490f5b
3 changed files with 81 additions and 5 deletions
@@ -1,20 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "SELECT s.lock, s.lock_error_logs, dm.job_id\n FROM script s\n LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash AND s.workspace_id = dm.workspace_id\n WHERE s.hash = $1 AND s.workspace_id = $2",
"query": "SELECT s.path, s.lock, s.lock_error_logs, dm.job_id\n FROM script s\n LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash AND s.workspace_id = dm.workspace_id\n WHERE s.hash = $1 AND s.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "lock",
"type_info": "Text"
},
{
"ordinal": 1,
"ordinal": 2,
"name": "lock_error_logs",
"type_info": "Text"
},
{
"ordinal": 2,
"ordinal": 3,
"name": "job_id",
"type_info": "Uuid"
}
@@ -26,10 +31,11 @@
]
},
"nullable": [
false,
true,
true,
true
]
},
"hash": "513ed713afdbafb587026d1536c47a9bbaa6967e36777454746b8817d68219a5"
"hash": "3a85abf71e6b3008f92528bd43b9bb8113b163a635b5480911b7aab625d892b9"
}
@@ -1014,3 +1014,68 @@ async fn test_list_search_scope_filtering(db: Pool<Postgres>) -> anyhow::Result<
Ok(())
}
/// The hash-addressed read routes resolve the path only after the route-level scope check, so
/// each must check the token's path scope itself.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_hash_read_routes_enforce_path_scope(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/scripts");
let mut hashes = std::collections::HashMap::new();
for folder in ["allowed", "private"] {
let resp = authed(client().post(format!(
"http://localhost:{port}/api/w/test-workspace/folders/create"
)))
.json(&json!({ "name": folder }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "create folder: {}", resp.text().await?);
let path = format!("f/{folder}/s");
let resp = authed(client().post(format!("{base}/create")))
.json(&new_script(
&path,
"summary",
"export async function main() { return 1; }",
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201, "create {path}: {}", resp.text().await?);
hashes.insert(folder, resp.text().await?);
}
sqlx::query(
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES
(encode(sha256('SCOPED_TOKEN'::bytea), 'hex'), 'SCOPED_TOK', 'SCOPED_TOKEN', 'test@windmill.dev', 'scoped', true, ARRAY['scripts:read:f/allowed/*'])",
)
.execute(&db)
.await?;
for (token, folder, allowed) in [
("SCOPED_TOKEN", "allowed", true),
("SCOPED_TOKEN", "private", false),
("SECRET_TOKEN", "private", true),
] {
let hash = &hashes[folder];
for url in [
format!("{base}/raw/h/{hash}.ts"),
format!("{base}/deployment_status/h/{hash}"),
] {
let status = client()
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.await
.unwrap()
.status();
assert_eq!(status.is_success(), allowed, "{token} GET {url}: {status}");
}
}
Ok(())
}
+6 -1
View File
@@ -3739,6 +3739,7 @@ async fn get_script_by_hash(
}
async fn raw_script_by_hash(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, hash_str)): Path<(String, String)>,
) -> Result<String> {
@@ -3749,6 +3750,8 @@ async fn raw_script_by_hash(
let r = get_script_by_hash_internal(&mut tx, &w_id, &hash, None).await?;
tx.commit().await?;
check_scopes(&authed, || format!("scripts:read:{}", &r.script.path))?;
Ok(r.script.content)
}
@@ -3759,12 +3762,13 @@ struct DeploymentStatus {
job_id: Option<sqlx::types::Uuid>,
}
async fn get_deployment_status(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, hash)): Path<(String, ScriptHash)>,
) -> JsonResult<DeploymentStatus> {
let mut tx = db.begin().await?;
let status_o = sqlx::query!(
"SELECT s.lock, s.lock_error_logs, dm.job_id
"SELECT s.path, s.lock, s.lock_error_logs, dm.job_id
FROM script s
LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash AND s.workspace_id = dm.workspace_id
WHERE s.hash = $1 AND s.workspace_id = $2",
@@ -3775,6 +3779,7 @@ async fn get_deployment_status(
.await?;
let status = not_found_if_none(status_o, "DeploymentStatus", hash.to_string())?;
check_scopes(&authed, || format!("scripts:read:{}", &status.path))?;
let deployment_status = DeploymentStatus {
lock: status.lock,