mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 08:02:26 +00:00
fbdf81ba5f
* fix: authenticate slack callback payload with per-workspace hmac Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: regression tests for unauthenticated slack callback decryption Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: verify slack submission signature before resume + close workspace oracle Addresses review: verify private_metadata HMAC before handle_resume_action so a tampered/unsigned submission is rejected up front, and map get_workspace_key failure to the generic 401 so the status code is not a workspace-existence oracle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: domain-separate slack payload hmac from resume-secret hmac Both MAC families key Hmac<Sha256> on the same per-workspace key; resume secrets are distributed to approvers in resume URLs, so add a fixed domain tag (slack_payload_v1) to the slack payload MAC to make the two non-interchangeable by construction rather than by byte-layout coincidence. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
231 lines
7.5 KiB
Rust
231 lines
7.5 KiB
Rust
//! Regression tests for GHSA-vm75-gmpw-rvp9: the unauthenticated `/api/slack` callback must
|
|
//! not be drivable into decrypting arbitrary workspace variables.
|
|
//!
|
|
//! The OpenModal branch reaches `get_slack_token` (a privileged, RLS-bypassing variable
|
|
//! decryption). It is now gated by a per-workspace HMAC over (w_id, job_id, path) — the same
|
|
//! workspace key used to sign resume URLs. Without a valid signature the request is rejected
|
|
//! with 401 before any decryption, even when `SLACK_SIGNING_SECRET` is unset (the default).
|
|
|
|
use hmac::{Hmac, Mac};
|
|
use serde_json::json;
|
|
use sha2::Sha256;
|
|
use sqlx::{Pool, Postgres};
|
|
use uuid::Uuid;
|
|
use windmill_test_utils::*;
|
|
|
|
fn client() -> reqwest::Client {
|
|
reqwest::Client::new()
|
|
}
|
|
|
|
/// Re-implementation of the server's `sign_slack_payload` for the positive-control test.
|
|
/// The fixture sets `workspace_key.key = 'test-key'` for `test-workspace`.
|
|
fn sign(w_id: &str, parts: &[&[u8]]) -> String {
|
|
let mut mac = Hmac::<Sha256>::new_from_slice(b"test-key").unwrap();
|
|
mac.update(b"slack_payload_v1\0"); // SLACK_PAYLOAD_HMAC_DOMAIN
|
|
mac.update(w_id.as_bytes());
|
|
for p in parts {
|
|
mac.update(b"\0");
|
|
mac.update(p);
|
|
}
|
|
hex::encode(mac.finalize().into_bytes())
|
|
}
|
|
|
|
/// POST an `open_modal` block action to the unauthenticated `/api/slack` callback.
|
|
async fn post_open_modal(port: u16, value: serde_json::Value) -> reqwest::Response {
|
|
let payload = json!({
|
|
"type": "block_actions",
|
|
"trigger_id": "trigger-123",
|
|
"container": { "message_ts": "0", "channel_id": "C1" },
|
|
"actions": [ { "action_id": "open_modal", "value": value.to_string() } ],
|
|
});
|
|
client()
|
|
.post(format!("http://localhost:{port}/api/slack"))
|
|
.form(&[("payload", payload.to_string())])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// POST a `view_submission` to the unauthenticated `/api/slack` callback with the given
|
|
/// private_metadata.
|
|
async fn post_view_submission(port: u16, private_metadata: serde_json::Value) -> reqwest::Response {
|
|
let payload = json!({
|
|
"type": "view_submission",
|
|
"view": {
|
|
"state": { "values": {} },
|
|
"private_metadata": private_metadata.to_string(),
|
|
},
|
|
});
|
|
client()
|
|
.post(format!("http://localhost:{port}/api/slack"))
|
|
.form(&[("payload", payload.to_string())])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// A submission with an unsigned (or tampered) `private_metadata` must be rejected with 401
|
|
/// BEFORE the resume/cancel action runs — the signature gate is checked first. The resume_url
|
|
/// here is well-formed (so it parses) but never acted upon.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_view_submission_without_signature_is_rejected(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let job_id = Uuid::new_v4();
|
|
let resume_url = format!("/api/w/test-workspace/jobs_u/resume/{job_id}/1/deadbeef");
|
|
|
|
let resp = post_view_submission(
|
|
port,
|
|
json!({
|
|
"resume_url": resume_url,
|
|
"resource_path": "u/admin/secret",
|
|
"container": { "message_ts": "0", "channel_id": "C1" },
|
|
"hide_cancel": false,
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"unsigned submission must be rejected before the resume action"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_open_modal_without_signature_is_rejected(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let job_id = Uuid::new_v4();
|
|
|
|
// No signature → must be rejected with 401 before any variable lookup. Before the fix
|
|
// this reached `get_slack_token` and forced decryption of `u/admin/secret`.
|
|
let resp = post_open_modal(
|
|
port,
|
|
json!({
|
|
"w_id": "test-workspace",
|
|
"job_id": job_id.to_string(),
|
|
"path": "u/admin/secret",
|
|
"flow_step_id": "a",
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"unsigned OpenModal callback must be rejected"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_open_modal_with_wrong_signature_is_rejected(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let job_id = Uuid::new_v4();
|
|
|
|
let resp = post_open_modal(
|
|
port,
|
|
json!({
|
|
"w_id": "test-workspace",
|
|
"job_id": job_id.to_string(),
|
|
"path": "u/admin/secret",
|
|
"flow_step_id": "a",
|
|
"signature": "deadbeef",
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"OpenModal callback with an invalid signature must be rejected"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_open_modal_with_tampered_path_is_rejected(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let job_id = Uuid::new_v4();
|
|
|
|
// A signature legitimately minted for one path cannot be reused to decrypt another: the
|
|
// path is bound into the HMAC.
|
|
let signature = sign(
|
|
"test-workspace",
|
|
&[job_id.to_string().as_bytes(), b"u/admin/legit_resource"],
|
|
);
|
|
let resp = post_open_modal(
|
|
port,
|
|
json!({
|
|
"w_id": "test-workspace",
|
|
"job_id": job_id.to_string(),
|
|
"path": "u/admin/some_other_secret",
|
|
"flow_step_id": "a",
|
|
"signature": signature,
|
|
}),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
resp.status(),
|
|
401,
|
|
"a signature bound to a different path must not authorize decryption"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_open_modal_with_valid_signature_passes_the_gate(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let job_id = Uuid::new_v4();
|
|
let path = "u/admin/nonexistent_resource";
|
|
|
|
// A correctly signed payload passes the authorization gate and proceeds to resolve the
|
|
// slack resource. The resource does not exist, so the handler returns a generic 400
|
|
// ("Invalid Slack callback request") rather than 401 — proving the gate accepted the
|
|
// signature (so the fix does not simply reject everything) without echoing the path.
|
|
let signature = sign(
|
|
"test-workspace",
|
|
&[job_id.to_string().as_bytes(), path.as_bytes()],
|
|
);
|
|
let resp = post_open_modal(
|
|
port,
|
|
json!({
|
|
"w_id": "test-workspace",
|
|
"job_id": job_id.to_string(),
|
|
"path": path,
|
|
"flow_step_id": "a",
|
|
"signature": signature,
|
|
}),
|
|
)
|
|
.await;
|
|
let status = resp.status();
|
|
let body = resp.text().await?;
|
|
assert_eq!(
|
|
status, 400,
|
|
"validly signed callback should pass the gate and 400 on the missing resource, got {status}: {body}"
|
|
);
|
|
assert!(
|
|
!body.contains("nonexistent_resource"),
|
|
"error must not echo the probed path: {body}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|