Skip to main content

trillium_proxy/
upstream.rs

1//! Upstream selectors
2use std::{borrow::Cow, fmt::Debug};
3use trillium::Conn;
4use url::Url;
5
6#[cfg(feature = "upstream-connection-counting")]
7mod connection_counting;
8#[cfg(feature = "upstream-random")]
9mod random;
10mod round_robin;
11
12#[cfg(feature = "upstream-connection-counting")]
13pub use connection_counting::ConnectionCounting;
14#[cfg(feature = "upstream-random")]
15pub use random::RandomSelector;
16pub use round_robin::RoundRobin;
17
18/// a trait for selecting the correct upstream
19pub trait UpstreamSelector: Debug + Send + Sync + 'static {
20    /// does what it says on the label
21    fn determine_upstream(&self, conn: &mut Conn) -> Option<Url>;
22
23    /// turn self into a `Box<dyn UpstreamSelector>`
24    fn boxed(self) -> Box<dyn UpstreamSelector>
25    where
26        Self: Sized,
27    {
28        Box::new(self.into_upstream())
29    }
30}
31
32impl UpstreamSelector for Box<dyn UpstreamSelector> {
33    fn determine_upstream(&self, conn: &mut Conn) -> Option<Url> {
34        UpstreamSelector::determine_upstream(&**self, conn)
35    }
36
37    fn boxed(self) -> Box<dyn UpstreamSelector> {
38        self
39    }
40}
41
42/// represents something that can be used as an upstream selector
43///
44/// This primarily exists so &str can be used as a synonym for `Url`.
45/// All `UpstreamSelector`s also are `IntoUpstreamSelector`
46pub trait IntoUpstreamSelector {
47    /// the type that Self will be transformed into
48    type UpstreamSelector: UpstreamSelector;
49    /// transform self into the upstream selector
50    fn into_upstream(self) -> Self::UpstreamSelector;
51}
52
53impl<U: UpstreamSelector> IntoUpstreamSelector for U {
54    type UpstreamSelector = Self;
55
56    fn into_upstream(self) -> Self {
57        self
58    }
59}
60
61impl IntoUpstreamSelector for &str {
62    type UpstreamSelector = Url;
63
64    fn into_upstream(self) -> Url {
65        let url = match Url::try_from(self) {
66            Ok(url) => url,
67            Err(_) => panic!("could not convert proxy target into a url"),
68        };
69
70        assert!(!url.cannot_be_a_base(), "{url} cannot be a base");
71        url
72    }
73}
74
75impl IntoUpstreamSelector for String {
76    type UpstreamSelector = Url;
77
78    fn into_upstream(self) -> Url {
79        (&*self).into_upstream()
80    }
81}
82
83#[derive(Debug, Copy, Clone)]
84/// an upstream selector for forward proxy
85pub struct ForwardProxy;
86impl UpstreamSelector for ForwardProxy {
87    fn determine_upstream(&self, conn: &mut Conn) -> Option<Url> {
88        conn.path_and_query().parse().ok()
89    }
90}
91
92impl UpstreamSelector for Url {
93    fn determine_upstream(&self, conn: &mut Conn) -> Option<Url> {
94        join_within_base(self, conn.path_and_query())
95    }
96}
97
98/// Resolve `path_and_query` against `base`, refusing to proxy anything that
99/// escapes the base's directory.
100///
101/// `Url::join` resolves `..` per RFC 3986 §5.2.4, which lets a request walk out
102/// of a base that carries a path prefix — base `…/app/` with a request path of
103/// `/../secret` resolves to `/secret`. Following the `starts_with` containment
104/// check that `trillium-static` uses for filesystem roots, an escape yields
105/// `None` so the base prefix stays a real boundary. `%2e%2e` is recognized as a
106/// `..` segment during parsing and normalized like a literal one, so encoded
107/// traversal is caught by the same check; `%2f` is left encoded and rides
108/// through as opaque path content.
109fn join_within_base(base: &Url, path_and_query: &str) -> Option<Url> {
110    // Without a trailing slash, `Url::join`'s relative-reference resolution
111    // replaces the base's last path segment instead of appending under it (base
112    // `…/api` + `/foo` → `…/foo`), silently dropping the mount prefix.
113    let base = if base.path().ends_with('/') {
114        Cow::Borrowed(base)
115    } else {
116        let mut owned = base.clone();
117        owned.set_path(&format!("{}/", base.path()));
118        Cow::Owned(owned)
119    };
120
121    // Prefix with `./` so `Url::join` treats the request path as a relative
122    // *path* rather than a relative *reference*. Without this, a colon in the
123    // first path segment (e.g. `/trillium::Handler`) is parsed as a URL scheme,
124    // yielding a `cannot_be_a_base` url. See RFC 3986 §4.2.
125    let upstream = base
126        .join(&format!("./{}", path_and_query.trim_start_matches('/')))
127        .ok()?;
128
129    let base_path = base.path();
130    let base_dir = &base_path[..=base_path.rfind('/').unwrap_or(0)];
131    upstream.path().starts_with(base_dir).then_some(upstream)
132}
133
134impl<F> UpstreamSelector for F
135where
136    F: Fn(&mut Conn) -> Option<Url> + Debug + Send + Sync + 'static,
137{
138    fn determine_upstream(&self, conn: &mut Conn) -> Option<Url> {
139        self(conn)
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::join_within_base;
146    use url::Url;
147
148    fn base(s: &str) -> Url {
149        Url::parse(s).unwrap()
150    }
151
152    #[test]
153    fn dot_segments_cannot_escape_a_base_path_prefix() {
154        let base = base("http://upstream.example/app/");
155
156        // A `..` that would climb out of the `/app/` mount is refused entirely,
157        // rather than silently proxying a path the operator never exposed. The
158        // `url` crate decodes `%2e` before resolving, so percent-encoded
159        // traversal is caught by the same containment check.
160        for escape in [
161            "/../secret",
162            "/../../etc/passwd",
163            "/a/../../secret",
164            "/%2e%2e/secret",
165        ] {
166            assert_eq!(
167                join_within_base(&base, escape),
168                None,
169                "{escape} escaped the base prefix"
170            );
171        }
172
173        // Normalization *within* the mount is fine and stays contained.
174        assert_eq!(join_within_base(&base, "/foo").unwrap().path(), "/app/foo");
175        assert_eq!(join_within_base(&base, "/a/../b").unwrap().path(), "/app/b");
176    }
177
178    #[test]
179    fn resolution_never_leaves_the_base_host() {
180        let base = base("http://upstream.example/");
181        for input in [
182            "/https://sneaky.com",
183            "/../secret",
184            "/a/../b",
185            "/trillium::Handler",
186            "//sneaky.com/path",
187        ] {
188            if let Some(url) = join_within_base(&base, input) {
189                assert_eq!(
190                    url.host_str(),
191                    Some("upstream.example"),
192                    "{input} left the base host -> {url}"
193                );
194            }
195        }
196    }
197
198    #[test]
199    fn base_without_trailing_slash_keeps_its_prefix() {
200        // A base mounted without a trailing slash appends under its final
201        // segment rather than having it replaced by relative-reference
202        // resolution.
203        let base = base("http://upstream.example/api");
204        assert_eq!(
205            join_within_base(&base, "/users").unwrap().as_str(),
206            "http://upstream.example/api/users"
207        );
208        // Traversal still can't climb out of the normalized prefix.
209        assert_eq!(join_within_base(&base, "/../secret"), None);
210    }
211
212    #[test]
213    fn query_and_first_segment_colons_survive_containment() {
214        let base = base("http://upstream.example/");
215        let url = join_within_base(&base, "/trillium::Handler?x=1&y=2").unwrap();
216        assert_eq!(url.path(), "/trillium::Handler");
217        assert_eq!(url.query(), Some("x=1&y=2"));
218    }
219}