Skip to main content

spin_trigger_http/
headers.rs

1use std::{
2    borrow::Cow,
3    net::SocketAddr,
4    str::{self, FromStr},
5};
6
7use anyhow::Result;
8use http::Uri;
9use hyper::Request;
10use spin_factor_outbound_networking::config::allowed_hosts::is_service_chaining_host;
11use spin_http::routes::RouteMatch;
12
13use crate::Body;
14
15// We need to make the following pieces of information available to both executors.
16// While the values we set are identical, the way they are passed to the
17// modules is going to be different, so each executor must must use the info
18// in its standardized way (environment variables for the Wagi executor, and custom headers
19// for the Spin HTTP executor).
20pub const FULL_URL: [&str; 2] = ["SPIN_FULL_URL", "X_FULL_URL"];
21pub const PATH_INFO: [&str; 2] = ["SPIN_PATH_INFO", "PATH_INFO"];
22pub const MATCHED_ROUTE: [&str; 2] = ["SPIN_MATCHED_ROUTE", "X_MATCHED_ROUTE"];
23pub const COMPONENT_ROUTE: [&str; 2] = ["SPIN_COMPONENT_ROUTE", "X_COMPONENT_ROUTE"];
24pub const RAW_COMPONENT_ROUTE: [&str; 2] = ["SPIN_RAW_COMPONENT_ROUTE", "X_RAW_COMPONENT_ROUTE"];
25pub const BASE_PATH: [&str; 2] = ["SPIN_BASE_PATH", "X_BASE_PATH"];
26pub const CLIENT_ADDR: [&str; 2] = ["SPIN_CLIENT_ADDR", "X_CLIENT_ADDR"];
27
28// Header key/value pairs that use copy on write to avoid allocation
29pub type HeaderPair<'a> = ([Cow<'static, str>; 2], Cow<'a, str>);
30
31/// Compute the default headers to be passed to the component.
32pub fn compute_default_headers<'a>(
33    uri: &Uri,
34    host: &str,
35    route_match: &'a RouteMatch,
36    client_addr: SocketAddr,
37) -> anyhow::Result<Vec<HeaderPair<'a>>> {
38    fn owned(strs: &[&'static str; 2]) -> [Cow<'static, str>; 2] {
39        [strs[0].into(), strs[1].into()]
40    }
41
42    let owned_full_url = owned(&FULL_URL);
43    let owned_path_info = owned(&PATH_INFO);
44    let owned_matched_route = owned(&MATCHED_ROUTE);
45    let owned_component_route = owned(&COMPONENT_ROUTE);
46    let owned_raw_component_route = owned(&RAW_COMPONENT_ROUTE);
47    let owned_base_path = owned(&BASE_PATH);
48    let owned_client_addr = owned(&CLIENT_ADDR);
49
50    let mut res = vec![];
51    let abs_path = uri
52        .path_and_query()
53        .expect("cannot get path and query")
54        .as_str();
55
56    let path_info = route_match.trailing_wildcard();
57
58    let scheme = uri.scheme_str().unwrap_or("http");
59
60    let full_url = format!("{scheme}://{host}{abs_path}");
61
62    res.push((owned_path_info, path_info));
63    res.push((owned_full_url, full_url.into()));
64    res.push((owned_matched_route, route_match.based_route().into()));
65
66    res.push((owned_base_path, "/".into()));
67    res.push((owned_raw_component_route, route_match.raw_route().into()));
68    res.push((
69        owned_component_route,
70        route_match.raw_route_or_prefix().into(),
71    ));
72    res.push((owned_client_addr, client_addr.to_string().into()));
73
74    for (wild_name, wild_value) in route_match.named_wildcards() {
75        let wild_header = format!("SPIN_PATH_MATCH_{}", wild_name.to_ascii_uppercase()).into();
76        let wild_wagi_header = format!("X_PATH_MATCH_{}", wild_name.to_ascii_uppercase()).into();
77        res.push(([wild_header, wild_wagi_header], wild_value.into()));
78    }
79
80    Ok(res)
81}
82
83pub fn strip_forbidden_headers(req: &mut Request<Body>) {
84    let headers = req.headers_mut();
85    if let Some(host_header) = headers.get("Host")
86        && let Ok(host) = host_header.to_str()
87        && is_service_chaining_host(host)
88    {
89        headers.remove("Host");
90    }
91    // This header is transport information - only meaningful to the server
92    // itself - and causes problems if guests need to forward requests.
93    headers.remove("Connection");
94}
95
96pub fn prepare_request_headers(
97    req: &Request<Body>,
98    route_match: &RouteMatch,
99    client_addr: SocketAddr,
100) -> Result<Vec<(String, String)>> {
101    let mut res = Vec::new();
102    for (name, value) in req
103        .headers()
104        .iter()
105        .map(|(name, value)| (name.to_string(), std::str::from_utf8(value.as_bytes())))
106    {
107        let value = value?.to_string();
108        res.push((name, value));
109    }
110
111    let default_host = http::HeaderValue::from_str("localhost")?;
112    let host = std::str::from_utf8(
113        req.headers()
114            .get("host")
115            .unwrap_or(&default_host)
116            .as_bytes(),
117    )?;
118
119    // Set the environment information (path info, base path, etc) as headers.
120    // In the future, we might want to have this information in a context
121    // object as opposed to headers.
122    for (keys, val) in compute_default_headers(req.uri(), host, route_match, client_addr)? {
123        res.push((prepare_header_key(&keys[0]), val.into_owned()));
124    }
125
126    Ok(res)
127}
128
129pub fn append_headers(
130    map: &mut http::HeaderMap,
131    headers: Option<Vec<(String, String)>>,
132) -> Result<()> {
133    if let Some(src) = headers {
134        for (k, v) in src.iter() {
135            map.insert(
136                http::header::HeaderName::from_str(k)?,
137                http::header::HeaderValue::from_str(v)?,
138            );
139        }
140    };
141
142    Ok(())
143}
144
145fn prepare_header_key(key: &str) -> String {
146    key.replace('_', "-").to_ascii_lowercase()
147}
148
149#[cfg(test)]
150mod tests {
151    use std::borrow::Cow;
152
153    use super::*;
154    use anyhow::Result;
155    use spin_http::routes::Router;
156
157    #[test]
158    fn test_spin_header_keys() {
159        assert_eq!(
160            prepare_header_key("SPIN_FULL_URL"),
161            "spin-full-url".to_string()
162        );
163        assert_eq!(
164            prepare_header_key("SPIN_PATH_INFO"),
165            "spin-path-info".to_string()
166        );
167        assert_eq!(
168            prepare_header_key("SPIN_RAW_COMPONENT_ROUTE"),
169            "spin-raw-component-route".to_string()
170        );
171    }
172
173    #[test]
174    fn test_default_headers() -> Result<()> {
175        let scheme = "https";
176        let host = "fermyon.dev";
177        let trigger_route = "/foo/...";
178        let component_path = "/foo";
179        let path_info = "/bar";
180        let client_addr: SocketAddr = "127.0.0.1:8777".parse().unwrap();
181
182        let req_uri = format!(
183            "{}://{}{}{}?key1=value1&key2=value2",
184            scheme, host, component_path, path_info
185        );
186
187        let req = http::Request::builder()
188            .method("POST")
189            .uri(req_uri)
190            .body("")?;
191
192        let router = Router::build(
193            "/",
194            [(
195                &spin_http::routes::TriggerLookupKey::Component("DUMMY".into()),
196                &trigger_route.into(),
197            )],
198            None,
199        )?;
200        let route_match = router.route("/foo/bar")?;
201
202        let default_headers = compute_default_headers(req.uri(), host, &route_match, client_addr)?;
203
204        assert_eq!(
205            search(&FULL_URL, &default_headers).unwrap(),
206            "https://fermyon.dev/foo/bar?key1=value1&key2=value2".to_string()
207        );
208        assert_eq!(
209            search(&PATH_INFO, &default_headers).unwrap(),
210            "/bar".to_string()
211        );
212        assert_eq!(
213            search(&MATCHED_ROUTE, &default_headers).unwrap(),
214            "/foo/...".to_string()
215        );
216        assert_eq!(
217            search(&BASE_PATH, &default_headers).unwrap(),
218            "/".to_string()
219        );
220        assert_eq!(
221            search(&RAW_COMPONENT_ROUTE, &default_headers).unwrap(),
222            "/foo/...".to_string()
223        );
224        assert_eq!(
225            search(&COMPONENT_ROUTE, &default_headers).unwrap(),
226            "/foo".to_string()
227        );
228        assert_eq!(
229            search(&CLIENT_ADDR, &default_headers).unwrap(),
230            "127.0.0.1:8777".to_string()
231        );
232
233        Ok(())
234    }
235
236    #[test]
237    fn test_default_headers_with_named_wildcards() -> Result<()> {
238        let scheme = "https";
239        let host = "fermyon.dev";
240        let trigger_route = "/foo/:userid/...";
241        let component_path = "/foo";
242        let path_info = "/bar";
243        let client_addr: SocketAddr = "127.0.0.1:8777".parse().unwrap();
244
245        let req_uri = format!(
246            "{}://{}{}/42{}?key1=value1&key2=value2",
247            scheme, host, component_path, path_info
248        );
249
250        let req = http::Request::builder()
251            .method("POST")
252            .uri(req_uri)
253            .body("")?;
254
255        let router = Router::build(
256            "/",
257            [(
258                &spin_http::routes::TriggerLookupKey::Component("DUMMY".into()),
259                &trigger_route.into(),
260            )],
261            None,
262        )?;
263        let route_match = router.route("/foo/42/bar")?;
264
265        let default_headers = compute_default_headers(req.uri(), host, &route_match, client_addr)?;
266
267        assert_eq!(
268            search(&FULL_URL, &default_headers).unwrap(),
269            "https://fermyon.dev/foo/42/bar?key1=value1&key2=value2".to_string()
270        );
271        assert_eq!(
272            search(&PATH_INFO, &default_headers).unwrap(),
273            "/bar".to_string()
274        );
275        assert_eq!(
276            search(&MATCHED_ROUTE, &default_headers).unwrap(),
277            "/foo/:userid/...".to_string()
278        );
279        assert_eq!(
280            search(&BASE_PATH, &default_headers).unwrap(),
281            "/".to_string()
282        );
283        assert_eq!(
284            search(&RAW_COMPONENT_ROUTE, &default_headers).unwrap(),
285            "/foo/:userid/...".to_string()
286        );
287        assert_eq!(
288            search(&COMPONENT_ROUTE, &default_headers).unwrap(),
289            "/foo/:userid".to_string()
290        );
291        assert_eq!(
292            search(&CLIENT_ADDR, &default_headers).unwrap(),
293            "127.0.0.1:8777".to_string()
294        );
295
296        assert_eq!(
297            search(
298                &["SPIN_PATH_MATCH_USERID", "X_PATH_MATCH_USERID"],
299                &default_headers
300            )
301            .unwrap(),
302            "42".to_string()
303        );
304
305        Ok(())
306    }
307
308    #[test]
309    fn forbidden_headers_are_removed() {
310        let mut req = Request::get("http://test.spin.internal")
311            .header("Host", "test.spin.internal")
312            .header("accept", "text/plain")
313            .body(Default::default())
314            .unwrap();
315
316        strip_forbidden_headers(&mut req);
317
318        assert_eq!(1, req.headers().len());
319        assert!(req.headers().get("Host").is_none());
320
321        let mut req = Request::get("http://test.spin.internal")
322            .header("Host", "test.spin.internal")
323            .header("accept", "text/plain")
324            .header("connection", "the knee bone and the leg bone")
325            .body(Default::default())
326            .unwrap();
327
328        strip_forbidden_headers(&mut req);
329
330        assert_eq!(1, req.headers().len());
331        assert!(req.headers().get("Host").is_none());
332        assert!(req.headers().get("Connection").is_none());
333
334        let mut req = Request::get("http://test.spin.internal")
335            .header("Host", "test.spin.internal:1234")
336            .header("accept", "text/plain")
337            .header("Connection", "the knee bone and the leg bone")
338            .body(Default::default())
339            .unwrap();
340
341        strip_forbidden_headers(&mut req);
342
343        assert_eq!(1, req.headers().len());
344        assert!(req.headers().get("Host").is_none());
345        assert!(req.headers().get("connection").is_none());
346    }
347
348    #[test]
349    fn non_forbidden_headers_are_not_removed() {
350        let mut req = Request::get("http://test.example.com")
351            .header("Host", "test.example.org")
352            .header("accept", "text/plain")
353            .body(Default::default())
354            .unwrap();
355
356        strip_forbidden_headers(&mut req);
357
358        assert_eq!(2, req.headers().len());
359        assert!(req.headers().get("Host").is_some());
360    }
361
362    fn search(
363        keys: &[&str; 2],
364        headers: &[([Cow<'static, str>; 2], Cow<'_, str>)],
365    ) -> Option<String> {
366        let mut res: Option<String> = None;
367        for (k, v) in headers {
368            if k[0] == keys[0] && k[1] == keys[1] {
369                res = Some(v.as_ref().to_owned());
370            }
371        }
372
373        res
374    }
375}