Skip to content

Commit 547d700

Browse files
authored
Merge pull request #117 from PyRo1121/fix/security-hardening
feat(security): add auth middleware, SSRF protection, shell hardening, and encrypted secrets
2 parents c4b51df + 6e5ca9c commit 547d700

10 files changed

Lines changed: 510 additions & 23 deletions

File tree

src/api/messaging.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ pub(super) async fn toggle_platform(
386386
let adapter = crate::messaging::webhook::WebhookAdapter::new(
387387
webhook_config.port,
388388
&webhook_config.bind,
389+
webhook_config.auth_token.clone(),
389390
);
390391
if let Err(error) = manager.register_and_start(adapter).await {
391392
tracing::error!(%error, "failed to start webhook adapter on toggle");

src/api/server.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,15 @@ use super::{
66
providers, settings, skills, system, webchat,
77
};
88

9+
use axum::Json;
10+
use axum::extract::Request;
911
use axum::Router;
1012
use axum::http::{StatusCode, Uri, header};
13+
use axum::middleware::{self, Next};
1114
use axum::response::{Html, IntoResponse, Response};
1215
use axum::routing::{delete, get, post, put};
1316
use rust_embed::Embed;
17+
use serde_json::json;
1418
use tower_http::cors::{Any, CorsLayer};
1519

1620
use std::net::SocketAddr;
@@ -143,7 +147,11 @@ pub async fn start_http_server(
143147
)
144148
.route("/update/apply", post(settings::update_apply))
145149
.route("/webchat/send", post(webchat::webchat_send))
146-
.route("/webchat/history", get(webchat::webchat_history));
150+
.route("/webchat/history", get(webchat::webchat_history))
151+
.layer(middleware::from_fn_with_state(
152+
state.clone(),
153+
api_auth_middleware,
154+
));
147155

148156
let app = Router::new()
149157
.nest("/api", api_routes)
@@ -169,6 +177,30 @@ pub async fn start_http_server(
169177
Ok(handle)
170178
}
171179

180+
async fn api_auth_middleware(state: Arc<ApiState>, request: Request, next: Next) -> Response {
181+
let Some(expected_token) = state.auth_token.as_deref() else {
182+
return next.run(request).await;
183+
};
184+
185+
let path = request.uri().path();
186+
if path == "/api/health" || path == "/health" {
187+
return next.run(request).await;
188+
}
189+
190+
let is_authorized = request
191+
.headers()
192+
.get(header::AUTHORIZATION)
193+
.and_then(|value| value.to_str().ok())
194+
.and_then(|value| value.strip_prefix("Bearer "))
195+
.is_some_and(|token| token == expected_token);
196+
197+
if is_authorized {
198+
next.run(request).await
199+
} else {
200+
(StatusCode::UNAUTHORIZED, Json(json!({"error": "unauthorized"}))).into_response()
201+
}
202+
}
203+
172204
async fn static_handler(uri: Uri) -> Response {
173205
let path = uri.path().trim_start_matches('/');
174206

src/api/state.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pub struct AgentInfo {
3737
/// State shared across all API handlers.
3838
pub struct ApiState {
3939
pub started_at: Instant,
40+
pub auth_token: Option<String>,
4041
/// Aggregated event stream from all agents. SSE clients subscribe here.
4142
pub event_tx: broadcast::Sender<ApiEvent>,
4243
/// Per-agent SQLite pools for querying channel/conversation data.
@@ -182,6 +183,7 @@ impl ApiState {
182183
let (event_tx, _) = broadcast::channel(512);
183184
Self {
184185
started_at: Instant::now(),
186+
auth_token: None,
185187
event_tx,
186188
agent_pools: arc_swap::ArcSwap::from_pointee(HashMap::new()),
187189
agent_configs: arc_swap::ArcSwap::from_pointee(Vec::new()),

src/config.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ pub struct ApiConfig {
6060
pub port: u16,
6161
/// Address to bind the HTTP server on.
6262
pub bind: String,
63+
pub auth_token: Option<String>,
6364
}
6465

6566
impl Default for ApiConfig {
@@ -68,6 +69,7 @@ impl Default for ApiConfig {
6869
enabled: true,
6970
port: 19898,
7071
bind: "127.0.0.1".into(),
72+
auth_token: None,
7173
}
7274
}
7375
}
@@ -1073,6 +1075,7 @@ pub struct WebhookConfig {
10731075
pub enabled: bool,
10741076
pub port: u16,
10751077
pub bind: String,
1078+
pub auth_token: Option<String>,
10761079
}
10771080

10781081
// -- TOML deserialization types --
@@ -1113,6 +1116,8 @@ struct TomlApiConfig {
11131116
port: u16,
11141117
#[serde(default = "default_api_bind")]
11151118
bind: String,
1119+
#[serde(default)]
1120+
auth_token: Option<String>,
11161121
}
11171122

11181123
impl Default for TomlApiConfig {
@@ -1121,6 +1126,7 @@ impl Default for TomlApiConfig {
11211126
enabled: default_api_enabled(),
11221127
port: default_api_port(),
11231128
bind: default_api_bind(),
1129+
auth_token: None,
11241130
}
11251131
}
11261132
}
@@ -1510,6 +1516,7 @@ struct TomlWebhookConfig {
15101516
port: u16,
15111517
#[serde(default = "default_webhook_bind")]
15121518
bind: String,
1519+
auth_token: Option<String>,
15131520
}
15141521

15151522
#[derive(Deserialize)]
@@ -2671,6 +2678,7 @@ impl Config {
26712678
enabled: w.enabled,
26722679
port: w.port,
26732680
bind: w.bind,
2681+
auth_token: w.auth_token.as_deref().and_then(resolve_env_value),
26742682
}),
26752683
twitch: toml.messaging.twitch.and_then(|t| {
26762684
let username = t
@@ -2712,6 +2720,7 @@ impl Config {
27122720
enabled: toml.api.enabled,
27132721
port: toml.api.port,
27142722
bind: hosted_api_bind(toml.api.bind),
2723+
auth_token: toml.api.auth_token.as_deref().and_then(resolve_env_value),
27152724
};
27162725

27172726
let metrics = MetricsConfig {

src/main.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -607,11 +607,13 @@ async fn run(
607607
let (agent_remove_tx, mut agent_remove_rx) = mpsc::channel::<String>(8);
608608

609609
// Start HTTP API server if enabled
610-
let api_state = Arc::new(spacebot::api::ApiState::new_with_provider_sender(
610+
let mut api_state = spacebot::api::ApiState::new_with_provider_sender(
611611
provider_tx,
612612
agent_tx,
613613
agent_remove_tx,
614-
));
614+
);
615+
api_state.auth_token = config.api.auth_token.clone();
616+
let api_state = Arc::new(api_state);
615617

616618
// Start background update checker
617619
spacebot::update::spawn_update_checker(api_state.update_status.clone());
@@ -1432,6 +1434,7 @@ async fn initialize_agents(
14321434
let adapter = spacebot::messaging::webhook::WebhookAdapter::new(
14331435
webhook_config.port,
14341436
&webhook_config.bind,
1437+
webhook_config.auth_token.clone(),
14351438
);
14361439
new_messaging_manager.register(adapter).await;
14371440
}

src/messaging/webhook.rs

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,26 @@
55
//! the integration point for scripts, CI pipelines, and other programs
66
//! that need to interact with Spacebot programmatically.
77
8-
use crate::messaging::traits::{InboundStream, Messaging};
9-
use crate::{InboundMessage, MessageContent, OutboundResponse};
8+
use std::collections::HashMap;
9+
use std::sync::Arc;
1010

1111
use anyhow::Context as _;
1212
use axum::Router;
1313
use axum::extract::{Json, State};
14-
use axum::http::StatusCode;
14+
use axum::http::header::AUTHORIZATION;
15+
use axum::http::{HeaderMap, StatusCode};
1516
use axum::routing::{get, post};
1617
use serde::{Deserialize, Serialize};
17-
18-
use std::collections::HashMap;
19-
use std::sync::Arc;
2018
use tokio::sync::{RwLock, mpsc};
2119

20+
use crate::messaging::traits::{InboundStream, Messaging};
21+
use crate::{InboundMessage, MessageContent, OutboundResponse};
22+
2223
/// Webhook adapter state.
2324
pub struct WebhookAdapter {
2425
port: u16,
2526
bind: String,
27+
auth_token: Option<String>,
2628
inbound_tx: Arc<RwLock<Option<mpsc::Sender<InboundMessage>>>>,
2729
/// Buffered responses per conversation_id, waiting to be polled.
2830
response_buffers: Arc<RwLock<HashMap<String, Vec<WebhookResponse>>>>,
@@ -34,6 +36,7 @@ pub struct WebhookAdapter {
3436
struct AppState {
3537
inbound_tx: Arc<RwLock<Option<mpsc::Sender<InboundMessage>>>>,
3638
response_buffers: Arc<RwLock<HashMap<String, Vec<WebhookResponse>>>>,
39+
auth_token: Option<String>,
3740
}
3841

3942
/// Inbound webhook request body.
@@ -72,10 +75,11 @@ struct PollResponse {
7275
}
7376

7477
impl WebhookAdapter {
75-
pub fn new(port: u16, bind: impl Into<String>) -> Self {
78+
pub fn new(port: u16, bind: impl Into<String>, auth_token: Option<String>) -> Self {
7679
Self {
7780
port,
7881
bind: bind.into(),
82+
auth_token,
7983
inbound_tx: Arc::new(RwLock::new(None)),
8084
response_buffers: Arc::new(RwLock::new(HashMap::new())),
8185
shutdown_tx: Arc::new(RwLock::new(None)),
@@ -98,8 +102,15 @@ impl Messaging for WebhookAdapter {
98102
let state = AppState {
99103
inbound_tx: self.inbound_tx.clone(),
100104
response_buffers: self.response_buffers.clone(),
105+
auth_token: self.auth_token.clone(),
101106
};
102107

108+
if self.auth_token.is_none() {
109+
tracing::warn!(
110+
"webhook authentication is disabled because no auth token is configured"
111+
);
112+
}
113+
103114
let app = Router::new()
104115
.route("/send", post(handle_send))
105116
.route("/poll/{conversation_id}", get(handle_poll))
@@ -226,9 +237,14 @@ impl Messaging for WebhookAdapter {
226237
// -- Axum handlers --
227238

228239
async fn handle_send(
240+
headers: HeaderMap,
229241
State(state): State<AppState>,
230242
Json(request): Json<WebhookRequest>,
231243
) -> Result<StatusCode, (StatusCode, String)> {
244+
if !is_authorized(&headers, state.auth_token.as_deref()) {
245+
return Err((StatusCode::UNAUTHORIZED, "unauthorized".into()));
246+
}
247+
232248
let tx = state.inbound_tx.read().await;
233249
let Some(tx) = tx.as_ref() else {
234250
return Err((
@@ -269,9 +285,14 @@ async fn handle_send(
269285
}
270286

271287
async fn handle_poll(
288+
headers: HeaderMap,
272289
State(state): State<AppState>,
273290
axum::extract::Path(conversation_id): axum::extract::Path<String>,
274-
) -> Json<PollResponse> {
291+
) -> Result<Json<PollResponse>, (StatusCode, String)> {
292+
if !is_authorized(&headers, state.auth_token.as_deref()) {
293+
return Err((StatusCode::UNAUTHORIZED, "unauthorized".into()));
294+
}
295+
275296
let key = format!("webhook:{conversation_id}");
276297
let messages = state
277298
.response_buffers
@@ -280,9 +301,29 @@ async fn handle_poll(
280301
.remove(&key)
281302
.unwrap_or_default();
282303

283-
Json(PollResponse { messages })
304+
Ok(Json(PollResponse { messages }))
284305
}
285306

286307
async fn handle_health() -> StatusCode {
287308
StatusCode::OK
288309
}
310+
311+
fn is_authorized(headers: &HeaderMap, expected_token: Option<&str>) -> bool {
312+
let Some(expected_token) = expected_token else {
313+
return true;
314+
};
315+
316+
if headers
317+
.get("x-webhook-token")
318+
.and_then(|value| value.to_str().ok())
319+
.is_some_and(|token| token == expected_token)
320+
{
321+
return true;
322+
}
323+
324+
headers
325+
.get(AUTHORIZATION)
326+
.and_then(|value| value.to_str().ok())
327+
.and_then(|value| value.strip_prefix("Bearer "))
328+
.is_some_and(|token| token == expected_token)
329+
}

0 commit comments

Comments
 (0)