mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Fix auth and SSRF vulns (#261)
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user