mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
[codex] Fix authz, scope propagation, and shell-injection bugs (#320)
This commit is contained in:
@@ -68,6 +68,10 @@ pub struct AuthContext {
|
||||
pub pubkey: nostr::PublicKey,
|
||||
/// Permission scopes granted to this connection.
|
||||
pub scopes: Vec<Scope>,
|
||||
/// Token-level channel restriction, if authentication used a scoped API token.
|
||||
///
|
||||
/// `None` means unrestricted or not token-authenticated.
|
||||
pub channel_ids: Option<Vec<uuid::Uuid>>,
|
||||
/// How the connection was authenticated.
|
||||
pub auth_method: AuthMethod,
|
||||
}
|
||||
@@ -187,6 +191,7 @@ impl AuthService {
|
||||
Ok(AuthContext {
|
||||
pubkey: verified_pubkey,
|
||||
scopes,
|
||||
channel_ids: None,
|
||||
auth_method,
|
||||
})
|
||||
}
|
||||
@@ -417,6 +422,7 @@ mod tests {
|
||||
let ctx = AuthContext {
|
||||
pubkey: keys.public_key(),
|
||||
scopes: vec![Scope::MessagesRead, Scope::ChannelsRead],
|
||||
channel_ids: None,
|
||||
auth_method: AuthMethod::Nip42PubkeyOnly,
|
||||
};
|
||||
assert!(ctx.has_scope(&Scope::MessagesRead));
|
||||
|
||||
@@ -13,7 +13,7 @@ use nostr::util::hex as nostr_hex;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{extract_auth_context, internal_error};
|
||||
use super::{constrain_accessible_channels, extract_auth_context, internal_error};
|
||||
|
||||
/// Returns all bot/agent members visible to the authenticated user, with presence status.
|
||||
///
|
||||
@@ -28,14 +28,17 @@ pub async fn agents_handler(
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
// Get requester's accessible channels to filter bot channel visibility.
|
||||
let accessible_channels = state
|
||||
.db
|
||||
.get_accessible_channels(&pubkey_bytes, None, None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("agents: failed to load accessible channels: {e}");
|
||||
internal_error("presence lookup failed")
|
||||
})?;
|
||||
let accessible_channels = constrain_accessible_channels(
|
||||
state
|
||||
.db
|
||||
.get_accessible_channels(&pubkey_bytes, None, None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("agents: failed to load accessible channels: {e}");
|
||||
internal_error("presence lookup failed")
|
||||
})?,
|
||||
ctx.channel_ids.as_deref(),
|
||||
);
|
||||
let accessible_ids: std::collections::HashSet<String> = accessible_channels
|
||||
.iter()
|
||||
.map(|ac| ac.channel.id.to_string())
|
||||
|
||||
@@ -17,7 +17,7 @@ use sprout_db::channel::ChannelRecord;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{extract_auth_context, internal_error};
|
||||
use super::{constrain_accessible_channels, extract_auth_context, internal_error};
|
||||
|
||||
/// Query parameters for `GET /api/channels`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -41,11 +41,14 @@ pub async fn channels_handler(
|
||||
.map_err(super::scope_error)?;
|
||||
let pubkey_bytes = ctx.pubkey_bytes.clone();
|
||||
|
||||
let channels = state
|
||||
.db
|
||||
.get_accessible_channels(&pubkey_bytes, params.visibility.as_deref(), params.member)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
let channels = constrain_accessible_channels(
|
||||
state
|
||||
.db
|
||||
.get_accessible_channels(&pubkey_bytes, params.visibility.as_deref(), params.member)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?,
|
||||
ctx.channel_ids.as_deref(),
|
||||
);
|
||||
|
||||
// Bulk-fetch member counts and last-message timestamps in two queries
|
||||
// instead of 2N queries (one per channel per metric).
|
||||
|
||||
@@ -21,7 +21,7 @@ use sprout_core::kind::{self, event_kind_u32};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::{extract_auth_context, internal_error};
|
||||
use super::{constrain_channel_ids, extract_auth_context, internal_error};
|
||||
|
||||
/// Agent activity kind set — used to partition activity into agent vs channel activity.
|
||||
const AGENT_KINDS: &[u32] = &[
|
||||
@@ -71,11 +71,14 @@ pub async fn feed_handler(
|
||||
.map(|t| t.split(',').map(|s| s.trim()).collect());
|
||||
let wants = |cat: &str| -> bool { type_filter.as_ref().is_none_or(|f| f.contains(cat)) };
|
||||
|
||||
let accessible_ids = state
|
||||
.db
|
||||
.get_accessible_channel_ids(&pubkey_bytes)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?;
|
||||
let accessible_ids = constrain_channel_ids(
|
||||
state
|
||||
.db
|
||||
.get_accessible_channel_ids(&pubkey_bytes)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("db error: {e}")))?,
|
||||
ctx.channel_ids.as_deref(),
|
||||
);
|
||||
|
||||
if accessible_ids.is_empty() {
|
||||
let generated_at = Utc::now().timestamp();
|
||||
|
||||
@@ -429,6 +429,28 @@ pub fn check_token_channel_access(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Intersect an owner-accessible channel list with a token's `channel_ids`, when present.
|
||||
pub(crate) fn constrain_channel_ids(
|
||||
mut channel_ids: Vec<Uuid>,
|
||||
allowed: Option<&[Uuid]>,
|
||||
) -> Vec<Uuid> {
|
||||
if let Some(allowed) = allowed {
|
||||
channel_ids.retain(|channel_id| allowed.contains(channel_id));
|
||||
}
|
||||
channel_ids
|
||||
}
|
||||
|
||||
/// Filter accessible channel records against a token's `channel_ids`, when present.
|
||||
pub(crate) fn constrain_accessible_channels(
|
||||
mut channels: Vec<sprout_db::channel::AccessibleChannel>,
|
||||
allowed: Option<&[Uuid]>,
|
||||
) -> Vec<sprout_db::channel::AccessibleChannel> {
|
||||
if let Some(allowed) = allowed {
|
||||
channels.retain(|channel| allowed.contains(&channel.channel.id));
|
||||
}
|
||||
channels
|
||||
}
|
||||
|
||||
/// Convert a scope-check failure into a 403 Forbidden response.
|
||||
///
|
||||
/// Used by handlers to propagate `require_scope` errors via `?`.
|
||||
@@ -557,8 +579,41 @@ where
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Utc;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn accessible_channel(id: Uuid) -> sprout_db::channel::AccessibleChannel {
|
||||
let now = Utc::now();
|
||||
sprout_db::channel::AccessibleChannel {
|
||||
channel: sprout_db::channel::ChannelRecord {
|
||||
id,
|
||||
name: "restricted".to_string(),
|
||||
channel_type: "stream".to_string(),
|
||||
visibility: "private".to_string(),
|
||||
description: None,
|
||||
canvas: None,
|
||||
created_by: vec![0; 32],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
archived_at: None,
|
||||
deleted_at: None,
|
||||
nip29_group_id: None,
|
||||
topic_required: false,
|
||||
max_members: None,
|
||||
topic: None,
|
||||
topic_set_by: None,
|
||||
topic_set_at: None,
|
||||
purpose: None,
|
||||
purpose_set_by: None,
|
||||
purpose_set_at: None,
|
||||
ttl_seconds: None,
|
||||
ttl_deadline: None,
|
||||
},
|
||||
is_member: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ── decode_jwt_payload_unverified ─────────────────────────────────────────
|
||||
//
|
||||
// This private helper is the core of the dev-mode JWT path in
|
||||
@@ -763,4 +818,38 @@ mod tests {
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(body.0["error"], "approval not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrain_channel_ids_intersects_with_token_allowlist() {
|
||||
let allowed = uuid::Uuid::new_v4();
|
||||
let denied = uuid::Uuid::new_v4();
|
||||
|
||||
let constrained = constrain_channel_ids(vec![allowed, denied], Some(&[allowed]));
|
||||
|
||||
assert_eq!(constrained, vec![allowed]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrain_channel_ids_leaves_unrestricted_lists_unchanged() {
|
||||
let a = uuid::Uuid::new_v4();
|
||||
let b = uuid::Uuid::new_v4();
|
||||
|
||||
let constrained = constrain_channel_ids(vec![a, b], None);
|
||||
|
||||
assert_eq!(constrained, vec![a, b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrain_accessible_channels_respects_token_allowlist() {
|
||||
let allowed = uuid::Uuid::new_v4();
|
||||
let denied = uuid::Uuid::new_v4();
|
||||
|
||||
let constrained = constrain_accessible_channels(
|
||||
vec![accessible_channel(allowed), accessible_channel(denied)],
|
||||
Some(&[allowed]),
|
||||
);
|
||||
|
||||
assert_eq!(constrained.len(), 1);
|
||||
assert_eq!(constrained[0].channel.id, allowed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use sprout_search::SearchQuery;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::extract_auth_context;
|
||||
use super::{constrain_channel_ids, extract_auth_context};
|
||||
|
||||
/// Query parameters for the search endpoint.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -42,21 +42,32 @@ pub async fn search_handler(
|
||||
let query_str = params.q.unwrap_or_default();
|
||||
let per_page = params.limit.unwrap_or(20).min(100);
|
||||
|
||||
let channel_ids = state
|
||||
.db
|
||||
.get_accessible_channel_ids(&pubkey_bytes)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let channel_ids = constrain_channel_ids(
|
||||
state
|
||||
.db
|
||||
.get_accessible_channel_ids(&pubkey_bytes)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
ctx.channel_ids.as_deref(),
|
||||
);
|
||||
|
||||
// Build Typesense filter_by: channel_id:=[id1,id2,...] || global events
|
||||
// Channel-restricted tokens must stay within their allowlist; unrestricted callers
|
||||
// may also see global events.
|
||||
let include_global = ctx.channel_ids.is_none();
|
||||
if channel_ids.is_empty() && !include_global {
|
||||
return Ok(Json(serde_json::json!({ "hits": [], "found": 0 })));
|
||||
}
|
||||
let filter_by = if channel_ids.is_empty() {
|
||||
Some("channel_id:=__global__".to_string())
|
||||
} else {
|
||||
} else if include_global {
|
||||
let ids: Vec<String> = channel_ids.iter().map(|id| id.to_string()).collect();
|
||||
Some(format!(
|
||||
"(channel_id:=[{}] || channel_id:=__global__)",
|
||||
ids.join(",")
|
||||
))
|
||||
} else {
|
||||
let ids: Vec<String> = channel_ids.iter().map(|id| id.to_string()).collect();
|
||||
Some(format!("channel_id:=[{}]", ids.join(",")))
|
||||
};
|
||||
|
||||
let search_query = SearchQuery {
|
||||
|
||||
@@ -189,6 +189,29 @@ pub struct RevokeAllResponse {
|
||||
pub revoked_count: u64,
|
||||
}
|
||||
|
||||
fn ensure_requested_scopes_within_caller(
|
||||
ctx: &super::RestAuthContext,
|
||||
requested_scopes: &[Scope],
|
||||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||||
if matches!(ctx.auth_method, RestAuthMethod::Nip98) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for scope in requested_scopes {
|
||||
if !ctx.scopes.contains(scope) {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "scope_escalation",
|
||||
"message": format!("Cannot mint scope '{}' — not in your token's scopes", scope)
|
||||
})),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `POST /api/tokens` — mint a new API token.
|
||||
@@ -343,24 +366,14 @@ pub async fn post_tokens(
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
parsed_scopes.retain(|s| seen.insert(s.clone()));
|
||||
|
||||
// ── Scope escalation prevention (Bearer-authenticated callers) ───────────
|
||||
// If the caller authenticated via an API token, the requested scopes must be
|
||||
// a subset of the caller's own scopes, and channel_ids must be a subset too.
|
||||
// NIP-98 and Okta JWT callers are unrestricted (they authenticate the identity
|
||||
// directly, not via a scoped token).
|
||||
if matches!(ctx.auth_method, RestAuthMethod::ApiToken) {
|
||||
for scope in &parsed_scopes {
|
||||
if !ctx.scopes.contains(scope) {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "scope_escalation",
|
||||
"message": format!("Cannot mint scope '{}' — not in your token's scopes", scope)
|
||||
})),
|
||||
));
|
||||
}
|
||||
}
|
||||
// ── Scope escalation prevention (all non-bootstrap callers) ──────────────
|
||||
// Any caller that arrived with an already-authorized identity must stay within
|
||||
// the scopes granted to that identity. NIP-98 bootstrap mints are the only
|
||||
// exception because they deliberately authenticate ownership, not preexisting
|
||||
// relay scopes.
|
||||
ensure_requested_scopes_within_caller(&ctx, &parsed_scopes)?;
|
||||
|
||||
if !matches!(ctx.auth_method, RestAuthMethod::Nip98) {
|
||||
// If caller has channel_ids restriction, child must also be restricted
|
||||
// to a subset of those channels.
|
||||
if let Some(ref caller_channels) = ctx.channel_ids {
|
||||
@@ -423,7 +436,7 @@ pub async fn post_tokens(
|
||||
// token is channel-restricted, this would be an escalation. The subset
|
||||
// check above already rejects `None` for restricted callers, so we
|
||||
// must also reject empty arrays here.
|
||||
if matches!(ctx.auth_method, RestAuthMethod::ApiToken) && ctx.channel_ids.is_some() {
|
||||
if ctx.channel_ids.is_some() {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
@@ -816,6 +829,24 @@ fn reconstruct_canonical_url_for_tokens(relay_url: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::Keys;
|
||||
|
||||
fn auth_context(
|
||||
auth_method: RestAuthMethod,
|
||||
scopes: Vec<Scope>,
|
||||
channel_ids: Option<Vec<Uuid>>,
|
||||
) -> super::super::RestAuthContext {
|
||||
let keys = Keys::generate();
|
||||
let pubkey = keys.public_key();
|
||||
super::super::RestAuthContext {
|
||||
pubkey,
|
||||
pubkey_bytes: pubkey.to_bytes().to_vec(),
|
||||
scopes,
|
||||
auth_method,
|
||||
token_id: None,
|
||||
channel_ids,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_allows_up_to_limit() {
|
||||
@@ -883,4 +914,22 @@ mod tests {
|
||||
let url = reconstruct_canonical_url_for_tokens("wss://relay.example.test/");
|
||||
assert_eq!(url, "https://relay.example.test/api/tokens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_callers_cannot_self_mint_new_scopes() {
|
||||
let ctx = auth_context(RestAuthMethod::OktaJwt, vec![Scope::MessagesRead], None);
|
||||
|
||||
let err = ensure_requested_scopes_within_caller(&ctx, &[Scope::MessagesWrite])
|
||||
.expect_err("bearer-auth callers must stay within their granted scopes");
|
||||
|
||||
assert_eq!(err.0, StatusCode::FORBIDDEN);
|
||||
assert_eq!(err.1 .0["error"].as_str(), Some("scope_escalation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nip98_bootstrap_mints_are_not_limited_by_existing_scope_list() {
|
||||
let ctx = auth_context(RestAuthMethod::Nip98, Vec::new(), None);
|
||||
|
||||
assert!(ensure_requested_scopes_within_caller(&ctx, &[Scope::MessagesWrite]).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,61 @@ pub struct CreateWorkflowBody {
|
||||
pub yaml_definition: String,
|
||||
}
|
||||
|
||||
fn require_workflow_owner(
|
||||
workflow: &sprout_db::workflow::WorkflowRecord,
|
||||
caller_pubkey: &[u8],
|
||||
action: &str,
|
||||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||||
if workflow.owner_pubkey != caller_pubkey {
|
||||
return Err(forbidden(&format!(
|
||||
"not authorized to {action} this workflow"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_send_message_targets(
|
||||
def: &sprout_workflow::WorkflowDef,
|
||||
workflow_channel_id: Option<uuid::Uuid>,
|
||||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||||
for step in &def.steps {
|
||||
if let sprout_workflow::ActionDef::SendMessage {
|
||||
channel: Some(channel),
|
||||
..
|
||||
} = &step.action
|
||||
{
|
||||
let trimmed = channel.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let target_channel = uuid::Uuid::parse_str(trimmed).map_err(|_| {
|
||||
api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!(
|
||||
"invalid workflow YAML: step '{}' has an invalid send_message.channel UUID",
|
||||
step.id
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(workflow_channel_id) = workflow_channel_id {
|
||||
if target_channel != workflow_channel_id {
|
||||
return Err(api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!(
|
||||
"invalid workflow YAML: step '{}' cannot override send_message.channel outside workflow channel {}",
|
||||
step.id, workflow_channel_id
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new workflow in a channel.
|
||||
///
|
||||
/// Parses and validates the YAML definition, generates a webhook secret if needed,
|
||||
@@ -96,6 +151,7 @@ pub async fn create_workflow(
|
||||
&format!("invalid workflow YAML: {e}"),
|
||||
)
|
||||
})?;
|
||||
validate_send_message_targets(&def, Some(channel_id))?;
|
||||
|
||||
validate_webhook_urls(&def)
|
||||
.await
|
||||
@@ -210,9 +266,8 @@ pub async fn update_workflow(
|
||||
if let Some(channel_id) = existing.channel_id {
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
} else if existing.owner_pubkey != pubkey_bytes {
|
||||
return Err(forbidden("not authorized to access this workflow"));
|
||||
}
|
||||
require_workflow_owner(&existing, &pubkey_bytes, "update")?;
|
||||
|
||||
let (def, definition_json_str) =
|
||||
sprout_workflow::WorkflowEngine::parse_yaml(&body.yaml_definition).map_err(|e| {
|
||||
@@ -221,6 +276,7 @@ pub async fn update_workflow(
|
||||
&format!("invalid workflow YAML: {e}"),
|
||||
)
|
||||
})?;
|
||||
validate_send_message_targets(&def, existing.channel_id)?;
|
||||
|
||||
validate_webhook_urls(&def)
|
||||
.await
|
||||
@@ -271,7 +327,7 @@ pub async fn update_workflow(
|
||||
|
||||
// ── DELETE /api/workflows/:id ─────────────────────────────────────────────────
|
||||
|
||||
/// Delete a workflow. Only the owner or a channel member may delete.
|
||||
/// Delete a workflow. Only the workflow owner may delete it.
|
||||
pub async fn delete_workflow(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
@@ -291,16 +347,13 @@ pub async fn delete_workflow(
|
||||
.await
|
||||
.map_err(|_| not_found("workflow not found"))?;
|
||||
|
||||
if workflow.owner_pubkey != pubkey_bytes {
|
||||
if let Some(channel_id) = workflow.channel_id {
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes)
|
||||
.await
|
||||
.map_err(|_| forbidden("not authorized to delete this workflow"))?;
|
||||
} else {
|
||||
return Err(forbidden("not authorized to delete this workflow"));
|
||||
}
|
||||
if let Some(channel_id) = workflow.channel_id {
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes)
|
||||
.await
|
||||
.map_err(|_| forbidden("not authorized to delete this workflow"))?;
|
||||
}
|
||||
require_workflow_owner(&workflow, &pubkey_bytes, "delete")?;
|
||||
|
||||
state
|
||||
.db
|
||||
@@ -345,9 +398,8 @@ pub async fn list_workflow_runs(
|
||||
if let Some(channel_id) = workflow.channel_id {
|
||||
check_token_channel_access(&ctx, &channel_id)?;
|
||||
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
|
||||
} else if workflow.owner_pubkey != pubkey_bytes {
|
||||
return Err(forbidden("not authorized to access this workflow"));
|
||||
}
|
||||
require_workflow_owner(&workflow, &pubkey_bytes, "trigger")?;
|
||||
|
||||
let limit = params.limit.unwrap_or(20).min(100) as i64;
|
||||
let runs = state
|
||||
@@ -430,7 +482,13 @@ pub async fn trigger_workflow(
|
||||
return Err(forbidden("not authorized to access this workflow"));
|
||||
}
|
||||
|
||||
let trigger_ctx = sprout_workflow::executor::TriggerContext::default();
|
||||
let trigger_ctx = sprout_workflow::executor::TriggerContext {
|
||||
channel_id: workflow
|
||||
.channel_id
|
||||
.map(|channel_id| channel_id.to_string())
|
||||
.unwrap_or_default(),
|
||||
..Default::default()
|
||||
};
|
||||
let trigger_ctx_json = serde_json::to_value(&trigger_ctx).ok();
|
||||
|
||||
let run_id = state
|
||||
@@ -533,7 +591,13 @@ pub async fn workflow_webhook(
|
||||
|
||||
// Build trigger context from webhook body fields before creating the run so
|
||||
// we can persist it immediately (needed for post-approval resume).
|
||||
let mut trigger_ctx = sprout_workflow::executor::TriggerContext::default();
|
||||
let mut trigger_ctx = sprout_workflow::executor::TriggerContext {
|
||||
channel_id: workflow
|
||||
.channel_id
|
||||
.map(|channel_id| channel_id.to_string())
|
||||
.unwrap_or_default(),
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(serde_json::Value::Object(ref map)) = body_json {
|
||||
for (k, v) in map {
|
||||
let val_str = match v {
|
||||
@@ -568,3 +632,80 @@ pub async fn workflow_webhook(
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Utc;
|
||||
use nostr::Keys;
|
||||
use sprout_db::workflow::{WorkflowRecord, WorkflowStatus};
|
||||
use sprout_workflow::{ActionDef, Step, TriggerDef, WorkflowDef};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn workflow_record(owner_pubkey: Vec<u8>) -> WorkflowRecord {
|
||||
WorkflowRecord {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "regression".to_string(),
|
||||
owner_pubkey,
|
||||
channel_id: Some(uuid::Uuid::new_v4()),
|
||||
definition: serde_json::json!({}),
|
||||
definition_hash: vec![0; 32],
|
||||
status: WorkflowStatus::Active,
|
||||
enabled: true,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn workflow_with_send_message_target(channel_id: uuid::Uuid) -> WorkflowDef {
|
||||
WorkflowDef {
|
||||
name: "cross-channel".to_string(),
|
||||
description: None,
|
||||
trigger: TriggerDef::MessagePosted { filter: None },
|
||||
steps: vec![Step {
|
||||
id: "notify".to_string(),
|
||||
name: None,
|
||||
if_expr: None,
|
||||
timeout_secs: None,
|
||||
action: ActionDef::SendMessage {
|
||||
text: "hello".to_string(),
|
||||
channel: Some(channel_id.to_string()),
|
||||
},
|
||||
}],
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_mutations_require_the_owner_pubkey() {
|
||||
let owner = Keys::generate().public_key().serialize().to_vec();
|
||||
let caller = Keys::generate().public_key().serialize().to_vec();
|
||||
let workflow = workflow_record(owner);
|
||||
|
||||
let err = require_workflow_owner(&workflow, &caller, "update")
|
||||
.expect_err("non-owners must not be able to update workflows");
|
||||
|
||||
assert_eq!(err.0, StatusCode::FORBIDDEN);
|
||||
assert_eq!(
|
||||
err.1 .0["error"].as_str(),
|
||||
Some("not authorized to update this workflow")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_workflows_cannot_override_send_message_destination() {
|
||||
let workflow_channel_id = uuid::Uuid::new_v4();
|
||||
let other_channel_id = uuid::Uuid::new_v4();
|
||||
let def = workflow_with_send_message_target(other_channel_id);
|
||||
|
||||
let err = validate_send_message_targets(&def, Some(workflow_channel_id))
|
||||
.expect_err("channel workflows must not be able to send outside their channel");
|
||||
let expected = format!(
|
||||
"invalid workflow YAML: step 'notify' cannot override send_message.channel outside workflow channel {}",
|
||||
workflow_channel_id
|
||||
);
|
||||
|
||||
assert_eq!(err.0, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(err.1 .0["error"].as_str(), Some(expected.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,14 @@ use crate::connection::{AuthState, ConnectionState};
|
||||
use crate::protocol::RelayMessage;
|
||||
use crate::state::AppState;
|
||||
|
||||
fn verify_api_token_nip42_binding(
|
||||
event: &nostr::Event,
|
||||
challenge: &str,
|
||||
relay_url: &str,
|
||||
) -> Result<(), sprout_auth::AuthError> {
|
||||
sprout_auth::verify_nip42_event(event, challenge, relay_url)
|
||||
}
|
||||
|
||||
/// Handle a NIP-42 AUTH message: verify the challenge response and transition the connection to authenticated state.
|
||||
pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state: Arc<AppState>) {
|
||||
let event_id_hex_early = event.id.to_hex();
|
||||
@@ -57,6 +65,41 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
|
||||
if let Some(ref token) = auth_token {
|
||||
if token.starts_with("sprout_") {
|
||||
// ── API token path ──────────────────────────────────────────────
|
||||
let event_clone = event.clone();
|
||||
let challenge_owned = challenge.clone();
|
||||
let relay_owned = relay_url.clone();
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
verify_api_token_nip42_binding(&event_clone, &challenge_owned, &relay_owned)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
warn!(conn_id = %conn_id, error = %e, "API token auth failed NIP-42 verification");
|
||||
metrics::counter!("sprout_auth_failures_total", "reason" => "nip42_invalid")
|
||||
.increment(1);
|
||||
*conn.auth_state.write().await = AuthState::Failed;
|
||||
conn.send(RelayMessage::ok(
|
||||
&event_id_hex,
|
||||
false,
|
||||
"auth-required: verification failed",
|
||||
));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(conn_id = %conn_id, error = %e, "API token NIP-42 verification task failed");
|
||||
metrics::counter!("sprout_auth_failures_total", "reason" => "nip42_internal")
|
||||
.increment(1);
|
||||
*conn.auth_state.write().await = AuthState::Failed;
|
||||
conn.send(RelayMessage::ok(
|
||||
&event_id_hex,
|
||||
false,
|
||||
"auth-required: verification failed",
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Hash the raw token and look it up in the DB. The relay owns this
|
||||
// path; sprout-auth has no DB access.
|
||||
let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
|
||||
@@ -122,6 +165,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
|
||||
let auth_ctx = sprout_auth::AuthContext {
|
||||
pubkey,
|
||||
scopes,
|
||||
channel_ids: record.channel_ids,
|
||||
auth_method: sprout_auth::AuthMethod::Nip42ApiToken,
|
||||
};
|
||||
// API token users have already proven authorization via their token —
|
||||
@@ -201,3 +245,50 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr::{Event, EventBuilder, Keys, Tag, Url};
|
||||
use sprout_auth::AuthError;
|
||||
|
||||
use super::*;
|
||||
|
||||
const TEST_RELAY: &str = "wss://relay.example.com";
|
||||
|
||||
fn make_api_token_auth_event(
|
||||
keys: &Keys,
|
||||
challenge: &str,
|
||||
relay_url: &str,
|
||||
token: &str,
|
||||
) -> Event {
|
||||
let url: Url = relay_url.parse().expect("valid relay url");
|
||||
let auth_token = Tag::parse(&["auth_token", token]).expect("valid auth_token tag");
|
||||
EventBuilder::auth(challenge, url)
|
||||
.add_tags(vec![auth_token])
|
||||
.sign_with_keys(keys)
|
||||
.expect("signing failed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_token_auth_still_requires_a_valid_nip42_challenge() {
|
||||
let keys = Keys::generate();
|
||||
let challenge = sprout_auth::generate_challenge();
|
||||
let event =
|
||||
make_api_token_auth_event(&keys, &challenge, TEST_RELAY, "sprout_test_api_token");
|
||||
|
||||
assert!(matches!(
|
||||
verify_api_token_nip42_binding(&event, "wrong-challenge", TEST_RELAY),
|
||||
Err(AuthError::ChallengeMismatch)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_token_auth_accepts_a_valid_nip42_proof() {
|
||||
let keys = Keys::generate();
|
||||
let challenge = sprout_auth::generate_challenge();
|
||||
let event =
|
||||
make_api_token_auth_event(&keys, &challenge, TEST_RELAY, "sprout_test_api_token");
|
||||
|
||||
assert!(verify_api_token_nip42_binding(&event, &challenge, TEST_RELAY).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
|
||||
metrics::counter!("sprout_events_received_total", "kind" => kind_str.clone()).increment(1);
|
||||
|
||||
// ── Extract auth from WS connection state ────────────────────────────
|
||||
let (conn_id, pubkey_bytes, auth_pubkey, scopes) = {
|
||||
let (conn_id, pubkey_bytes, auth_pubkey, scopes, channel_ids) = {
|
||||
let auth = conn.auth_state.read().await;
|
||||
match &*auth {
|
||||
AuthState::Authenticated(ctx) => (
|
||||
@@ -166,6 +166,7 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
|
||||
ctx.pubkey.serialize().to_vec(),
|
||||
ctx.pubkey,
|
||||
ctx.scopes.clone(),
|
||||
ctx.channel_ids.clone(),
|
||||
),
|
||||
_ => {
|
||||
reject("auth");
|
||||
@@ -241,6 +242,7 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
|
||||
let ingest_auth = IngestAuth::Nip42 {
|
||||
pubkey: auth_pubkey,
|
||||
scopes,
|
||||
channel_ids,
|
||||
conn_id,
|
||||
};
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ pub enum IngestAuth {
|
||||
pubkey: nostr::PublicKey,
|
||||
/// Permission scopes granted to this connection.
|
||||
scopes: Vec<Scope>,
|
||||
/// Token-level channel restriction, if the WebSocket auth used an API token.
|
||||
channel_ids: Option<Vec<Uuid>>,
|
||||
/// WebSocket connection identifier.
|
||||
conn_id: Uuid,
|
||||
},
|
||||
@@ -101,7 +103,11 @@ impl IngestAuth {
|
||||
/// Token-level channel restriction (Http/ApiToken only).
|
||||
pub fn channel_ids(&self) -> Option<&[Uuid]> {
|
||||
match self {
|
||||
Self::Http {
|
||||
Self::Nip42 {
|
||||
channel_ids: Some(ids),
|
||||
..
|
||||
}
|
||||
| Self::Http {
|
||||
channel_ids: Some(ids),
|
||||
..
|
||||
} => Some(ids),
|
||||
@@ -1536,6 +1542,7 @@ mod tests {
|
||||
let ws_auth = IngestAuth::Nip42 {
|
||||
pubkey: keys.public_key(),
|
||||
scopes: vec![],
|
||||
channel_ids: None,
|
||||
conn_id: uuid::Uuid::new_v4(),
|
||||
};
|
||||
assert!(
|
||||
|
||||
@@ -29,7 +29,7 @@ pub async fn handle_req(
|
||||
conn: Arc<ConnectionState>,
|
||||
state: Arc<AppState>,
|
||||
) {
|
||||
let (conn_id, pubkey_bytes) = {
|
||||
let (conn_id, pubkey_bytes, token_channel_ids) = {
|
||||
let auth = conn.auth_state.read().await;
|
||||
match &*auth {
|
||||
AuthState::Authenticated(ctx) => {
|
||||
@@ -53,7 +53,7 @@ pub async fn handle_req(
|
||||
return;
|
||||
}
|
||||
|
||||
(conn.conn_id, pk_bytes)
|
||||
(conn.conn_id, pk_bytes, ctx.channel_ids.clone())
|
||||
}
|
||||
_ => {
|
||||
conn.send(RelayMessage::notice(
|
||||
@@ -68,7 +68,7 @@ pub async fn handle_req(
|
||||
}
|
||||
};
|
||||
|
||||
let accessible_channels = match state.db.get_accessible_channel_ids(&pubkey_bytes).await {
|
||||
let mut accessible_channels = match state.db.get_accessible_channel_ids(&pubkey_bytes).await {
|
||||
Ok(ids) => ids,
|
||||
Err(e) => {
|
||||
warn!(conn_id = %conn_id, "Failed to get accessible channels: {e}");
|
||||
@@ -76,6 +76,9 @@ pub async fn handle_req(
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(allowed) = token_channel_ids.as_deref() {
|
||||
accessible_channels.retain(|channel_id| allowed.contains(channel_id));
|
||||
}
|
||||
|
||||
let channel_id = extract_channel_id_from_filters(&filters);
|
||||
|
||||
@@ -93,7 +96,15 @@ pub async fn handle_req(
|
||||
));
|
||||
return;
|
||||
}
|
||||
handle_search_req(&sub_id, &filters, &accessible_channels, &conn, &state).await;
|
||||
handle_search_req(
|
||||
&sub_id,
|
||||
&filters,
|
||||
&accessible_channels,
|
||||
token_channel_ids.is_none(),
|
||||
&conn,
|
||||
&state,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -235,27 +246,48 @@ pub async fn handle_req(
|
||||
/// Maximum Typesense pages to fetch per filter (prevents unbounded loops).
|
||||
const MAX_SEARCH_PAGES: u32 = 10;
|
||||
|
||||
fn build_search_channel_scope_filter(
|
||||
accessible_channels: &[uuid::Uuid],
|
||||
include_global: bool,
|
||||
) -> Option<String> {
|
||||
if accessible_channels.is_empty() {
|
||||
return if include_global {
|
||||
Some("channel_id:=__global__".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
|
||||
let ids: Vec<String> = accessible_channels
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect();
|
||||
Some(if include_global {
|
||||
format!(
|
||||
"(channel_id:=[{}] || channel_id:=__global__)",
|
||||
ids.join(",")
|
||||
)
|
||||
} else {
|
||||
format!("channel_id:=[{}]", ids.join(","))
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_search_req(
|
||||
sub_id: &str,
|
||||
filters: &[Filter],
|
||||
accessible_channels: &[uuid::Uuid],
|
||||
include_global: bool,
|
||||
conn: &ConnectionState,
|
||||
state: &AppState,
|
||||
) {
|
||||
let all_channels_filter = {
|
||||
if accessible_channels.is_empty() {
|
||||
"channel_id:=__global__".to_string()
|
||||
} else {
|
||||
let ids: Vec<String> = accessible_channels
|
||||
.iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect();
|
||||
format!(
|
||||
"(channel_id:=[{}] || channel_id:=__global__)",
|
||||
ids.join(",")
|
||||
)
|
||||
}
|
||||
};
|
||||
let all_channels_filter =
|
||||
match build_search_channel_scope_filter(accessible_channels, include_global) {
|
||||
Some(filter) => filter,
|
||||
None => {
|
||||
conn.send(RelayMessage::eose(sub_id));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut seen_ids: HashSet<nostr::EventId> = HashSet::new();
|
||||
|
||||
@@ -667,4 +699,22 @@ mod tests {
|
||||
let q5 = filter_to_query_params(&multi_d_filter, None);
|
||||
assert_eq!(q5.d_tag, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_search_scope_excludes_global_results() {
|
||||
let channel_id = uuid::Uuid::new_v4();
|
||||
|
||||
let scope = build_search_channel_scope_filter(&[channel_id], false)
|
||||
.expect("restricted tokens with channel access should still search that channel");
|
||||
|
||||
assert_eq!(scope, format!("channel_id:=[{channel_id}]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_search_scope_without_accessible_channels_matches_nothing() {
|
||||
assert!(
|
||||
build_search_channel_scope_filter(&[], false).is_none(),
|
||||
"restricted tokens must not fall back to global search results"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,13 +84,29 @@ impl ActionSink for RelayActionSink {
|
||||
));
|
||||
}
|
||||
|
||||
let author_pubkey = nostr::PublicKey::from_hex(&author_pubkey).map_err(|e| {
|
||||
ActionSinkError::InvalidInput(format!("invalid author pubkey: {e}"))
|
||||
})?;
|
||||
let author_pubkey_bytes = author_pubkey.serialize().to_vec();
|
||||
let author_pubkey_hex = author_pubkey.to_hex();
|
||||
let is_member = state
|
||||
.db
|
||||
.is_member(channel_uuid, &author_pubkey_bytes)
|
||||
.await
|
||||
.map_err(|e| ActionSinkError::Database(e.to_string()))?;
|
||||
if !is_member && channel.visibility != "open" {
|
||||
return Err(ActionSinkError::InvalidInput(
|
||||
"workflow owner does not have access to destination channel".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// 3. Build kind:9 Nostr event
|
||||
// - Signed by relay keypair (event.pubkey = relay pubkey)
|
||||
// - `p` tag attributes the message to the workflow owner
|
||||
// - `h` tag scopes to the channel (NIP-29, canonical UUID)
|
||||
// - `sprout:workflow` tag prevents recursive workflow triggering
|
||||
let tags = vec![
|
||||
Tag::parse(&["p", &author_pubkey])
|
||||
Tag::parse(&["p", &author_pubkey_hex])
|
||||
.map_err(|e| ActionSinkError::EventBuild(format!("p tag: {e}")))?,
|
||||
Tag::parse(&["h", &channel_id_canonical])
|
||||
.map_err(|e| ActionSinkError::EventBuild(format!("h tag: {e}")))?,
|
||||
@@ -142,8 +158,9 @@ impl ActionSink for RelayActionSink {
|
||||
// 5. Post-persist side effects (fan-out, search, audit)
|
||||
// Only if actually inserted (idempotency guard).
|
||||
if was_inserted {
|
||||
let _ = dispatch_persistent_event(&state, &stored_event, kind_u32, &author_pubkey)
|
||||
.await;
|
||||
let _ =
|
||||
dispatch_persistent_event(&state, &stored_event, kind_u32, &author_pubkey_hex)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(event_id_hex)
|
||||
|
||||
@@ -495,6 +495,50 @@ pub enum StepResult {
|
||||
|
||||
// ── Action dispatch ───────────────────────────────────────────────────────────
|
||||
|
||||
fn resolve_send_message_channel(
|
||||
explicit_channel: Option<&str>,
|
||||
trigger_channel: &str,
|
||||
workflow_channel_id: Option<Uuid>,
|
||||
) -> Result<String, WorkflowError> {
|
||||
let explicit_channel = explicit_channel
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
if let Some(workflow_channel_id) = workflow_channel_id {
|
||||
if let Some(explicit_channel) = explicit_channel {
|
||||
let override_channel_id = explicit_channel.parse::<Uuid>().map_err(|e| {
|
||||
WorkflowError::InvalidDefinition(format!(
|
||||
"SendMessage: invalid channel override UUID: {e}"
|
||||
))
|
||||
})?;
|
||||
if override_channel_id != workflow_channel_id {
|
||||
return Err(WorkflowError::InvalidDefinition(format!(
|
||||
"SendMessage: channel override must match the workflow channel ({workflow_channel_id})"
|
||||
)));
|
||||
}
|
||||
}
|
||||
return Ok(workflow_channel_id.to_string());
|
||||
}
|
||||
|
||||
if let Some(explicit_channel) = explicit_channel {
|
||||
let override_channel_id = explicit_channel.parse::<Uuid>().map_err(|e| {
|
||||
WorkflowError::InvalidDefinition(format!(
|
||||
"SendMessage: invalid channel override UUID: {e}"
|
||||
))
|
||||
})?;
|
||||
return Ok(override_channel_id.to_string());
|
||||
}
|
||||
|
||||
if trigger_channel.trim().is_empty() {
|
||||
return Err(WorkflowError::InvalidDefinition(
|
||||
"SendMessage: no channel_id available (trigger has no channel context and no channel override was specified)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(trigger_channel.trim().to_string())
|
||||
}
|
||||
|
||||
/// Dispatch a resolved action and return its output.
|
||||
///
|
||||
/// For MVP, most actions log their intent and return a success output.
|
||||
@@ -513,29 +557,7 @@ pub async fn dispatch_action(
|
||||
|
||||
match action {
|
||||
SendMessage { text, channel } => {
|
||||
// Use explicit channel override if provided; otherwise fall back to
|
||||
// the channel that triggered this workflow run.
|
||||
let channel_id = channel
|
||||
.as_deref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&trigger_ctx.channel_id);
|
||||
|
||||
info!(
|
||||
run_id = %run_id,
|
||||
step = step_id,
|
||||
channel = %channel_id,
|
||||
"SendMessage → {channel_id}: {text}"
|
||||
);
|
||||
|
||||
if channel_id.is_empty() {
|
||||
return Err(WorkflowError::InvalidDefinition(
|
||||
"SendMessage: no channel_id available (trigger has no channel context and \
|
||||
no channel override was specified)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Look up workflow owner for message attribution.
|
||||
// Look up workflow metadata for destination validation and attribution.
|
||||
let wf_run = engine.db.get_workflow_run(run_id).await.map_err(|e| {
|
||||
WorkflowError::WebhookError(format!(
|
||||
"SendMessage: failed to load workflow run {run_id}: {e}"
|
||||
@@ -551,11 +573,23 @@ pub async fn dispatch_action(
|
||||
wf_run.workflow_id
|
||||
))
|
||||
})?;
|
||||
let channel_id = resolve_send_message_channel(
|
||||
channel.as_deref(),
|
||||
&trigger_ctx.channel_id,
|
||||
workflow.channel_id,
|
||||
)?;
|
||||
let owner_pubkey_hex = hex::encode(&workflow.owner_pubkey);
|
||||
|
||||
info!(
|
||||
run_id = %run_id,
|
||||
step = step_id,
|
||||
channel = %channel_id,
|
||||
"SendMessage → {channel_id}: {text}"
|
||||
);
|
||||
|
||||
let event_id = engine
|
||||
.action_sink()?
|
||||
.send_message(channel_id, text, &owner_pubkey_hex)
|
||||
.send_message(&channel_id, text, &owner_pubkey_hex)
|
||||
.await
|
||||
.map_err(WorkflowError::from)?;
|
||||
|
||||
@@ -1787,4 +1821,38 @@ mod tests {
|
||||
assert_eq!(ctx.message_id, "");
|
||||
assert!(ctx.webhook_fields.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_uses_bound_workflow_channel_by_default() {
|
||||
let workflow_channel_id = Uuid::new_v4();
|
||||
let resolved = resolve_send_message_channel(None, "", Some(workflow_channel_id))
|
||||
.expect("bound channel should be used");
|
||||
assert_eq!(resolved, workflow_channel_id.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_rejects_cross_channel_override_for_bound_workflow() {
|
||||
let workflow_channel_id = Uuid::new_v4();
|
||||
let other_channel_id = Uuid::new_v4();
|
||||
let err = resolve_send_message_channel(
|
||||
Some(&other_channel_id.to_string()),
|
||||
"",
|
||||
Some(workflow_channel_id),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, WorkflowError::InvalidDefinition(_)));
|
||||
assert!(
|
||||
err.to_string().contains("channel override must match"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_canonicalizes_valid_explicit_override_for_global_workflow() {
|
||||
let override_channel_id = Uuid::new_v4();
|
||||
let resolved =
|
||||
resolve_send_message_channel(Some(&override_channel_id.to_string()), "", None)
|
||||
.expect("override should be accepted");
|
||||
assert_eq!(resolved, override_channel_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,10 +233,11 @@ fn path_candidates_from_env(command: &str) -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
fn find_via_login_shell(command: &str) -> Option<PathBuf> {
|
||||
let which_cmd = format!("command -v {command}");
|
||||
|
||||
for shell in ["/bin/zsh", "/bin/bash"] {
|
||||
let Ok(output) = Command::new(shell).args(["-l", "-c", &which_cmd]).output() else {
|
||||
let Ok(output) = Command::new(shell)
|
||||
.args(["-l", "-c", r#"command -v -- "$1""#, "_", command])
|
||||
.output()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -370,8 +371,8 @@ pub async fn mint_token_via_api(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
managed_agent_avatar_url, normalize_agent_args, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL,
|
||||
GOOSE_AVATAR_URL,
|
||||
find_via_login_shell, managed_agent_avatar_url, normalize_agent_args,
|
||||
CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -421,4 +422,22 @@ mod tests {
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_shell_lookup_treats_command_as_data() {
|
||||
let marker =
|
||||
std::env::temp_dir().join(format!("sprout-discovery-marker-{}", uuid::Uuid::new_v4()));
|
||||
let payload = format!("doesnotexist; touch {} #", marker.display());
|
||||
|
||||
let resolved = find_via_login_shell(&payload);
|
||||
|
||||
assert!(
|
||||
resolved.is_none(),
|
||||
"payload should not resolve to a command"
|
||||
);
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"shell lookup must not execute injected commands"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user