diff --git a/backend/.sqlx/query-513ed713afdbafb587026d1536c47a9bbaa6967e36777454746b8817d68219a5.json b/backend/.sqlx/query-3a85abf71e6b3008f92528bd43b9bb8113b163a635b5480911b7aab625d892b9.json similarity index 53% rename from backend/.sqlx/query-513ed713afdbafb587026d1536c47a9bbaa6967e36777454746b8817d68219a5.json rename to backend/.sqlx/query-3a85abf71e6b3008f92528bd43b9bb8113b163a635b5480911b7aab625d892b9.json index 31187af03a..590e610cc6 100644 --- a/backend/.sqlx/query-513ed713afdbafb587026d1536c47a9bbaa6967e36777454746b8817d68219a5.json +++ b/backend/.sqlx/query-3a85abf71e6b3008f92528bd43b9bb8113b163a635b5480911b7aab625d892b9.json @@ -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" } diff --git a/backend/windmill-api-integration-tests/tests/scripts.rs b/backend/windmill-api-integration-tests/tests/scripts.rs index 3a146add27..5d1cb69133 100644 --- a/backend/windmill-api-integration-tests/tests/scripts.rs +++ b/backend/windmill-api-integration-tests/tests/scripts.rs @@ -1014,3 +1014,68 @@ async fn test_list_search_scope_filtering(db: Pool) -> 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) -> 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(()) +} diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 51f2619805..4aca8885ca 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -3739,6 +3739,7 @@ async fn get_script_by_hash( } async fn raw_script_by_hash( + authed: ApiAuthed, Extension(db): Extension, Path((w_id, hash_str)): Path<(String, String)>, ) -> Result { @@ -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, } async fn get_deployment_status( + authed: ApiAuthed, Extension(db): Extension, Path((w_id, hash)): Path<(String, ScriptHash)>, ) -> JsonResult { 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,