Fix auth and SSRF vulns (#261)

This commit is contained in:
Jordan Mecom
2026-04-10 18:37:10 +00:00
committed by GitHub
parent d0970d64e6
commit 489d69ffde
19 changed files with 305 additions and 89 deletions
+6
View File
@@ -16,6 +16,7 @@
/// - IPv4 CGNAT 100.64.0.0/10 (RFC 6598) — cloud metadata risk
/// - IPv4 benchmarking 198.18.0.0/15 (RFC 2544)
/// - IPv6 loopback ::1
/// - IPv6 unspecified ::
/// - IPv6 ULA fc00::/7
/// - IPv6 link-local fe80::/10
/// - IPv6 multicast ff00::/8
@@ -42,6 +43,7 @@ pub fn is_private_ip(ip: &std::net::IpAddr) -> bool {
return is_private_ip(&std::net::IpAddr::V4(v4));
}
v6.is_loopback()
|| v6.is_unspecified()
|| v6.segments()[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA
|| v6.segments()[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local
|| v6.segments()[0] & 0xff00 == 0xff00 // ff00::/8 multicast
@@ -93,6 +95,10 @@ mod tests {
assert!(is_private_ip(&"::1".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_unspecified_v6() {
assert!(is_private_ip(&"::".parse::<IpAddr>().unwrap()));
}
#[test]
fn test_ula_v6() {
assert!(is_private_ip(&"fd00::1".parse::<IpAddr>().unwrap()));
}
+15 -22
View File
@@ -216,7 +216,7 @@ pub async fn post_tokens(
{
if let Some(encoded) = auth_header.strip_prefix("Nostr ") {
// Reconstruct canonical URL for NIP-98 verification.
let canonical_url = reconstruct_canonical_url_for_tokens(&headers, &state);
let canonical_url = reconstruct_canonical_url_for_tokens(&state.config.relay_url);
// The Authorization: Nostr header value is base64-encoded JSON.
// Decode it before passing to verify_nip98_event which expects JSON.
@@ -801,27 +801,14 @@ pub async fn delete_all_tokens(
// ── Internal helpers ──────────────────────────────────────────────────────────
/// Reconstruct the canonical URL for NIP-98 verification on the token mint endpoint.
fn reconstruct_canonical_url_for_tokens(headers: &HeaderMap, state: &AppState) -> String {
let proto = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.unwrap_or("https");
let host = headers
.get("x-forwarded-host")
.or_else(|| headers.get("host"))
.and_then(|v| v.to_str().ok());
if let Some(host) = host {
format!("{proto}://{host}/api/tokens")
} else {
let base = state
.config
.relay_url
.replace("wss://", "https://")
.replace("ws://", "http://");
format!("{base}/api/tokens")
}
/// Derive the canonical token-mint URL from the configured relay identity.
fn reconstruct_canonical_url_for_tokens(relay_url: &str) -> String {
let base = relay_url
.trim()
.trim_end_matches('/')
.replace("wss://", "https://")
.replace("ws://", "http://");
format!("{base}/api/tokens")
}
// ── Tests ─────────────────────────────────────────────────────────────────────
@@ -890,4 +877,10 @@ mod tests {
let req: MintTokenRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.channel_ids.as_ref().unwrap().len(), 1);
}
#[test]
fn canonical_token_url_uses_configured_relay_identity() {
let url = reconstruct_canonical_url_for_tokens("wss://relay.example.test/");
assert_eq!(url, "https://relay.example.test/api/tokens");
}
}
@@ -93,7 +93,10 @@ pub(crate) async fn validate_webhook_urls(
if let Some(host) = parsed.host_str() {
// Block loopback hostnames and cloud metadata endpoints.
if matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]") {
if matches!(
host,
"localhost" | "127.0.0.1" | "::1" | "[::1]" | "::" | "[::]"
) {
return Err(format!(
"webhook URL in step '{}' targets loopback address",
step.id
@@ -358,6 +361,16 @@ mod tests {
);
}
#[tokio::test]
async fn ipv6_unspecified_is_rejected() {
let def = make_workflow(vec![webhook_step("s1", "http://[::]/evil")]);
let err = validate_webhook_urls(&def).await.unwrap_err();
assert!(
err.contains("loopback") || err.contains("private") || err.contains("internal"),
"unexpected error: {err}"
);
}
// ── Non-http(s) schemes ───────────────────────────────────────────────────
#[tokio::test]
+4
View File
@@ -345,6 +345,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"));
}
let limit = params.limit.unwrap_or(20).min(100) as i64;
@@ -424,6 +426,8 @@ pub async fn trigger_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?;
} else if workflow.owner_pubkey != pubkey_bytes {
return Err(forbidden("not authorized to access this workflow"));
}
let trigger_ctx = sprout_workflow::executor::TriggerContext::default();
+7 -2
View File
@@ -24,6 +24,9 @@ use crate::state::AppState;
/// Prevents transient read stalls from hard-disconnecting agents mid-inference.
pub(crate) const SLOW_CLIENT_GRACE_LIMIT: u8 = 3;
/// Shared mutable subscription map for a single WebSocket connection.
pub(crate) type ConnectionSubscriptions = Arc<Mutex<HashMap<String, Vec<Filter>>>>;
/// NIP-42 authentication state for a single connection.
#[derive(Debug, Clone)]
pub enum AuthState {
@@ -50,7 +53,7 @@ pub struct ConnectionState {
/// Current NIP-42 authentication state.
pub auth_state: RwLock<AuthState>,
/// Active subscriptions keyed by subscription ID.
pub subscriptions: Mutex<HashMap<String, Vec<Filter>>>,
pub subscriptions: ConnectionSubscriptions,
/// Sender for outbound data messages (EVENT, NOTICE, OK, etc.).
pub send_tx: mpsc::Sender<WsMessage>,
/// Sender for outbound control frames (Pong, Close).
@@ -120,6 +123,7 @@ pub async fn handle_connection(socket: WebSocket, state: Arc<AppState>, addr: So
let (ctrl_tx, ctrl_rx) = mpsc::channel::<WsMessage>(8);
let backpressure_count = Arc::new(AtomicU8::new(0));
let subscriptions = Arc::new(Mutex::new(HashMap::new()));
let conn = Arc::new(ConnectionState {
conn_id,
@@ -127,7 +131,7 @@ pub async fn handle_connection(socket: WebSocket, state: Arc<AppState>, addr: So
auth_state: RwLock::new(AuthState::Pending {
challenge: challenge.clone(),
}),
subscriptions: Mutex::new(HashMap::new()),
subscriptions: Arc::clone(&subscriptions),
send_tx: tx.clone(),
ctrl_tx: ctrl_tx.clone(),
cancel: cancel.clone(),
@@ -157,6 +161,7 @@ pub async fn handle_connection(socket: WebSocket, state: Arc<AppState>, addr: So
tx.clone(),
cancel.clone(),
Arc::clone(&backpressure_count),
subscriptions,
);
let (ws_send, ws_recv) = socket.split();
+6
View File
@@ -127,6 +127,9 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
// API token users have already proven authorization via their token —
// the pubkey allowlist does not apply here.
*conn.auth_state.write().await = AuthState::Authenticated(auth_ctx);
state
.conn_manager
.set_authenticated_pubkey(conn_id, pubkey.serialize().to_vec());
conn.send(RelayMessage::ok(&event_id_hex, true, ""));
}
Err(e) => {
@@ -180,6 +183,9 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
}
info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful");
*conn.auth_state.write().await = AuthState::Authenticated(auth_ctx);
state
.conn_manager
.set_authenticated_pubkey(conn_id, pubkey.serialize().to_vec());
conn.send(RelayMessage::ok(&event_id_hex, true, ""));
}
Err(e) => {
@@ -13,6 +13,7 @@ use sprout_core::kind::{
use sprout_db::channel::MemberRole;
use super::event::dispatch_persistent_event;
use crate::protocol::RelayMessage;
use crate::state::AppState;
/// Check if a kind is an admin kind (9000-9022) that needs pre-storage validation.
@@ -29,6 +30,37 @@ pub fn is_side_effect_kind(kind: u32) -> bool {
matches!(kind, 0 | 5 | 9000..=9022 | 41001..=41003 | 40099)
}
async fn evict_live_channel_subscriptions(
state: &Arc<AppState>,
channel_id: Uuid,
target_pubkey: &[u8],
) {
let conn_ids = state.conn_manager.connection_ids_for_pubkey(target_pubkey);
for conn_id in conn_ids {
let removed = state
.sub_registry
.remove_channel_subscriptions(conn_id, channel_id);
if removed.is_empty() {
continue;
}
if let Some(subscriptions) = state.conn_manager.subscriptions_for(conn_id) {
let mut conn_subscriptions = subscriptions.lock().await;
for sub_id in &removed {
conn_subscriptions.remove(sub_id);
}
}
for sub_id in removed {
let _ = state.conn_manager.send_to(
conn_id,
RelayMessage::closed(&sub_id, "restricted: channel access revoked"),
);
}
}
}
/// Dispatch side effects for a stored event.
pub async fn handle_side_effects(
kind: u32,
@@ -724,6 +756,7 @@ async fn handle_remove_user(event: &Event, state: &Arc<AppState>) -> anyhow::Res
.db
.remove_member(channel_id, &target_pubkey, &actor_bytes)
.await?;
evict_live_channel_subscriptions(state, channel_id, &target_pubkey).await;
let actor_hex = nostr::util::hex::encode(&actor_bytes);
let target_hex = nostr::util::hex::encode(&target_pubkey);
@@ -1158,6 +1191,7 @@ async fn handle_leave_request(event: &Event, state: &Arc<AppState>) -> anyhow::R
.db
.remove_member(channel_id, &actor_bytes, &actor_bytes)
.await?;
evict_live_channel_subscriptions(state, channel_id, &actor_bytes).await;
let actor_hex = nostr::util::hex::encode(&actor_bytes);
emit_system_message(
+75 -5
View File
@@ -22,7 +22,7 @@ use sprout_workflow::WorkflowEngine;
use crate::api::tokens::MintRateLimiter;
use crate::config::Config;
use crate::connection::SLOW_CLIENT_GRACE_LIMIT;
use crate::connection::{ConnectionSubscriptions, SLOW_CLIENT_GRACE_LIMIT};
use crate::subscription::SubscriptionRegistry;
/// Per-connection entry in the connection manager.
@@ -32,6 +32,8 @@ struct ConnEntry {
/// Shared with `ConnectionState` — both direct sends and fan-out
/// broadcasts track the same consecutive-full counter.
backpressure_count: Arc<AtomicU8>,
subscriptions: ConnectionSubscriptions,
authenticated_pubkey: Arc<std::sync::RwLock<Option<Vec<u8>>>>,
}
/// Tracks active WebSocket connections and provides message routing by connection ID.
@@ -48,13 +50,14 @@ impl ConnectionManager {
}
/// Registers a connection with its outbound sender, cancellation token,
/// and shared backpressure counter (same `Arc` as `ConnectionState`).
/// shared backpressure counter, and mutable subscription map.
pub fn register(
&self,
conn_id: Uuid,
tx: mpsc::Sender<WsMessage>,
cancel: CancellationToken,
backpressure_count: Arc<AtomicU8>,
subscriptions: ConnectionSubscriptions,
) {
self.connections.insert(
conn_id,
@@ -62,6 +65,8 @@ impl ConnectionManager {
tx,
cancel,
backpressure_count,
subscriptions,
authenticated_pubkey: Arc::new(std::sync::RwLock::new(None)),
},
);
}
@@ -71,6 +76,42 @@ impl ConnectionManager {
self.connections.remove(&conn_id);
}
/// Record the authenticated pubkey for a connection after NIP-42 succeeds.
pub fn set_authenticated_pubkey(&self, conn_id: Uuid, pubkey_bytes: Vec<u8>) {
if let Some(entry) = self.connections.get(&conn_id) {
if let Ok(mut slot) = entry.authenticated_pubkey.write() {
*slot = Some(pubkey_bytes);
}
}
}
/// Return all live connection IDs authenticated as `pubkey_bytes`.
pub fn connection_ids_for_pubkey(&self, pubkey_bytes: &[u8]) -> Vec<Uuid> {
self.connections
.iter()
.filter_map(|entry| {
let matches = entry
.authenticated_pubkey
.read()
.ok()
.and_then(|value| {
value
.as_ref()
.map(|stored| stored.as_slice() == pubkey_bytes)
})
.unwrap_or(false);
matches.then_some(*entry.key())
})
.collect()
}
/// Return the subscription map for a connection, if it is still live.
pub fn subscriptions_for(&self, conn_id: Uuid) -> Option<ConnectionSubscriptions> {
self.connections
.get(&conn_id)
.map(|entry| Arc::clone(&entry.subscriptions))
}
/// Sends a text message to the given connection.
///
/// Returns `false` if the connection is gone or the buffer is full.
@@ -285,7 +326,13 @@ mod tests {
let (tx, rx) = mpsc::channel(buffer_size);
let cancel = CancellationToken::new();
let bp = Arc::new(AtomicU8::new(0));
mgr.register(conn_id, tx, cancel.clone(), Arc::clone(&bp));
mgr.register(
conn_id,
tx,
cancel.clone(),
Arc::clone(&bp),
Arc::new(Mutex::new(HashMap::new())),
);
(mgr, conn_id, rx, cancel, bp)
}
@@ -351,7 +398,7 @@ mod tests {
conn_id,
remote_addr: "127.0.0.1:1234".parse().unwrap(),
auth_state: RwLock::new(AuthState::Failed),
subscriptions: Mutex::new(HashMap::new()),
subscriptions: Arc::new(Mutex::new(HashMap::new())),
send_tx: tx.clone(),
ctrl_tx,
cancel: cancel.clone(),
@@ -359,7 +406,13 @@ mod tests {
};
let mgr = ConnectionManager::new();
mgr.register(conn_id, tx, cancel.clone(), Arc::clone(&bp));
mgr.register(
conn_id,
tx,
cancel.clone(),
Arc::clone(&bp),
Arc::clone(&conn.subscriptions),
);
// Fill the buffer via direct send.
assert!(conn.send("fill".into()));
@@ -384,4 +437,21 @@ mod tests {
"shared counter reached limit via mixed path"
);
}
#[tokio::test]
async fn tracks_connections_by_authenticated_pubkey() {
let mgr = ConnectionManager::new();
let conn_id = Uuid::new_v4();
let (tx, _rx) = mpsc::channel(1);
let cancel = CancellationToken::new();
let bp = Arc::new(AtomicU8::new(0));
let subscriptions = Arc::new(Mutex::new(HashMap::new()));
mgr.register(conn_id, tx, cancel, bp, Arc::clone(&subscriptions));
let pubkey = vec![7u8; 32];
mgr.set_authenticated_pubkey(conn_id, pubkey.clone());
assert_eq!(mgr.connection_ids_for_pubkey(&pubkey), vec![conn_id]);
assert!(mgr.subscriptions_for(conn_id).is_some());
}
}
+54
View File
@@ -110,6 +110,28 @@ impl SubscriptionRegistry {
}
}
/// Remove all subscriptions on `conn_id` scoped to `channel_id`.
pub fn remove_channel_subscriptions(&self, conn_id: ConnId, channel_id: Uuid) -> Vec<SubId> {
let sub_ids: Vec<SubId> = self
.subs
.get(&conn_id)
.map(|conn_subs| {
conn_subs
.iter()
.filter_map(|(sub_id, (_, sub_channel_id))| {
(*sub_channel_id == Some(channel_id)).then_some(sub_id.clone())
})
.collect()
})
.unwrap_or_default();
for sub_id in &sub_ids {
self.remove_subscription(conn_id, sub_id);
}
sub_ids
}
/// Return all (conn_id, sub_id) pairs whose filters match the given event.
pub fn fan_out(&self, event: &StoredEvent) -> Vec<(ConnId, SubId)> {
let mut results = Vec::new();
@@ -713,4 +735,36 @@ mod tests {
};
assert!(registry.channel_kind_index.get(&key).is_none());
}
#[test]
fn test_remove_channel_subscriptions_only_evicts_target_channel() {
let registry = SubscriptionRegistry::new();
let conn_id = Uuid::new_v4();
let channel_a = Uuid::new_v4();
let channel_b = Uuid::new_v4();
registry.register(
conn_id,
"sub-a".to_string(),
vec![Filter::new().kind(Kind::TextNote)],
Some(channel_a),
);
registry.register(
conn_id,
"sub-b".to_string(),
vec![Filter::new().kind(Kind::TextNote)],
Some(channel_b),
);
let removed = registry.remove_channel_subscriptions(conn_id, channel_a);
assert_eq!(removed, vec!["sub-a".to_string()]);
let event_a = make_stored_event(Kind::TextNote, Some(channel_a));
assert!(registry.fan_out(&event_a).is_empty());
let event_b = make_stored_event(Kind::TextNote, Some(channel_b));
let matches_b = registry.fan_out(&event_b);
assert_eq!(matches_b.len(), 1);
assert_eq!(matches_b[0].1, "sub-b");
}
}
@@ -178,15 +178,11 @@ async fn test_mint_token_via_nip98() {
});
let body_bytes = serde_json::to_vec(&body_json).unwrap();
// The relay's reconstruct_canonical_url_for_tokens uses X-Forwarded-Proto
// to determine the scheme. We pass "http" so the canonical URL matches
// what we sign in the NIP-98 event.
let auth_header = build_nip98_header(&keys, &endpoint_url, "POST", &body_bytes);
let resp = client
.post(&endpoint_url)
.header("Authorization", auth_header)
.header("X-Forwarded-Proto", "http")
.header("Content-Type", "application/json")
.body(body_bytes)
.send()
@@ -389,7 +385,6 @@ async fn test_nip98_rejected_for_list_tokens() {
let resp = client
.get(&endpoint_url)
.header("Authorization", auth_header)
.header("X-Forwarded-Proto", "http")
.send()
.await
.expect("GET /api/tokens failed");
@@ -438,7 +433,6 @@ async fn test_nip98_requires_payload_tag() {
let resp = client
.post(&endpoint_url)
.header("Authorization", auth_header)
.header("X-Forwarded-Proto", "http")
.header("Content-Type", "application/json")
.body(body_bytes)
.send()
@@ -477,7 +471,6 @@ async fn test_nip98_wrong_payload_rejected() {
let resp = client
.post(&endpoint_url)
.header("Authorization", auth_header)
.header("X-Forwarded-Proto", "http")
.header("Content-Type", "application/json")
.body(body_bytes)
.send()
+2 -2
View File
@@ -4,7 +4,7 @@ use tauri::State;
use crate::{
app_state::AppState,
events,
relay::{build_authed_request, send_json_request, submit_event},
relay::{api_path, build_authed_request, send_json_request, submit_event},
};
#[tauri::command]
@@ -12,7 +12,7 @@ pub async fn get_canvas(
channel_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/channels/{channel_id}/canvas");
let path = api_path(&["channels", &channel_id, "canvas"]);
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}
+6 -5
View File
@@ -5,7 +5,7 @@ use crate::{
app_state::AppState,
events,
models::{ChannelDetailInfo, ChannelInfo, ChannelMembersResponse},
relay::{build_authed_request, send_json_request, submit_event},
relay::{api_path, build_authed_request, send_json_request, submit_event},
};
// ── Reads (unchanged) ────────────────────────────────────────────────────────
@@ -21,7 +21,7 @@ pub async fn get_channel_details(
channel_id: String,
state: State<'_, AppState>,
) -> Result<ChannelDetailInfo, String> {
let path = format!("/api/channels/{channel_id}");
let path = api_path(&["channels", &channel_id]);
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}
@@ -31,7 +31,7 @@ pub async fn get_channel_members(
channel_id: String,
state: State<'_, AppState>,
) -> Result<ChannelMembersResponse, String> {
let path = format!("/api/channels/{channel_id}/members");
let path = api_path(&["channels", &channel_id, "members"]);
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}
@@ -73,7 +73,8 @@ pub async fn create_channel(
submit_event(builder, &state).await?;
// Follow-up GET to return the full ChannelInfo the frontend expects.
let path = format!("/api/channels/{channel_uuid}");
let channel_uuid_string = channel_uuid.to_string();
let path = api_path(&["channels", &channel_uuid_string]);
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}
@@ -90,7 +91,7 @@ pub async fn update_channel(
submit_event(builder, &state).await?;
// Follow-up GET to return the full ChannelDetailInfo.
let path = format!("/api/channels/{channel_id}");
let path = api_path(&["channels", &channel_id]);
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}
+3 -3
View File
@@ -4,7 +4,7 @@ use tauri::State;
use crate::{
app_state::AppState,
models::{ChannelInfo, OpenDmBody, OpenDmResponse},
relay::{build_authed_request, send_empty_request, send_json_request},
relay::{api_path, build_authed_request, send_empty_request, send_json_request},
};
#[tauri::command]
@@ -16,14 +16,14 @@ pub async fn open_dm(
.json(&OpenDmBody { pubkeys: &pubkeys });
let response: OpenDmResponse = send_json_request(request).await?;
let path = format!("/api/channels/{}", response.channel_id);
let path = api_path(&["channels", &response.channel_id]);
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}
#[tauri::command]
pub async fn hide_dm(channel_id: String, state: State<'_, AppState>) -> Result<(), String> {
let path = format!("/api/dms/{channel_id}/hide");
let path = api_path(&["dms", &channel_id, "hide"]);
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?;
send_empty_request(request).await
}
+7 -13
View File
@@ -9,8 +9,7 @@ use crate::{
FeedResponse, ForumPostsResponse, ForumThreadResponse, GetFeedQuery, GetForumPostsQuery,
GetForumThreadQuery, SearchQueryParams, SearchResponse, SendChannelMessageResponse,
},
relay::{build_authed_request, relay_error_message, send_json_request, submit_event},
util::percent_encode,
relay::{api_path, build_authed_request, relay_error_message, send_json_request, submit_event},
};
// ── Reads (unchanged) ────────────────────────────────────────────────────────
@@ -51,7 +50,7 @@ pub async fn get_forum_posts(
before: Option<i64>,
state: State<'_, AppState>,
) -> Result<ForumPostsResponse, String> {
let path = format!("/api/channels/{channel_id}/messages");
let path = api_path(&["channels", &channel_id, "messages"]);
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?.query(
&GetForumPostsQuery {
limit,
@@ -71,7 +70,7 @@ pub async fn get_forum_thread(
cursor: Option<String>,
state: State<'_, AppState>,
) -> Result<ForumThreadResponse, String> {
let path = format!("/api/channels/{channel_id}/threads/{event_id}");
let path = api_path(&["channels", &channel_id, "threads", &event_id]);
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?
.query(&GetForumThreadQuery { limit, cursor });
@@ -80,12 +79,8 @@ pub async fn get_forum_thread(
#[tauri::command]
pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result<String, String> {
let request = build_authed_request(
&state.http_client,
Method::GET,
&format!("/api/events/{event_id}"),
&state,
)?;
let path = api_path(&["events", &event_id]);
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
let response = request
.send()
.await
@@ -112,7 +107,7 @@ async fn resolve_thread_ref(
let parent_eid =
EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?;
let path = format!("/api/events/{parent_event_id}");
let path = api_path(&["events", parent_event_id]);
let request = build_authed_request(&state.http_client, Method::GET, &path, state)?;
let response = request
.send()
@@ -257,8 +252,7 @@ pub async fn remove_reaction(
state: State<'_, AppState>,
) -> Result<(), String> {
// Fetch reactions to find our reaction event ID — same pattern as MCP.
let encoded_event_id = percent_encode(event_id.trim());
let path = format!("/api/messages/{encoded_event_id}/reactions");
let path = api_path(&["messages", event_id.trim(), "reactions"]);
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
let reactions: serde_json::Value = send_json_request(request).await?;
+2 -2
View File
@@ -11,7 +11,7 @@ use crate::{
GetUserNotesQuery, GetUsersBatchBody, ProfileInfo, SearchUsersResponse, SetPresenceBody,
SetPresenceResponse, UserNotesResponse, UsersBatchResponse,
},
relay::{build_authed_request, send_json_request, submit_event},
relay::{api_path, build_authed_request, send_json_request, submit_event},
};
#[tauri::command]
@@ -81,7 +81,7 @@ pub async fn get_user_profile(
state: State<'_, AppState>,
) -> Result<ProfileInfo, String> {
let path = match pubkey {
Some(pubkey) => format!("/api/users/{pubkey}/profile"),
Some(pubkey) => api_path(&["users", &pubkey, "profile"]),
None => "/api/users/me/profile".to_string(),
};
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
+2 -8
View File
@@ -5,7 +5,7 @@ use crate::{
app_state::AppState,
models::{ListTokensResponse, MintTokenBody, MintTokenResponse, RevokeAllTokensResponse},
relay::{
build_authed_request, build_nip98_auth_header, build_token_management_request,
api_path, build_authed_request, build_nip98_auth_header, build_token_management_request,
relay_api_base_url, send_empty_request, send_json_request,
},
};
@@ -39,18 +39,12 @@ pub async fn mint_token(
let body_bytes =
serde_json::to_vec(&body).map_err(|error| format!("serialize failed: {error}"))?;
let auth_header = build_nip98_auth_header(&Method::POST, &url, &body_bytes, &state)?;
let forwarded_proto = if url.starts_with("http://") {
"http"
} else {
"https"
};
state
.http_client
.request(Method::POST, url)
.header("Authorization", auth_header)
.header("Content-Type", "application/json")
.header("X-Forwarded-Proto", forwarded_proto)
.body(body_bytes)
};
let response: MintTokenResponse = send_json_request(request).await?;
@@ -68,7 +62,7 @@ pub async fn mint_token(
#[tauri::command]
pub async fn revoke_token(token_id: String, state: State<'_, AppState>) -> Result<(), String> {
let path = format!("/api/tokens/{token_id}");
let path = api_path(&["tokens", &token_id]);
let request =
build_token_management_request(&state.http_client, Method::DELETE, &path, &state)?;
send_empty_request(request).await
+13 -13
View File
@@ -4,7 +4,7 @@ use tauri::State;
use crate::{
app_state::AppState,
relay::{build_authed_request, send_empty_request, send_json_request},
relay::{api_path, build_authed_request, send_empty_request, send_json_request},
};
// ── Reads ───────────────────────────────────────────────────────────────────
@@ -14,7 +14,7 @@ pub async fn get_channel_workflows(
channel_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/channels/{channel_id}/workflows");
let path = api_path(&["channels", &channel_id, "workflows"]);
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}
@@ -24,7 +24,7 @@ pub async fn get_workflow(
workflow_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/workflows/{workflow_id}");
let path = api_path(&["workflows", &workflow_id]);
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}
@@ -35,11 +35,11 @@ pub async fn get_workflow_runs(
limit: Option<u32>,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let mut path = format!("/api/workflows/{workflow_id}/runs");
let path = api_path(&["workflows", &workflow_id, "runs"]);
let mut request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
if let Some(limit) = limit {
path.push_str(&format!("?limit={limit}"));
request = request.query(&[("limit", limit.to_string())]);
}
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}
@@ -56,7 +56,7 @@ pub async fn create_workflow(
yaml_definition: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/channels/{channel_id}/workflows");
let path = api_path(&["channels", &channel_id, "workflows"]);
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?
.json(&CreateWorkflowBody { yaml_definition });
send_json_request(request).await
@@ -73,7 +73,7 @@ pub async fn update_workflow(
yaml_definition: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/workflows/{workflow_id}");
let path = api_path(&["workflows", &workflow_id]);
let request = build_authed_request(&state.http_client, Method::PUT, &path, &state)?
.json(&UpdateWorkflowBody { yaml_definition });
send_json_request(request).await
@@ -84,7 +84,7 @@ pub async fn delete_workflow(
workflow_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let path = format!("/api/workflows/{workflow_id}");
let path = api_path(&["workflows", &workflow_id]);
let request = build_authed_request(&state.http_client, Method::DELETE, &path, &state)?;
send_empty_request(request).await
}
@@ -94,7 +94,7 @@ pub async fn trigger_workflow(
workflow_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/workflows/{workflow_id}/trigger");
let path = api_path(&["workflows", &workflow_id, "trigger"]);
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?;
send_json_request(request).await
}
@@ -107,7 +107,7 @@ pub async fn get_run_approvals(
run_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/workflows/{workflow_id}/runs/{run_id}/approvals");
let path = api_path(&["workflows", &workflow_id, "runs", &run_id, "approvals"]);
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}
@@ -124,7 +124,7 @@ pub async fn grant_approval(
note: Option<String>,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/approvals/by-hash/{token}/grant");
let path = api_path(&["approvals", "by-hash", &token, "grant"]);
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?
.json(&ApprovalBody { note });
send_json_request(request).await
@@ -136,7 +136,7 @@ pub async fn deny_approval(
note: Option<String>,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let path = format!("/api/approvals/by-hash/{token}/deny");
let path = api_path(&["approvals", "by-hash", &token, "deny"]);
let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?
.json(&ApprovalBody { note });
send_json_request(request).await
@@ -343,11 +343,6 @@ pub async fn mint_token_via_api(
// Build NIP-98 auth header signed by the AGENT's keys (not the desktop user's).
let payload_hash = hex::encode(Sha256::digest(&body_bytes));
let forwarded_proto = if url.starts_with("http://") {
"http"
} else {
"https"
};
let tags = vec![
Tag::parse(vec!["u", &url]).map_err(|e| format!("url tag failed: {e}"))?,
Tag::parse(vec!["method", "POST"]).map_err(|e| format!("method tag failed: {e}"))?,
@@ -365,7 +360,6 @@ pub async fn mint_token_via_api(
.request(Method::POST, &url)
.header("Authorization", auth_header)
.header("Content-Type", "application/json")
.header("X-Forwarded-Proto", forwarded_proto)
.body(body_bytes);
let response: crate::models::MintTokenResponse = send_json_request(request).await?;
+55
View File
@@ -6,6 +6,7 @@ use serde::Deserialize;
use sha2::{Digest, Sha256};
use crate::app_state::AppState;
use crate::util::percent_encode;
const DEFAULT_RELAY_WS_URL: &str = "ws://localhost:3000";
@@ -48,12 +49,43 @@ pub fn relay_api_base_url() -> String {
relay_http_base_url(&relay_ws_url())
}
/// Build a relay API path from untrusted path segments by percent-encoding each segment.
pub fn api_path(segments: &[&str]) -> String {
let mut path = String::from("/api");
for segment in segments {
path.push('/');
path.push_str(&percent_encode(segment));
}
path
}
fn validate_api_path(path: &str) -> Result<(), String> {
let path_only = path
.split_once('?')
.map(|(prefix, _)| prefix)
.unwrap_or(path);
if !path_only.starts_with('/') {
return Err("API paths must start with '/'".to_string());
}
if path_only
.split('/')
.any(|segment| matches!(segment, "." | ".."))
{
return Err("API path contains unsafe traversal segments".to_string());
}
Ok(())
}
pub fn build_authed_request(
client: &reqwest::Client,
method: Method,
path: &str,
state: &AppState,
) -> Result<reqwest::RequestBuilder, String> {
validate_api_path(path)?;
let url = format!("{}{}", relay_api_base_url(), path);
let request = client.request(method, url);
@@ -144,6 +176,7 @@ pub fn build_token_management_request(
path: &str,
state: &AppState,
) -> Result<reqwest::RequestBuilder, String> {
validate_api_path(path)?;
let url = format!("{}{}", relay_api_base_url(), path);
let request = client.request(method, url);
@@ -244,6 +277,28 @@ pub async fn send_empty_request(request: reqwest::RequestBuilder) -> Result<(),
Ok(())
}
#[cfg(test)]
mod tests {
use super::{api_path, validate_api_path};
#[test]
fn api_path_encodes_path_segments() {
let path = api_path(&["tokens", "../../etc/passwd"]);
assert_eq!(path, "/api/tokens/..%2F..%2Fetc%2Fpasswd");
}
#[test]
fn validate_api_path_rejects_traversal_segments() {
assert!(validate_api_path("/api/tokens/../admin").is_err());
assert!(validate_api_path("/api/tokens/./admin").is_err());
}
#[test]
fn validate_api_path_allows_encoded_segments() {
assert!(validate_api_path("/api/tokens/..%2Fadmin").is_ok());
}
}
// ── Signed-event submission ──────────────────────────────────────────────────
/// Response from `POST /api/events`.