Skip to main content

featherbit/plugins/native/
proxy_rewrite.rs

1//! The `proxy-rewrite` node — rewrites the request path and adds/removes
2//! headers on either the request or the response, depending on `phase`.
3
4use async_trait::async_trait;
5use std::collections::HashMap;
6
7use crate::context::Context;
8use crate::plugins::{Plugin, PluginOutput, PluginResult};
9use crate::vars::template::Template;
10
11/// Mutates either `Context.request` or `Context.response` (selected by
12/// `phase`): strips/adds a path prefix and adds/removes headers. Path
13/// rewriting only applies in the request phase; header names are lowercased
14/// before being applied. This plugin never fails at execution time.
15///
16/// `add_path_prefix` and `add_headers` values support `{{namespace.path}}`
17/// template references, rendered per request (see
18/// [`crate::vars::template::Template`]); `strip_path_prefix` and
19/// `remove_headers` are matchers, not emitted values, and are never
20/// templated.
21pub struct ProxyRewritePlugin {
22    strip_path_prefix: Option<String>,
23    add_path_prefix: Option<Template>,
24    add_headers: HashMap<String, Template>,
25    remove_headers: Vec<String>,
26    phase: RewritePhase,
27}
28
29/// Which side of the exchange the rewrite applies to.
30#[derive(Debug, Clone, PartialEq)]
31enum RewritePhase {
32    Request,
33    Response,
34}
35
36/// Stringifies a scalar header value (string, number, or bool); returns `None`
37/// for non-scalar shapes so callers can reject them at config load.
38fn header_value_to_string(v: &serde_json::Value) -> Option<String> {
39    match v {
40        serde_json::Value::String(s) => Some(s.clone()),
41        serde_json::Value::Number(n) => Some(n.to_string()),
42        serde_json::Value::Bool(b) => Some(b.to_string()),
43        _ => None,
44    }
45}
46
47/// Accepts both config shapes for `add_headers`:
48/// - map form (YAML):        `add_headers: { x-foo: bar }`
49/// - array form (UI editor): `add_headers: [{ name: x-foo, value: bar }]`
50///
51/// Scalar values (numbers, bools) are stringified; array entries with a blank
52/// `name` are skipped; any other shape is an error at config load. Each value
53/// is parsed into a [`Template`] (warnings discarded — Task 6 reports them);
54/// it renders `{{namespace.path}}` references per request and never touches
55/// `$var` syntax.
56fn parse_add_headers(
57    config: &HashMap<String, serde_json::Value>,
58) -> Result<HashMap<String, Template>, String> {
59    let Some(raw) = config.get("add_headers") else {
60        return Ok(HashMap::new());
61    };
62
63    match raw {
64        serde_json::Value::Object(m) => m
65            .iter()
66            .map(|(k, v)| {
67                header_value_to_string(v)
68                    .map(|s| (k.clone(), Template::parse(&s).0))
69                    .ok_or_else(|| format!("add_headers['{}'] must be a scalar value", k))
70            })
71            .collect(),
72        serde_json::Value::Array(items) => {
73            let mut headers = HashMap::new();
74            for item in items {
75                let obj = item.as_object().ok_or_else(|| {
76                    "add_headers entries must be objects with 'name' and 'value'".to_string()
77                })?;
78                let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
79                if name.trim().is_empty() {
80                    // blank row left in the UI editor
81                    continue;
82                }
83                let value = match obj.get("value") {
84                    None => String::new(),
85                    Some(v) => header_value_to_string(v)
86                        .ok_or_else(|| format!("add_headers['{}'] must be a scalar value", name))?,
87                };
88                headers.insert(name.to_string(), Template::parse(&value).0);
89            }
90            Ok(headers)
91        }
92        _ => Err(
93            "add_headers must be a map of name: value or a list of {name, value} objects"
94                .to_string(),
95        ),
96    }
97}
98
99impl ProxyRewritePlugin {
100    /// Builds the plugin from node config.
101    ///
102    /// Accepted keys (all optional):
103    /// - `phase` (string, default `request`): `request` or `response`; any
104    ///   value other than `response` falls back to `request`.
105    /// - `strip_path_prefix` (string): prefix removed from the request path
106    ///   when it matches; the result is normalized to start with `/`.
107    /// - `add_path_prefix` (string): prefix prepended to the request path
108    ///   (after stripping).
109    /// - `add_headers` (map `{name: value}` **or** array `[{name, value}]`,
110    ///   the UI editor's form): headers to append. Scalar values are
111    ///   stringified; malformed shapes (e.g. a plain string, or nested
112    ///   objects as values) error here at config load.
113    /// - `remove_headers` (array of strings): header names to delete.
114    ///
115    /// ```yaml
116    /// type: proxy-rewrite
117    /// config:
118    ///   phase: request
119    ///   strip_path_prefix: /api/v1
120    ///   add_headers:
121    ///     x-forwarded-tier: edge
122    ///   remove_headers: [x-internal]
123    /// ```
124    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
125        // The mirror of response-rewrite's mix-up: that plugin takes a single
126        // `headers` object, but proxy-rewrite takes `add_headers`/`remove_headers`.
127        // Reject the misplaced key rather than silently ignoring it.
128        if config.contains_key("headers") {
129            return Err(
130                "proxy-rewrite has no 'headers' key (that is response-rewrite's schema); \
131                 use add_headers: { name: value } and remove_headers: [name]"
132                    .to_string(),
133            );
134        }
135
136        let phase = match config.get("phase").and_then(|v| v.as_str()) {
137            Some("response") => RewritePhase::Response,
138            _ => RewritePhase::Request,
139        };
140
141        let strip_path_prefix = config
142            .get("strip_path_prefix")
143            .and_then(|v| v.as_str())
144            .map(String::from);
145
146        let add_path_prefix = config
147            .get("add_path_prefix")
148            .and_then(|v| v.as_str())
149            .map(|s| Template::parse(s).0);
150
151        let add_headers = parse_add_headers(config)?;
152
153        let remove_headers = config
154            .get("remove_headers")
155            .and_then(|v| v.as_array())
156            .map(|seq| {
157                seq.iter()
158                    .filter_map(|v| v.as_str().map(String::from))
159                    .collect()
160            })
161            .unwrap_or_default();
162
163        Ok(Self {
164            strip_path_prefix,
165            add_path_prefix,
166            add_headers,
167            remove_headers,
168            phase,
169        })
170    }
171}
172
173#[async_trait]
174impl Plugin for ProxyRewritePlugin {
175    fn plugin_type(&self) -> &str {
176        "proxy-rewrite"
177    }
178
179    fn reads_response_body(&self) -> bool {
180        // Path rewriting never touches the body, but a response-phase
181        // `add_headers` value is rendered after the upstream and may reference
182        // the response body. In the request phase the body does not exist yet
183        // for any configuration, so only the response phase can read it.
184        self.phase == RewritePhase::Response
185            && self
186                .add_headers
187                .values()
188                .any(|t| t.references_response_body())
189    }
190
191    async fn execute(&self, mut ctx: Context) -> PluginResult {
192        match self.phase {
193            RewritePhase::Request => {
194                // Strip path prefix
195                if let Some(ref prefix) = self.strip_path_prefix {
196                    if ctx.request.path.starts_with(prefix.as_str()) {
197                        let new_path = ctx.request.path[prefix.len()..].to_string();
198                        ctx.request.path = if new_path.is_empty() || !new_path.starts_with('/') {
199                            format!("/{}", new_path.trim_start_matches('/'))
200                        } else {
201                            new_path
202                        };
203                    }
204                }
205
206                // Add path prefix
207                if let Some(ref prefix) = self.add_path_prefix {
208                    let rendered = prefix.render(&ctx).into_owned();
209                    ctx.request.path = format!("{}{}", rendered, ctx.request.path);
210                }
211
212                // Add headers
213                for (key, tpl) in &self.add_headers {
214                    let value = tpl.render(&ctx).into_owned();
215                    ctx.request
216                        .headers
217                        .entry(key.to_lowercase())
218                        .or_default()
219                        .push(value);
220                }
221
222                // Remove headers (case-insensitive: the stored key may not be lowercase)
223                for key in &self.remove_headers {
224                    crate::plugins::util::headers::remove_ci(&mut ctx.request.headers, key);
225                }
226            }
227            RewritePhase::Response => {
228                // Add headers to response
229                for (key, tpl) in &self.add_headers {
230                    let value = tpl.render(&ctx).into_owned();
231                    ctx.response
232                        .headers
233                        .entry(key.to_lowercase())
234                        .or_default()
235                        .push(value);
236                }
237
238                // Remove headers from response (case-insensitive)
239                for key in &self.remove_headers {
240                    crate::plugins::util::headers::remove_ci(&mut ctx.response.headers, key);
241                }
242            }
243        }
244
245        Ok(PluginOutput::success(ctx))
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
253    use bytes::Bytes;
254
255    fn test_context(path: &str) -> Context {
256        Context {
257            request: GatewayRequest {
258                method: "GET".to_string(),
259                path: path.to_string(),
260                host: "localhost".to_string(),
261                scheme: "http".to_string(),
262                headers: HashMap::new(),
263                query_params: HashMap::new(),
264                body: Bytes::new(),
265                remote_addr: "127.0.0.1:12345".to_string(),
266                protocol: Protocol::Http1,
267            },
268            response: GatewayResponse {
269                status_code: 0,
270                headers: HashMap::new(),
271                body: Bytes::new(),
272                stream: None,
273            },
274            message: HashMap::new(),
275            errors: Vec::new(),
276        }
277    }
278
279    #[tokio::test]
280    async fn test_strip_path_prefix() {
281        let mut config = HashMap::new();
282        config.insert(
283            "strip_path_prefix".to_string(),
284            serde_json::Value::String("/api/v1".to_string()),
285        );
286        config.insert(
287            "phase".to_string(),
288            serde_json::Value::String("request".to_string()),
289        );
290
291        let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
292        let ctx = test_context("/api/v1/users");
293        let result = plugin.execute(ctx).await.unwrap();
294        assert_eq!(result.context.request.path, "/users");
295    }
296
297    #[tokio::test]
298    async fn test_strip_path_prefix_root() {
299        let mut config = HashMap::new();
300        config.insert(
301            "strip_path_prefix".to_string(),
302            serde_json::Value::String("/api/v1".to_string()),
303        );
304
305        let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
306        let ctx = test_context("/api/v1");
307        let result = plugin.execute(ctx).await.unwrap();
308        assert_eq!(result.context.request.path, "/");
309    }
310
311    #[tokio::test]
312    async fn test_add_request_header() {
313        let mut config = HashMap::new();
314        let mut headers = serde_json::Map::new();
315        headers.insert(
316            "x-custom".to_string(),
317            serde_json::Value::String("value".to_string()),
318        );
319        config.insert(
320            "add_headers".to_string(),
321            serde_json::Value::Object(headers),
322        );
323
324        let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
325        let ctx = test_context("/test");
326        let result = plugin.execute(ctx).await.unwrap();
327        assert_eq!(
328            result.context.request.headers.get("x-custom"),
329            Some(&vec!["value".to_string()])
330        );
331    }
332
333    #[tokio::test]
334    async fn test_add_request_header_array_shape() {
335        // The UI editor serializes add_headers as [{name, value}]
336        let mut config = HashMap::new();
337        config.insert(
338            "add_headers".to_string(),
339            serde_json::json!([{ "name": "x-custom", "value": "value" }]),
340        );
341
342        let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
343        let ctx = test_context("/test");
344        let result = plugin.execute(ctx).await.unwrap();
345        assert_eq!(
346            result.context.request.headers.get("x-custom"),
347            Some(&vec!["value".to_string()])
348        );
349    }
350
351    #[tokio::test]
352    async fn test_add_response_header_array_shape() {
353        let mut config = HashMap::new();
354        config.insert(
355            "phase".to_string(),
356            serde_json::Value::String("response".to_string()),
357        );
358        config.insert(
359            "add_headers".to_string(),
360            serde_json::json!([
361                { "name": "x-custom", "value": "value" },
362                { "name": "", "value": "ignored blank row" }
363            ]),
364        );
365
366        let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
367        let ctx = test_context("/test");
368        let result = plugin.execute(ctx).await.unwrap();
369        assert_eq!(
370            result.context.response.headers.get("x-custom"),
371            Some(&vec!["value".to_string()])
372        );
373        assert_eq!(result.context.response.headers.len(), 1);
374    }
375
376    #[tokio::test]
377    async fn test_add_headers_numeric_value_map_shape() {
378        let mut config = HashMap::new();
379        config.insert(
380            "add_headers".to_string(),
381            serde_json::json!({ "x-version": 2 }),
382        );
383
384        let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
385        let ctx = test_context("/test");
386        let result = plugin.execute(ctx).await.unwrap();
387        assert_eq!(
388            result.context.request.headers.get("x-version"),
389            Some(&vec!["2".to_string()])
390        );
391    }
392
393    #[tokio::test]
394    async fn test_add_headers_rejects_malformed() {
395        let mut config = HashMap::new();
396        config.insert(
397            "add_headers".to_string(),
398            serde_json::Value::String("x-custom: value".to_string()),
399        );
400        assert!(ProxyRewritePlugin::from_config(&config).is_err());
401
402        let mut config = HashMap::new();
403        config.insert(
404            "add_headers".to_string(),
405            serde_json::json!({ "x-nested": {"a": 1} }),
406        );
407        assert!(ProxyRewritePlugin::from_config(&config).is_err());
408    }
409
410    #[tokio::test]
411    async fn test_remove_response_header() {
412        let mut config = HashMap::new();
413        config.insert(
414            "phase".to_string(),
415            serde_json::Value::String("response".to_string()),
416        );
417        config.insert(
418            "remove_headers".to_string(),
419            serde_json::Value::Array(vec![serde_json::Value::String("x-internal".to_string())]),
420        );
421
422        let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
423        let mut ctx = test_context("/test");
424        ctx.response
425            .headers
426            .insert("x-internal".to_string(), vec!["secret".to_string()]);
427
428        let result = plugin.execute(ctx).await.unwrap();
429        assert!(!result.context.response.headers.contains_key("x-internal"));
430    }
431
432    /// TDD (Task 3): an `add_headers` value templated with
433    /// `{{request.method}}` must render against the live request and reach
434    /// the upstream-bound request headers.
435    #[tokio::test]
436    async fn test_add_headers_value_renders_template() {
437        let mut config = HashMap::new();
438        config.insert(
439            "add_headers".to_string(),
440            serde_json::json!({ "x-method": "{{request.method}}" }),
441        );
442
443        let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
444        let mut ctx = test_context("/test");
445        ctx.request.method = "POST".to_string();
446        let result = plugin.execute(ctx).await.unwrap();
447        assert_eq!(
448            result.context.request.headers.get("x-method"),
449            Some(&vec!["POST".to_string()])
450        );
451    }
452
453    /// TDD (Task 3): a literal `$` in an `add_headers` value (e.g. a password
454    /// fragment) must never be touched by the template engine — the safety
455    /// property this sweep exists to prove. Plain `render` never processes
456    /// `$var` syntax, unlike the legacy fields swept in Task 2.
457    #[tokio::test]
458    async fn test_add_headers_value_dollar_untouched() {
459        let mut config = HashMap::new();
460        config.insert(
461            "add_headers".to_string(),
462            serde_json::json!({ "x-secret": "pa$sword4" }),
463        );
464
465        let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
466        let ctx = test_context("/test");
467        let result = plugin.execute(ctx).await.unwrap();
468        assert_eq!(
469            result.context.request.headers.get("x-secret"),
470            Some(&vec!["pa$sword4".to_string()])
471        );
472    }
473
474    /// TDD (Task 3): `add_path_prefix` renders `{{...}}` references too.
475    #[tokio::test]
476    async fn test_add_path_prefix_renders_template() {
477        let mut config = HashMap::new();
478        config.insert(
479            "add_path_prefix".to_string(),
480            serde_json::json!("/{{request.headers.x-tenant}}"),
481        );
482
483        let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
484        let mut ctx = test_context("/users");
485        ctx.request
486            .headers
487            .insert("x-tenant".to_string(), vec!["acme".to_string()]);
488        let result = plugin.execute(ctx).await.unwrap();
489        assert_eq!(result.context.request.path, "/acme/users");
490    }
491
492    /// Header names are case-insensitive: a response header stored with a
493    /// non-lowercase key (e.g. from a Lua script or another plugin) must still
494    /// be removed by a lowercase `remove_headers` entry, and vice versa.
495    #[tokio::test]
496    async fn test_remove_response_header_is_case_insensitive() {
497        let mut config = HashMap::new();
498        config.insert(
499            "phase".to_string(),
500            serde_json::Value::String("response".to_string()),
501        );
502        config.insert(
503            "remove_headers".to_string(),
504            serde_json::Value::Array(vec![serde_json::Value::String("x-powered-by".to_string())]),
505        );
506
507        let plugin = ProxyRewritePlugin::from_config(&config).unwrap();
508        let mut ctx = test_context("/test");
509        // Stored with mixed case, removed with a lowercase config entry.
510        ctx.response
511            .headers
512            .insert("X-Powered-By".to_string(), vec!["php".to_string()]);
513
514        let result = plugin.execute(ctx).await.unwrap();
515        assert!(!result.context.response.headers.contains_key("X-Powered-By"));
516    }
517
518    /// A response-phase header whose value renders the response body makes the
519    /// node a body reader; the legacy `$resp_body` spelling counts too, since
520    /// `add_headers` values interpolate it at render time.
521    #[test]
522    fn test_proxy_rewrite_response_header_reading_the_body_forces_buffering() {
523        let config: HashMap<String, serde_json::Value> =
524            serde_json::from_value(serde_json::json!({
525                "phase": "response",
526                "add_headers": { "x-echo": "$resp_body" }
527            }))
528            .unwrap();
529        let p = ProxyRewritePlugin::from_config(&config).unwrap();
530        assert!(p.reads_response_body());
531    }
532
533    /// Ordinary header rewriting must stay stream-safe.
534    #[test]
535    fn test_proxy_rewrite_plain_headers_stay_stream_safe() {
536        let config: HashMap<String, serde_json::Value> =
537            serde_json::from_value(serde_json::json!({
538                "phase": "response",
539                "add_headers": { "x-served-by": "featherbit" }
540            }))
541            .unwrap();
542        let p = ProxyRewritePlugin::from_config(&config).unwrap();
543        assert!(!p.reads_response_body());
544    }
545}