[codex] Fix authz, scope propagation, and shell-injection bugs (#320)

This commit is contained in:
Jordan Mecom
2026-04-16 12:03:35 -04:00
committed by GitHub
parent 7cc9104ef2
commit 6c0e363bdd
15 changed files with 674 additions and 115 deletions
+6
View File
@@ -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));
+12 -9
View File
@@ -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())
+9 -6
View File
@@ -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).
+9 -6
View File
@@ -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();
+89
View File
@@ -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);
}
}
+19 -8
View File
@@ -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 {
+67 -18
View File
@@ -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());
}
}
+157 -16
View File
@@ -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()));
}
}
+91
View File
@@ -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());
}
}
+3 -1
View File
@@ -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,
};
+8 -1
View File
@@ -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!(
+68 -18
View File
@@ -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"
);
}
}
+20 -3
View File
@@ -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)
+92 -24
View File
@@ -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"
);
}
}