From b6b6470c9e7d3f70bfb7c7d996d2d507d58a75e8 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 23 Sep 2026 15:50:21 +0200 Subject: [PATCH] test: pin rls and scope checks on by-hash script endpoints Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/scripts.rs | 55 +++++++++++++++++++ backend/windmill-api-scripts/src/scripts.rs | 2 +- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/backend/windmill-api-integration-tests/tests/scripts.rs b/backend/windmill-api-integration-tests/tests/scripts.rs index 3a146add27..b02488b287 100644 --- a/backend/windmill-api-integration-tests/tests/scripts.rs +++ b/backend/windmill-api-integration-tests/tests/scripts.rs @@ -1014,3 +1014,58 @@ async fn test_list_search_scope_filtering(db: Pool) -> anyhow::Result< Ok(()) } + +/// A hash alone must not read back a script the caller cannot see: RLS hides it from a +/// non-owner, and a path-scoped token is refused outside its scope. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_by_hash_endpoints_enforce_rls_and_scopes(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = authed(client().post(format!( + "http://localhost:{port}/api/w/test-workspace/scripts/create" + ))) + .json(&new_script( + "u/test-user/secret", + "", + "export async function main() { return 'secret'; }", + )) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201, "create: {}", resp.text().await?); + let hash = 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/other/*'])", + ) + .execute(&db) + .await?; + + for (endpoint, path) in [ + ("raw/h", format!("{hash}.ts")), + ("deployment_status/h", hash.clone()), + ] { + let get = |token: &'static str| { + client() + .get(script_url(port, endpoint, &path)) + .header("Authorization", format!("Bearer {token}")) + .send() + }; + assert_eq!(get("SECRET_TOKEN").await?.status(), 200, "{endpoint} owner"); + assert_eq!( + get("SECRET_TOKEN_2").await?.status(), + 404, + "{endpoint} non-owner" + ); + assert_eq!( + get("SCOPED_TOKEN").await?.status(), + 403, + "{endpoint} out of scope" + ); + } + + Ok(()) +} diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 6324bfaaf0..1d023a3275 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -1418,7 +1418,7 @@ async fn create_script_internal<'c>( if let Some(clashing_hash) = clashing_hash_o { // Named only when the caller could already read it. The probe above has to be // unscoped to be correct, but a hash alone reads a script's content back - // through `raw/h/{hash}`, which authorizes nothing per script — so echoing one + // through `get/h/{hash}`, which reads unscoped by default — so echoing one // the caller cannot see hands them a way to fetch it. let visible_to_caller = sqlx::query_scalar!( "SELECT 1 FROM script WHERE hash = $1 AND workspace_id = $2",