Skip to main content

synapse_proxy/transform/
inject.rs

1//! `inject` built-in: set a header or a dotted body path from a context key or
2//! a constant. Caller-supplied values at the target are overwritten.
3
4use async_trait::async_trait;
5use serde_json::Value;
6
7use crate::config::InjectSpec;
8use crate::context::ResolvedContext;
9
10use super::{ProxyRequest, RequestTransform, TransformError};
11
12#[derive(Clone)]
13enum Target {
14    Header(String),
15    Body(String),
16}
17
18#[derive(Clone)]
19enum Source {
20    Context(String),
21    Const(Value),
22}
23
24#[derive(Clone)]
25pub struct Inject {
26    target: Target,
27    source: Source,
28}
29
30impl Inject {
31    /// Validate exactly one target and one source.
32    pub fn from_spec(spec: &InjectSpec) -> anyhow::Result<Self> {
33        let target = match (&spec.header, &spec.body) {
34            (Some(h), None) => Target::Header(h.clone()),
35            (None, Some(b)) => Target::Body(b.clone()),
36            _ => anyhow::bail!("inject requires exactly one of `header` or `body`"),
37        };
38        let source = match (&spec.from_context, &spec.constant) {
39            (Some(k), None) => Source::Context(k.clone()),
40            (None, Some(v)) => Source::Const(v.clone()),
41            _ => anyhow::bail!("inject requires exactly one of `from_context` or `const`"),
42        };
43        Ok(Self { target, source })
44    }
45
46    fn resolve_value(&self, ctx: &ResolvedContext) -> Option<Value> {
47        match &self.source {
48            Source::Const(v) => Some(v.clone()),
49            Source::Context(k) => ctx.get(k).map(|s| Value::String(s.to_string())),
50        }
51    }
52}
53
54/// Set a dotted path (`a.b.c`) in `root`, creating intermediate objects.
55pub fn set_body_path(root: &mut Value, path: &str, value: Value) {
56    let mut cur = root;
57    let parts: Vec<&str> = path.split('.').collect();
58    for (i, part) in parts.iter().enumerate() {
59        if !cur.is_object() {
60            *cur = Value::Object(Default::default());
61        }
62        let Some(obj) = cur.as_object_mut() else {
63            return;
64        };
65        if i == parts.len() - 1 {
66            obj.insert((*part).to_string(), value);
67            return;
68        }
69        cur = obj
70            .entry((*part).to_string())
71            .or_insert_with(|| Value::Object(Default::default()));
72    }
73}
74
75#[async_trait]
76impl RequestTransform for Inject {
77    async fn apply(
78        &self,
79        ctx: &ResolvedContext,
80        req: &mut ProxyRequest,
81    ) -> Result<(), TransformError> {
82        let value = self.resolve_value(ctx);
83        match &self.target {
84            Target::Header(name) => match value {
85                Some(v) => {
86                    let s = match &v {
87                        Value::String(s) => s.clone(),
88                        other => other.to_string(),
89                    };
90                    req.set_header(name, &s);
91                }
92                None => req.remove_header(name), // fail-safe: strip any caller-supplied value
93            },
94            Target::Body(path) => {
95                if let Some(v) = value {
96                    let body = req.body_json_mut()?;
97                    set_body_path(body, path, v);
98                }
99                // Body target with absent context: left as-is (require_context is the gate).
100            }
101        }
102        Ok(())
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::transform::ProxyRequest;
110    use axum::http::{HeaderMap, Method};
111    use std::collections::HashMap;
112
113    fn ctx() -> ResolvedContext {
114        // build via ContextStore to avoid exposing internals
115        let s = crate::context::ContextStore::new(HashMap::from([("org".into(), "acme".into())]));
116        s.resolve()
117    }
118    fn req(body: &[u8]) -> ProxyRequest {
119        ProxyRequest::from_parts(
120            Method::POST,
121            "/x".into(),
122            None,
123            HeaderMap::new(),
124            body.to_vec(),
125        )
126    }
127
128    #[tokio::test]
129    async fn injects_context_header_overwriting() {
130        let inj = Inject::from_spec(&InjectSpec {
131            header: Some("X-Tenant-Id".into()),
132            body: None,
133            from_context: Some("org".into()),
134            constant: None,
135        })
136        .unwrap();
137        let mut r = req(b"");
138        r.set_header("x-tenant-id", "attacker");
139        inj.apply(&ctx(), &mut r).await.unwrap();
140        assert_eq!(r.headers.get("X-Tenant-Id").unwrap(), "acme"); // overwritten
141    }
142
143    #[tokio::test]
144    async fn injects_constant_into_nested_body_path() {
145        let inj = Inject::from_spec(&InjectSpec {
146            header: None,
147            body: Some("params.context.user".into()),
148            from_context: None,
149            constant: Some(serde_json::json!("_default")),
150        })
151        .unwrap();
152        let mut r = req(b"{\"params\":{}}");
153        inj.apply(&ctx(), &mut r).await.unwrap();
154        let v: Value = serde_json::from_slice(&r.into_body_bytes()).unwrap();
155        assert_eq!(v["params"]["context"]["user"], "_default");
156    }
157
158    #[test]
159    fn from_spec_rejects_ambiguous_target() {
160        let bad = InjectSpec {
161            header: Some("h".into()),
162            body: Some("b".into()),
163            from_context: Some("k".into()),
164            constant: None,
165        };
166        assert!(Inject::from_spec(&bad).is_err());
167    }
168
169    #[test]
170    fn set_body_path_single_segment() {
171        let mut v = serde_json::json!({});
172        set_body_path(&mut v, "org", serde_json::json!("acme"));
173        assert_eq!(v["org"], "acme");
174    }
175
176    #[test]
177    fn set_body_path_overwrites_non_object_intermediate() {
178        let mut v = serde_json::json!({ "a": "scalar" });
179        set_body_path(&mut v, "a.b", serde_json::json!(1));
180        assert_eq!(v["a"]["b"], 1); // scalar intermediate replaced by an object
181    }
182
183    #[tokio::test]
184    async fn absent_context_header_is_stripped_not_passed_through() {
185        // context has no "user" key
186        let s = crate::context::ContextStore::new(std::collections::HashMap::new());
187        let ctx = s.resolve();
188        let inj = Inject::from_spec(&InjectSpec {
189            header: Some("x-user-id".into()),
190            body: None,
191            from_context: Some("user".into()),
192            constant: None,
193        })
194        .unwrap();
195        let mut r = req(b""); // helper in this test module
196        r.set_header("x-user-id", "attacker"); // caller-supplied identity
197        inj.apply(&ctx, &mut r).await.unwrap();
198        assert!(
199            r.headers.get("x-user-id").is_none(),
200            "caller identity must be stripped when context absent"
201        );
202    }
203}