mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 16:49:34 +00:00
fix: 5 critical/high-priority bugs (auth bypass, relay failures, unbounded recursion, context growth) (#1083)
* fix: address 5 critical and high-priority bugs from issue tracker - #1033: reject webhook requests when secret is cleared at runtime via update_secret(None), preventing auth bypass through SIGHUP hot-swap - #908: reset consecutive_failures counter on successful SSE stream reconnection in relay channel, so circuit breaker counts truly consecutive failures - #975: add depth limit (16) to validate_tool_schema() to prevent stack overflow on deeply nested schemas - #974: add depth limit (8) to resolve_nested() to prevent stack overflow on deeply nested capabilities wrappers - #826: truncate oversized tool outputs (>8KB) in routine lightweight loop to prevent unbounded context growth across iterations Each fix includes a regression test. Closes #1033, #908, #975, #974, #826 Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: 5 more high-priority bugs (routine cache, job signals, input limits) - #1077: recompute next_fire_at when re-enabling cron routines via web toggle, mirroring CLI behavior so cron ticker picks them up - #1076: refresh event trigger cache after web toggle/delete operations so event/system_event routines reflect changes immediately - #892: remove Stuck from check_signals() stop-states in JobDelegate since Stuck is recoverable (Stuck -> InProgress via self-repair) - #976: truncate oversized description strings in CapabilitiesFile to 4KB to prevent memory abuse from malicious capabilities files - #977: drop oversized parameters schema JSON (>64KB) in CapabilitiesFile to prevent unbounded memory growth Each fix includes regression tests where applicable. Closes #1077, #1076, #892, #976, #977 Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: prevent ReDoS in event trigger regex patterns - #825: use RegexBuilder with 64KB size limit when compiling user-supplied event trigger patterns, both at creation time (routine tool) and at cache refresh (routine engine) Note: Rust's regex crate already guarantees O(n) matching, so the size limit prevents excessive memory use during compilation rather than catastrophic backtracking at match time. Closes #825 Co-Authored-By: Claude Opus 4.6 <[email protected]> * Harden HTTP SSRF IP filtering * Apply rustfmt after staging merge --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
1e00b1fed5
commit
e805ec61aa
+42
-8
@@ -269,21 +269,25 @@ async fn webhook_handler(
|
||||
let mut fallback_req = None;
|
||||
{
|
||||
let webhook_secret = state.webhook_secret.read().await;
|
||||
let Some(expected_secret) = webhook_secret.as_ref() else {
|
||||
if webhook_secret.is_none() {
|
||||
// No secret configured — reject all requests. This guards against
|
||||
// the secret being cleared at runtime via update_secret(None).
|
||||
// The start() method also prevents startup without a secret, but
|
||||
// this is defense-in-depth for the SIGHUP hot-swap path.
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(WebhookResponse {
|
||||
message_id: Uuid::nil(),
|
||||
status: "error".to_string(),
|
||||
response: Some(
|
||||
"Webhook authentication required: HTTP webhook secret is not configured."
|
||||
.to_string(),
|
||||
),
|
||||
response: Some("Webhook authentication not configured".to_string()),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let expected_secret = expected_secret.expose_secret();
|
||||
}
|
||||
let expected_secret = webhook_secret
|
||||
.as_ref()
|
||||
.expect("checked is_none above")
|
||||
.expose_secret();
|
||||
|
||||
match headers.get("x-ironclaw-signature") {
|
||||
Some(raw_signature) => match raw_signature.to_str() {
|
||||
@@ -1206,4 +1210,34 @@ mod tests {
|
||||
let body = b"test body content";
|
||||
assert!(!verify_hmac_signature(secret, body, "sha256=not-hex!"));
|
||||
}
|
||||
|
||||
/// Regression test for issue #1033: when the webhook secret is cleared at
|
||||
/// runtime via update_secret(None), subsequent requests must be rejected
|
||||
/// instead of being processed without authentication.
|
||||
#[tokio::test]
|
||||
async fn webhook_rejects_when_secret_cleared_at_runtime() {
|
||||
let channel = test_channel(Some("initial-secret"));
|
||||
let _stream = channel.start().await.unwrap();
|
||||
|
||||
// Clear the secret at runtime (simulates a bad SIGHUP config reload)
|
||||
channel.update_secret(None).await;
|
||||
|
||||
let app = channel.routes();
|
||||
let body = serde_json::json!({
|
||||
"content": "hello"
|
||||
});
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/webhook")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"requests must be rejected when webhook secret is cleared at runtime"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +294,8 @@ impl Channel for RelayChannel {
|
||||
match client.connect_stream(&token, stream_timeout_secs).await {
|
||||
Ok((new_stream, new_parser)) => {
|
||||
tracing::info!("Relay SSE stream reconnected");
|
||||
consecutive_failures = 0;
|
||||
backoff_ms = backoff_initial_ms;
|
||||
current_stream = new_stream;
|
||||
// Abort old parser before replacing
|
||||
if let Some(old) = parser_handle.write().await.take() {
|
||||
@@ -312,6 +314,8 @@ impl Channel for RelayChannel {
|
||||
tracing::info!(
|
||||
"Relay SSE stream reconnected with new token"
|
||||
);
|
||||
consecutive_failures = 0;
|
||||
backoff_ms = backoff_initial_ms;
|
||||
current_stream = new_stream;
|
||||
if let Some(old) = parser_handle.write().await.take() {
|
||||
old.abort();
|
||||
|
||||
@@ -190,12 +190,21 @@ pub async fn routines_toggle_handler(
|
||||
None => !routine.enabled,
|
||||
};
|
||||
|
||||
// When re-enabling a cron routine, recompute next_fire_at so the cron
|
||||
// ticker can pick it up. Mirrors the CLI behavior (issue #1077).
|
||||
if routine.enabled
|
||||
&& !was_enabled
|
||||
&& let Trigger::Cron { schedule, timezone } = &routine.trigger
|
||||
&& let Trigger::Cron {
|
||||
ref schedule,
|
||||
ref timezone,
|
||||
} = routine.trigger
|
||||
{
|
||||
routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref())
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
routine.next_fire_at = next_cron_fire(schedule, timezone.as_deref()).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to compute next fire: {e}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
store
|
||||
@@ -203,6 +212,12 @@ pub async fn routines_toggle_handler(
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Refresh the in-memory event trigger cache so event/system_event
|
||||
// routines reflect the new enabled state immediately (issue #1076).
|
||||
if let Some(engine) = state.routine_engine.read().await.as_ref() {
|
||||
engine.refresh_event_cache().await;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": if routine.enabled { "enabled" } else { "disabled" },
|
||||
"routine_id": routine_id,
|
||||
@@ -227,6 +242,12 @@ pub async fn routines_delete_handler(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
if deleted {
|
||||
// Refresh the in-memory event trigger cache so deleted event/system_event
|
||||
// routines stop firing immediately (issue #1076).
|
||||
if let Some(engine) = state.routine_engine.read().await.as_ref() {
|
||||
engine.refresh_event_cache().await;
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"status": "deleted",
|
||||
"routine_id": routine_id,
|
||||
|
||||
Reference in New Issue
Block a user