From cea5aee60987f192823012db163bae4c2fae4998 Mon Sep 17 00:00:00 2001 From: Shiv Rossi Date: Tue, 13 Oct 2026 13:23:27 -0600 Subject: [PATCH] feat(rite): add structured JSON HTTP action bodies --- README.md | 40 ++- crates/rite-core/src/lib.rs | 400 ++++++++++++++++++++++++++++- crates/rite-server/src/dispatch.rs | 28 +- crates/rite-server/src/lib.rs | 307 +++++++++++++++++++--- rite.example.toml | 2 +- 5 files changed, 724 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index cdb02c4..6ed8dcc 100644 --- a/README.md +++ b/README.md @@ -112,20 +112,48 @@ repository's [`deploy/docker-compose.yml`](https://github.com/TechGodHQ/iris/blo `http_post` forwards the normalized event as JSON by default. A handler may instead set `body_template` to render a deterministic text body using `{{event_type}}`, `{{action}}`, `{{title}}`, `{{body}}`, or nested event -metadata such as `{{metadata.provider}}`; missing fields render empty. -Optional `headers` are forwarded verbatim. Templated bodies default to -`Content-Type: text/plain` unless that header is explicitly set. This remains -a generic HTTP action: Discord, Slack, and other webhook targets do not add -provider-specific Rite action types. +metadata such as `{{metadata.provider}}`; missing fields render empty. Optional +`headers` are forwarded verbatim. Templated bodies default to +`Content-Type: text/plain` unless that header is explicitly set. + +For a JSON API that needs a custom object, use `body_json` instead of +`body_template` (the two modes are mutually exclusive). It accepts nested TOML +tables and arrays, strings, booleans, integers, finite floats, and scalar roots. +TOML datetimes and non-finite floats are rejected; TOML has no null value, and +Rite does not invent a null sentinel. Only configured string leaves are +interpolated. The supported placeholders are the same event fields above; +malformed or unknown placeholders are rejected while loading configuration. +Each inserted value is treated as opaque string data and is never rescanned or +parsed as JSON. A missing or null referenced value fails that action without +sending it; non-string metadata is represented as JSON text within the string +leaf. Static object keys, URLs, headers, and credentials remain configuration +owned. + +Structured mode sets `Content-Type: application/json` by default. An explicit +content type may be `application/json` or `application/json; charset=utf-8` +(case-insensitive; quoted UTF-8 is also accepted). Other media types, parameters, +charsets, or duplicate case-insensitive content-type headers are rejected during +configuration loading. These rules apply only to `body_json`; legacy text and +no-template request behavior is unchanged. ```toml [[rites]] name = "deploy-alert" source = "iris" match = { body_contains = "URGENT" } -action = { type = "http_post", url = "https://hooks.example.test/alerts", headers = { "content-type" = "application/json" }, body_template = "{\"content\":\"{{title}}: {{body}}\"}" } +action = { type = "http_post", url = "https://hooks.example.test/alerts", body_template = "{{title}}: {{body}}" } + +[[rites]] +name = "structured-iris-message" +source = "iris" +match = { event_type = "text" } +action = { type = "http_post", url = "https://api.example.test/messages/synthetic-thread", body_json = { body = "{{body}}", provider = "telegram.synthetic" } } ``` +The second example keeps the provider instance fixed in configuration while +preserving arbitrary message text as a JSON string. Rite remains a generic HTTP +action runner and does not add provider-specific action types. + ## Compound matching Flat `match` tables AND every entry. For OR or negation, compose conditions diff --git a/crates/rite-core/src/lib.rs b/crates/rite-core/src/lib.rs index 36bd683..831c2d7 100644 --- a/crates/rite-core/src/lib.rs +++ b/crates/rite-core/src/lib.rs @@ -16,6 +16,10 @@ pub type Result = std::result::Result; /// boundary without rendering the originating TOML source text. pub const MATCH_VALIDATION_ERROR_PREFIX: &str = "rite matcher validation: "; +/// Stable marker for action errors that may safely cross the config-load +/// boundary without rendering the originating TOML values. +pub const ACTION_VALIDATION_ERROR_PREFIX: &str = "rite action validation: "; + /// Errors returned by Rite components. #[derive(Debug, Error)] pub enum RiteError { @@ -115,6 +119,7 @@ pub struct RiteHandler { )] pub matcher: BTreeMap, /// Action performed after a match. + #[serde(deserialize_with = "deserialize_validated_action")] pub action: RiteAction, } @@ -369,6 +374,51 @@ where }) } +fn deserialize_validated_action<'de, D>( + deserializer: D, +) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + let action = RiteAction::deserialize(deserializer)?; + validate_action(&action).map_err(|error| { + serde::de::Error::custom(format!("{ACTION_VALIDATION_ERROR_PREFIX}{error}")) + })?; + Ok(action) +} + +fn deserialize_json_body<'de, D>(deserializer: D) -> std::result::Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = toml::Value::deserialize(deserializer)?; + toml_value_to_json(&value).map(Some).map_err(|error| { + serde::de::Error::custom(format!("{ACTION_VALIDATION_ERROR_PREFIX}{error}")) + }) +} + +fn toml_value_to_json(value: &toml::Value) -> std::result::Result { + match value { + toml::Value::String(value) => Ok(Value::String(value.clone())), + toml::Value::Integer(value) => Ok(Value::from(*value)), + toml::Value::Float(value) => serde_json::Number::from_f64(*value) + .map(Value::Number) + .ok_or("structured JSON body cannot contain a non-finite float"), + toml::Value::Boolean(value) => Ok(Value::Bool(*value)), + toml::Value::Datetime(_) => Err("structured JSON body cannot contain a TOML datetime"), + toml::Value::Array(values) => values + .iter() + .map(toml_value_to_json) + .collect::, _>>() + .map(Value::Array), + toml::Value::Table(values) => values + .iter() + .map(|(key, value)| Ok((key.clone(), toml_value_to_json(value)?))) + .collect::, &'static str>>() + .map(Value::Object), + } +} + /// A scalar value used to match an event field or metadata item. /// /// The `Table` and `Array` variants only appear in their declared compound or @@ -515,7 +565,7 @@ impl RiteHandler { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum RiteAction { - /// Forward the normalized event as JSON, or a configured template, to an HTTP endpoint. + /// Forward an event or configured body to a generic HTTP endpoint. HttpPost { /// Destination URL. url: Url, @@ -525,9 +575,230 @@ pub enum RiteAction { /// Optional `{{field}}` template for the request body. #[serde(default)] body_template: Option, + /// Optional structured JSON body. Only string leaves are templated. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_json_body" + )] + body_json: Option, }, } +/// A safe, code-authored failure while validating or rendering a structured body. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum JsonTemplateError { + /// The configured placeholder syntax or field name is invalid. + #[error("structured JSON template is invalid")] + InvalidTemplate, + /// The configured HTTP body modes conflict or cannot be serialized. + #[error("structured JSON action configuration is invalid")] + InvalidConfiguration, + /// A referenced event field is missing or null. + #[error("structured JSON template references missing or null data")] + MissingData, +} + +fn validate_action(action: &RiteAction) -> std::result::Result<(), String> { + match action { + RiteAction::HttpPost { + headers, + body_template, + body_json, + .. + } => { + if body_template.is_some() && body_json.is_some() { + return Err("body_json and body_template are mutually exclusive".into()); + } + if let Some(body) = body_json { + validate_json_template(body).map_err(|error| error.to_string())?; + validate_json_content_type(headers)?; + } + Ok(()) + } + } +} + +fn validate_json_content_type( + headers: &BTreeMap, +) -> std::result::Result<(), String> { + let mut values = headers + .iter() + .filter(|(name, _)| name.eq_ignore_ascii_case("content-type")) + .map(|(_, value)| value.as_str()); + let Some(value) = values.next() else { + return Ok(()); + }; + if values.next().is_some() { + return Err("structured JSON permits only one content-type header".into()); + } + + let mut parts = value.split(';'); + let media_type = parts.next().unwrap_or_default().trim(); + if !media_type.eq_ignore_ascii_case("application/json") { + return Err("structured JSON content-type must be application/json".into()); + } + let Some(parameter) = parts.next() else { + return Ok(()); + }; + if parts.next().is_some() { + return Err("structured JSON content-type permits only charset=utf-8".into()); + } + let Some((name, raw_value)) = parameter.split_once('=') else { + return Err("structured JSON content-type permits only charset=utf-8".into()); + }; + let raw_value = raw_value.trim(); + let charset = if raw_value.starts_with('"') && raw_value.ends_with('"') && raw_value.len() >= 2 + { + &raw_value[1..raw_value.len() - 1] + } else if raw_value.starts_with('"') || raw_value.ends_with('"') { + return Err("structured JSON content-type permits only charset=utf-8".into()); + } else { + raw_value + }; + if !name.trim().eq_ignore_ascii_case("charset") || !charset.eq_ignore_ascii_case("utf-8") { + return Err("structured JSON content-type permits only charset=utf-8".into()); + } + Ok(()) +} + +/// Validate the structured body's string-leaf templates before serving. +pub fn validate_json_template(value: &Value) -> std::result::Result<(), JsonTemplateError> { + match value { + Value::String(template) => { + template_fields(template)?; + Ok(()) + } + Value::Array(values) => values.iter().try_for_each(validate_json_template), + Value::Object(values) => values.values().try_for_each(validate_json_template), + Value::Null | Value::Bool(_) | Value::Number(_) => Ok(()), + } +} + +fn template_fields( + template: &str, +) -> std::result::Result, &str)>, JsonTemplateError> { + let mut fields = Vec::new(); + let mut cursor = 0; + loop { + let opening = template[cursor..].find("{{").map(|offset| cursor + offset); + let closing = template[cursor..].find("}}").map(|offset| cursor + offset); + match (opening, closing) { + (None, None) => return Ok(fields), + (None, Some(_)) => return Err(JsonTemplateError::InvalidTemplate), + (Some(open), Some(close)) if close < open => { + return Err(JsonTemplateError::InvalidTemplate); + } + (Some(open), _) => { + let field_start = open + 2; + let field_end = template[field_start..] + .find("}}") + .map(|offset| field_start + offset) + .ok_or(JsonTemplateError::InvalidTemplate)?; + let field = &template[field_start..field_end]; + if field.contains("{{") || !is_supported_template_field(field) { + return Err(JsonTemplateError::InvalidTemplate); + } + let end = field_end + 2; + fields.push((open..end, field)); + cursor = end; + } + } + } +} + +fn is_supported_template_field(field: &str) -> bool { + match field { + "source" | "event_type" | "action" | "timestamp" | "severity" | "title" | "body" => true, + _ => field.strip_prefix("metadata.").is_some_and(|path| { + let segments = path.split('.').collect::>(); + !segments.is_empty() && segments.iter().all(|segment| !segment.is_empty()) + }), + } +} + +/// Render a structured HTTP body, substituting only configured string leaves. +/// +/// Inserted values are appended once and are never interpreted as templates or +/// JSON syntax. The caller serializes the returned value after rendering. +pub fn render_json_template( + value: &Value, + event: &RiteEvent, +) -> std::result::Result { + match value { + Value::String(template) => Ok(Value::String(render_json_template_string(template, event)?)), + Value::Array(values) => values + .iter() + .map(|value| render_json_template(value, event)) + .collect::, _>>() + .map(Value::Array), + Value::Object(values) => values + .iter() + .map(|(key, value)| Ok((key.clone(), render_json_template(value, event)?))) + .collect::, JsonTemplateError>>() + .map(Value::Object), + Value::Null | Value::Bool(_) | Value::Number(_) => Ok(value.clone()), + } +} + +fn render_json_template_string( + template: &str, + event: &RiteEvent, +) -> std::result::Result { + let fields = template_fields(template)?; + let mut rendered = String::with_capacity(template.len()); + let mut cursor = 0; + for (range, field) in fields { + rendered.push_str(&template[cursor..range.start]); + rendered.push_str(&structured_template_value(field, event)?); + cursor = range.end; + } + rendered.push_str(&template[cursor..]); + Ok(rendered) +} + +fn structured_template_value( + field: &str, + event: &RiteEvent, +) -> std::result::Result { + let text = match field { + "source" => event.source.clone(), + "event_type" => event.event_type.clone(), + "action" => event.action.clone().ok_or(JsonTemplateError::MissingData)?, + "timestamp" => event.timestamp.to_rfc3339(), + "severity" => match event.severity { + Severity::Info => "info", + Severity::Warning => "warning", + Severity::Error => "error", + Severity::Critical => "critical", + } + .to_owned(), + "title" => event.title.clone(), + "body" => event.body.clone().ok_or(JsonTemplateError::MissingData)?, + _ => { + let path = field + .strip_prefix("metadata.") + .ok_or(JsonTemplateError::InvalidTemplate)?; + let mut segments = path.split('.'); + let mut value = event + .metadata + .get(segments.next().ok_or(JsonTemplateError::InvalidTemplate)?) + .ok_or(JsonTemplateError::MissingData)?; + for segment in segments { + value = value.get(segment).ok_or(JsonTemplateError::MissingData)?; + } + if value.is_null() { + return Err(JsonTemplateError::MissingData); + } + match value { + Value::String(text) => text.clone(), + other => other.to_string(), + } + } + }; + Ok(text) +} + /// Render a deterministic HTTP action template against a normalized event. /// /// Supported fields are `source`, `event_type`, `action`, `timestamp`, @@ -843,6 +1114,133 @@ action = { type = "http_post", url = "https://example.test/hook" }"#, )); } + #[test] + fn structured_json_toml_values_render_as_opaque_string_data() { + let adversarial = "quotes: \" \"; slash: \\; newline:\n tab:\t Unicode: 雪; delimiters: {{title}} }}; injection: \"},\"provider\":\"evil\""; + let handler: RiteHandler = toml::from_str( + r#"name = "structured" +source = "iris" +action = { type = "http_post", url = "https://example.test/hook", body_json = { "{{body}}" = "literal key", content = "{{body}}", nested = { count = "{{metadata.nested.count}}", label = "{{metadata.nested.label}}" }, provider = "telegram.synthetic", values = [true, 7, 1.5, "static 雪"] } }"#, + ) + .expect("structured TOML parses"); + let RiteAction::HttpPost { + body_json: Some(body_json), + .. + } = handler.action + else { + panic!("structured body is configured"); + }; + let event = event( + "iris", + "text", + Severity::Info, + Some(adversarial), + BTreeMap::from([( + "nested".into(), + serde_json::json!({ "count": 42, "label": "雪" }), + )]), + ); + + let rendered = render_json_template(&body_json, &event).expect("template renders"); + assert_eq!(rendered["{{body}}"], "literal key"); + assert_eq!(rendered["content"], adversarial); + assert_eq!(rendered["nested"]["count"], "42"); + assert_eq!(rendered["nested"]["label"], "雪"); + assert_eq!(rendered["provider"], "telegram.synthetic"); + assert_eq!( + rendered["values"], + serde_json::json!([true, 7, 1.5, "static 雪"]) + ); + let encoded = serde_json::to_vec(&rendered).expect("complete body serializes as JSON"); + let decoded: Value = serde_json::from_slice(&encoded).expect("serialized body parses"); + assert_eq!(decoded, rendered); + } + + #[test] + fn structured_json_missing_or_null_fields_fail_but_present_empty_strings_do_not() { + let body_template = serde_json::json!("{{body}}"); + let missing_body = event("iris", "text", Severity::Info, None, BTreeMap::new()); + assert_eq!( + render_json_template(&body_template, &missing_body), + Err(JsonTemplateError::MissingData) + ); + + let metadata_template = serde_json::json!("{{metadata.optional}} "); + let null_metadata = event( + "iris", + "text", + Severity::Info, + Some("present"), + BTreeMap::from([("optional".into(), Value::Null)]), + ); + assert_eq!( + render_json_template(&metadata_template, &null_metadata), + Err(JsonTemplateError::MissingData) + ); + + let mut empty_action = event("iris", "text", Severity::Info, None, BTreeMap::new()); + empty_action.action = Some(String::new()); + assert_eq!( + render_json_template(&serde_json::json!("{{action}}"), &empty_action) + .expect("empty but present values are valid"), + serde_json::json!("") + ); + } + + #[test] + fn structured_json_toml_rejects_datetime_and_non_finite_numbers() { + for (value, expected) in [ + ("2026-10-13", "TOML datetime"), + ("nan", "non-finite float"), + ("inf", "non-finite float"), + ] { + let config = format!( + "name = \"structured\"\nsource = \"github\"\naction = {{ type = \"http_post\", url = \"https://example.test/hook\", body_json = {{ value = {value} }} }}" + ); + let error = toml::from_str::(&config) + .expect_err("non-JSON TOML values must be rejected"); + assert!(error.message().contains(expected), "{value}: {error}"); + } + } + + #[test] + fn structured_json_validation_rejects_invalid_modes_templates_and_content_types() { + let invalid = [ + "body_template = \"legacy\", body_json = { text = \"fixed\" }", + "body_json = { text = \"{{unknown}}\" }", + "body_json = { text = \"{{metadata..value}}\" }", + "body_json = { text = \"unclosed {{body\" }", + "body_json = { text = \"orphan }}\" }", + "headers = { \"Content-Type\" = \"text/plain\" }, body_json = { text = \"fixed\" }", + "headers = { \"Content-Type\" = \"application/json; charset=latin1\" }, body_json = { text = \"fixed\" }", + "headers = { \"Content-Type\" = \"application/json; charset=utf-8; version=1\" }, body_json = { text = \"fixed\" }", + "headers = { \"Content-Type\" = \"application/json\", \"content-type\" = \"application/json\" }, body_json = { text = \"fixed\" }", + ]; + for fields in invalid { + let config = format!( + "name = \"structured\"\nsource = \"github\"\naction = {{ type = \"http_post\", url = \"https://example.test/hook\", {fields} }}" + ); + assert!( + toml::from_str::(&config).is_err(), + "invalid configuration accepted: {fields}" + ); + } + + let valid: RiteHandler = toml::from_str( + r#"name = "structured" +source = "github" +action = { type = "http_post", url = "https://example.test/hook", headers = { "Content-Type" = "Application/JSON; charset=\"UTF-8\"" }, body_json = "scalar root" }"#, + ) + .expect("JSON UTF-8 content type and scalar roots are supported"); + assert!(matches!( + valid.action, + RiteAction::HttpPost { + body_json: Some(Value::String(ref value)), + .. + } if value == "scalar root" + )); + } + fn event( source: &str, event_type: &str, diff --git a/crates/rite-server/src/dispatch.rs b/crates/rite-server/src/dispatch.rs index ab38dcc..f5d0039 100644 --- a/crates/rite-server/src/dispatch.rs +++ b/crates/rite-server/src/dispatch.rs @@ -192,15 +192,16 @@ async fn receive_event(state: &AppState, input: RawOperationInput) -> axum::resp url, headers, body_template, - } => { - let request = crate::http_post_request( - &state.client, - url, - headers, - body_template.as_deref(), - &event, - ); - match request.send().await { + body_json, + } => match crate::http_post_request( + &state.client, + url, + headers, + body_template.as_deref(), + body_json.as_ref(), + &event, + ) { + Ok(request) => match request.send().await { Ok(response) if response.status().is_success() => { executed += 1; state @@ -223,8 +224,15 @@ async fn receive_event(state: &AppState, input: RawOperationInput) -> axum::resp .fetch_add(1, std::sync::atomic::Ordering::Relaxed); tracing::warn!(handler = %handler.name, "rite action request failed"); } + }, + Err(error) => { + state + .metrics + .actions_failed + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + tracing::warn!(handler = %handler.name, error = %error, "rite action request could not be built"); } - } + }, } } let matched_handlers = matched diff --git a/crates/rite-server/src/lib.rs b/crates/rite-server/src/lib.rs index 0c33219..aae50ae 100644 --- a/crates/rite-server/src/lib.rs +++ b/crates/rite-server/src/lib.rs @@ -12,7 +12,10 @@ use std::{ }; use axum::{Router, routing::get}; -use rite_core::{EventSource, MATCH_VALIDATION_ERROR_PREFIX, RiteAction, RiteEvent, RiteHandler}; +use rite_core::{ + ACTION_VALIDATION_ERROR_PREFIX, EventSource, MATCH_VALIDATION_ERROR_PREFIX, RiteAction, + RiteEvent, RiteHandler, +}; use rite_sources::{github::GitHubSource, iris::IrisSource, uptime_kuma::UptimeKumaSource}; use serde::Deserialize; @@ -120,8 +123,8 @@ pub fn app(state: AppState) -> Router { /// A configuration-load failure safe to display in CLI output and logs. /// /// Parser errors can retain a copy of their complete source document for span -/// rendering. This type deliberately retains only a known-safe matcher -/// diagnostic or a generic parse/type error, never raw TOML values. +/// rendering. This type deliberately retains only known-safe matcher/action +/// diagnostics or a generic parse/type error, never raw TOML values. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfigLoadError(String); @@ -130,6 +133,7 @@ impl ConfigLoadError { let message = error.message(); let safe_message = message .strip_prefix(MATCH_VALIDATION_ERROR_PREFIX) + .or_else(|| message.strip_prefix(ACTION_VALIDATION_ERROR_PREFIX)) .map_or_else( || "configuration syntax or value type is invalid".to_owned(), ToOwned::to_owned, @@ -308,29 +312,46 @@ pub fn configured_state(secret: &str, config: RiteConfig) -> rite_core::Result, body_template: Option<&str>, + body_json: Option<&serde_json::Value>, event: &RiteEvent, -) -> reqwest::RequestBuilder { +) -> std::result::Result { + if body_template.is_some() && body_json.is_some() { + return Err(rite_core::JsonTemplateError::InvalidConfiguration); + } + let mut request = client.post(url.clone()); for (name, value) in headers { request = request.header(name, value); } - if let Some(template) = body_template { + if let Some(body_json) = body_json { + let rendered = rite_core::render_json_template(body_json, event)?; + let body = serde_json::to_vec(&rendered) + .map_err(|_| rite_core::JsonTemplateError::InvalidConfiguration)?; + if !headers + .keys() + .any(|name| name.eq_ignore_ascii_case("content-type")) + { + request = request.header("content-type", "application/json"); + } + Ok(request.body(body)) + } else if let Some(template) = body_template { if !headers .keys() .any(|name| name.eq_ignore_ascii_case("content-type")) { request = request.header("content-type", "text/plain"); } - request.body(rite_core::render_template(template, event)) + Ok(request.body(rite_core::render_template(template, event))) } else { - request.json(event) + Ok(request.json(event)) } } @@ -361,21 +382,33 @@ pub fn start_iris_subscription(state: &AppState) { url, headers, body_template, + body_json, } = &handler.action; - let request = - http_post_request(&client, url, headers, body_template.as_deref(), &event); - match request.send().await { - Ok(response) if response.status().is_success() => { - metrics.actions_succeeded.fetch_add(1, Ordering::Relaxed); - tracing::info!(handler = %handler.name, "Iris event action completed"); - } - Ok(response) => { - metrics.actions_failed.fetch_add(1, Ordering::Relaxed); - tracing::warn!(handler = %handler.name, status = %response.status(), "Iris event action returned failure status"); - } - Err(_error) => { + match http_post_request( + &client, + url, + headers, + body_template.as_deref(), + body_json.as_ref(), + &event, + ) { + Ok(request) => match request.send().await { + Ok(response) if response.status().is_success() => { + metrics.actions_succeeded.fetch_add(1, Ordering::Relaxed); + tracing::info!(handler = %handler.name, "Iris event action completed"); + } + Ok(response) => { + metrics.actions_failed.fetch_add(1, Ordering::Relaxed); + tracing::warn!(handler = %handler.name, status = %response.status(), "Iris event action returned failure status"); + } + Err(_error) => { + metrics.actions_failed.fetch_add(1, Ordering::Relaxed); + tracing::warn!(handler = %handler.name, "Iris event action request failed"); + } + }, + Err(error) => { metrics.actions_failed.fetch_add(1, Ordering::Relaxed); - tracing::warn!(handler = %handler.name, "Iris event action request failed"); + tracing::warn!(handler = %handler.name, error = %error, "Iris event action request could not be built"); } } } @@ -864,28 +897,222 @@ mod tests { } #[tokio::test] - async fn iris_actions_send_the_same_generic_template_request() { + async fn webhook_structured_json_preserves_adversarial_text_and_fails_closed_on_missing_data() { + let (action_url, mut captured, shutdown) = receiver().await; + let config_text = r#"[sources.uptime_kuma] +enabled = true +secret = "kuma-secret" + +[[rites]] +name = "structured-send" +source = "uptime_kuma" +match = { event_type = "heartbeat" } +action = { type = "http_post", url = "__ACTION_URL__", body_json = { body = "{{body}}", nested = { enabled = true, count = 4 }, provider = "telegram.synthetic" } } +"# + .replace("__ACTION_URL__", &action_url); + let config = load_config(&config_text).expect("structured action config parses"); + let state = configured_state("github-secret", config).expect("state configures"); + let valid_app = app(state.clone()); + let adversarial = "quotes: \"; slash: \\; newline:\n tab:\t Unicode: 雪; delimiters: {{title}} }}; injection: \"},\"provider\":\"evil\""; + let body = serde_json::to_vec(&serde_json::json!({ + "monitor": { "id": 8, "name": "API" }, + "heartbeat": { "status": 0, "msg": adversarial } + })) + .expect("synthetic heartbeat serializes"); + let response = valid_app + .oneshot( + Request::post("/event/uptime_kuma") + .header("Signature", kuma_signature("kuma-secret", &body)) + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + + let (headers, captured_body) = timeout(Duration::from_secs(1), captured.recv()) + .await + .expect("structured action arrives") + .expect("request captured"); + assert_eq!(headers[header::CONTENT_TYPE], "application/json"); + let json: serde_json::Value = + serde_json::from_str(&captured_body).expect("receiver parses complete JSON"); + assert_eq!(json["body"], adversarial); + assert_eq!(json["provider"], "telegram.synthetic"); + assert_eq!( + json["nested"], + serde_json::json!({ "enabled": true, "count": 4 }) + ); + assert_eq!(state.metrics.actions_succeeded.load(Ordering::Relaxed), 1); + + let missing_config = load_config(&format!( + "[sources.uptime_kuma]\nenabled = true\nsecret = \"kuma-secret\"\n\n[[rites]]\nname = \"missing-body\"\nsource = \"uptime_kuma\"\nmatch = {{ event_type = \"heartbeat\" }}\naction = {{ type = \"http_post\", url = \"{action_url}\", body_json = {{ body = \"{{{{body}}}}\" }} }}" + )) + .expect("missing-data action config parses"); + let missing_state = configured_state("github-secret", missing_config) + .expect("missing-data state configures"); + let missing_app = app(missing_state.clone()); + let no_message = serde_json::to_vec(&serde_json::json!({ + "monitor": { "id": 9, "name": "API" }, + "heartbeat": { "status": 0 } + })) + .expect("synthetic heartbeat serializes"); + let response = missing_app + .oneshot( + Request::post("/event/uptime_kuma") + .header("Signature", kuma_signature("kuma-secret", &no_message)) + .body(Body::from(no_message)) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert_eq!( + missing_state.metrics.actions_failed.load(Ordering::Relaxed), + 1 + ); + assert!( + timeout(Duration::from_millis(100), captured.recv()) + .await + .is_err() + ); + shutdown.send(()).expect("receiver shuts down"); + } + + #[tokio::test] + async fn generated_handler_views_round_trip_structured_body_json() { + let config = load_config( + r#"[[rites]] +name = "structured-view" +source = "github" +action = { type = "http_post", url = "https://example.test/hook", body_json = { body = "{{title}}", provider = "telegram.synthetic" } } +"#, + ) + .expect("structured config parses"); + let app = app(configured_state("secret", config).expect("state configures")); + let listed = app + .clone() + .oneshot( + Request::get("/handlers") + .body(Body::empty()) + .expect("list request"), + ) + .await + .expect("list response"); + assert_eq!(listed.status(), StatusCode::OK); + let listed_body = to_bytes(listed.into_body(), usize::MAX) + .await + .expect("list body"); + let listed_json: serde_json::Value = + serde_json::from_slice(&listed_body).expect("list response is JSON"); + let expected = serde_json::json!({ "body": "{{title}}", "provider": "telegram.synthetic" }); + assert_eq!(listed_json[0]["action"]["body_json"], expected); + + let fetched = app + .oneshot( + Request::get("/handlers/structured-view") + .body(Body::empty()) + .expect("get request"), + ) + .await + .expect("get response"); + assert_eq!(fetched.status(), StatusCode::OK); + let fetched_body = to_bytes(fetched.into_body(), usize::MAX) + .await + .expect("get body"); + let fetched_json: serde_json::Value = + serde_json::from_slice(&fetched_body).expect("get response is JSON"); + assert_eq!(fetched_json["action"]["body_json"], expected); + } + + #[test] + fn structured_config_errors_are_value_free_at_the_production_load_boundary() { + let config = r#"[[rites]] +name = "structured" +source = "github" +action = { type = "http_post", url = "https://example.test/hook?token=PRIVATE_CONFIG_TOKEN", body_json = { private = "PRIVATE_BODY_SENTINEL", timestamp = 2026-10-13 } } +"#; + let error = load_config(config).expect_err("TOML datetimes are not JSON values"); + let displayed = error.to_string(); + assert!(displayed.contains("TOML datetime")); + assert!(!displayed.contains("PRIVATE_CONFIG_TOKEN")); + assert!(!displayed.contains("PRIVATE_BODY_SENTINEL")); + } + + #[test] + fn structured_policy_violations_fail_during_production_config_loading() { + let invalid_action_fields = [ + r#"body_template = "legacy", body_json = { body = "fixed" }"#, + r#"body_json = { body = "{{unknown_PRIVATE_BODY_SENTINEL}}" }"#, + r#"body_json = { body = "unclosed {{body" }"#, + r#"headers = { "Content-Type" = "text/plain" }, body_json = { body = "fixed" }"#, + r#"headers = { "Content-Type" = "application/json; charset=latin1" }, body_json = { body = "fixed" }"#, + r#"headers = { "Content-Type" = "application/json", "content-type" = "application/json" }, body_json = { body = "fixed" }"#, + r#"body_json = { private = "PRIVATE_BODY_SENTINEL", timestamp = 2026-10-13 }"#, + "body_json = nan", + ]; + let template = r#"[[rites]] +name = "invalid-structured" +source = "github" +action = { type = "http_post", url = "https://example.test/hook?token=PRIVATE_CONFIG_TOKEN", __ACTION_FIELDS__ } +"#; + + for fields in invalid_action_fields { + let config = template.replace("__ACTION_FIELDS__", fields); + let error = load_config(&config) + .expect_err("invalid structured action config must fail during loading"); + let displayed = error.to_string(); + assert!(!displayed.contains("PRIVATE_CONFIG_TOKEN")); + assert!(!displayed.contains("PRIVATE_BODY_SENTINEL")); + } + } + + #[tokio::test] + async fn iris_structured_json_uses_the_shared_request_builder() { let (action_url, mut captured, shutdown) = receiver().await; let iris_listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("Iris listener binds"); let iris_address = iris_listener.local_addr().expect("Iris listener address"); + let adversarial = "quotes: \"; slash: \\; newline:\n tab:\t Unicode: 雪; delimiters: {{title}} }}; injection: \"},\"provider\":\"evil\""; + let message = serde_json::json!({ + "id": "00000000-0000-0000-0000-000000000001", + "thread_id": "00000000-0000-0000-0000-000000000002", + "source": "telegram", + "source_id": "42", + "sender": { "source_id": "shiv", "display_name": "Shiv" }, + "kind": "text", + "body": adversarial, + "timestamp": "2026-08-15T00:00:00Z", + "metadata": {} + }) + .to_string(); + let (iris_shutdown, wait_for_shutdown) = tokio::sync::oneshot::channel(); tokio::spawn(async move { let (mut socket, _) = iris_listener.accept().await.expect("Iris client connects"); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: keep-alive\r\n\r\nevent: message\r\ndata: {message}\r\n\r\n" + ); socket - .write_all(concat!( - "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n", - "event: message\r\n", - "data: {\"id\":\"00000000-0000-0000-0000-000000000001\",\"thread_id\":\"00000000-0000-0000-0000-000000000002\",\"source\":\"telegram\",\"source_id\":\"42\",\"sender\":{\"source_id\":\"shiv\",\"display_name\":\"Shiv\"},\"kind\":\"text\",\"body\":\"hello\",\"timestamp\":\"2026-08-15T00:00:00Z\",\"metadata\":{}}\r\n\r\n" - ).as_bytes()) + .write_all(response.as_bytes()) .await .expect("Iris event writes"); + let _ = wait_for_shutdown.await; }); - let config = load_config(&format!( - "[sources.iris]\nenabled = true\nbase_url = \"http://{iris_address}\"\n\n[[rites]]\nname = \"iris-template\"\nsource = \"iris\"\nmatch = {{ event_type = \"text\" }}\naction = {{ type = \"http_post\", url = \"{action_url}\", headers = {{ x_action = \"iris\" }}, body_template = \"{{{{source}}}}:{{{{body}}}}\" }}" - )) - .expect("config parses"); - let state = configured_state("secret", config).expect("state configures"); + let config_text = r#"[sources.iris] + enabled = true + base_url = "__IRIS_URL__" + + [[rites]] + name = "iris-structured" + source = "iris" + match = { event_type = "text" } + action = { type = "http_post", url = "__ACTION_URL__", headers = { x_action = "iris" }, body_json = { body = "{{body}}", provider = "telegram.synthetic" } } + "# + .replace("__IRIS_URL__", &format!("http://{iris_address}")) + .replace("__ACTION_URL__", &action_url); + let config = load_config(&config_text).expect("config parses"); + let state = configured_state("fixture-secret", config).expect("state configures"); start_iris_subscription(&state); let (headers, body) = timeout(Duration::from_secs(1), captured.recv()) @@ -893,8 +1120,18 @@ mod tests { .expect("Iris action arrives") .expect("Iris request captured"); assert_eq!(headers["x_action"], "iris"); - assert_eq!(headers[header::CONTENT_TYPE], "text/plain"); - assert_eq!(body, "iris:hello"); + assert_eq!(headers[header::CONTENT_TYPE], "application/json"); + let json: serde_json::Value = serde_json::from_str(&body).expect("complete body is JSON"); + assert_eq!(json["body"], adversarial); + assert_eq!(json["provider"], "telegram.synthetic"); + timeout(Duration::from_secs(1), async { + while state.metrics.actions_succeeded.load(Ordering::Relaxed) != 1 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("successful HTTP response updates the action metric"); + iris_shutdown.send(()).expect("Iris stream shuts down"); shutdown.send(()).expect("receiver shuts down"); } diff --git a/rite.example.toml b/rite.example.toml index ec71601..69dc066 100644 --- a/rite.example.toml +++ b/rite.example.toml @@ -24,4 +24,4 @@ action = { type = "http_post", url = "https://example.test/hooks/urgent", header name = "inbox-attention" source = "iris" match = { all_of = [{ path_equals = { path = ["metadata", "message", "source"], value = "agent_hook" } }, { path_equals = { path = ["metadata", "message", "metadata", "schema_version"], value = 1 } }, { path_equals = { path = ["metadata", "message", "metadata", "installation_id"], value = "synthetic-alpha" } }, { path_equals = { path = ["metadata", "message", "metadata", "event_kind"], value = "attention_required" } }, { path_equals = { path = ["metadata", "message", "metadata", "direction"], value = "inbound" } }, { path_equals = { path = ["metadata", "message", "metadata", "content_opt_in"], value = true } }] } -action = { type = "http_post", url = "https://example.test/hooks/inbox" } +action = { type = "http_post", url = "https://example.test/hooks/inbox", body_json = { body = "{{metadata.message.body}}", provider = "telegram.synthetic" } }