Skip to main content

static_web_server/
redirects.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// This file is part of Static Web Server.
3// See https://static-web-server.net/ for more information
4// Copyright (C) 2019-present Jose Quintana <joseluisq.net>
5
6//! Redirection module to handle config redirect URLs with pattern matching support.
7//!
8//! # Security: ReDoS / pattern complexity
9//!
10//! Redirect/rewrite source patterns are admin-supplied at startup. SWS
11//! uses [`regex_lite`], which has **no backtracking** (linear-time NFA
12//! engine), so the classic catastrophic-backtracking ReDoS class does
13//! not apply. However:
14//!
15//! - Per-request work is still proportional to `pattern_size * uri_len`.
16//!   To bound it, requests with URI paths longer than the internal matching cap
17//!   are skipped (no regex evaluation, no redirect).
18//! - Operators should treat redirect patterns as trusted configuration
19//!   and avoid loading them from untrusted sources.
20
21use headers::HeaderValue;
22use hyper::{Body, Request, Response, StatusCode};
23use regex_lite::Regex;
24
25use crate::{Error, error_page, handler::RequestHandlerOpts, settings::Redirects};
26
27/// Maximum URI length (bytes) that will be fed to the redirect regex
28/// engine. Requests above this size skip redirect matching entirely.
29///
30/// 8 KiB matches the common HTTP-server URI cap and is more than the
31/// largest realistic redirect source while still bounding per-request
32/// regex work to a small constant.
33pub(crate) const MAX_URI_LEN_FOR_REGEX: usize = 8 * 1024;
34
35/// Applies redirect rules to a request if necessary.
36pub(crate) fn pre_process<T>(
37    opts: &RequestHandlerOpts,
38    req: &Request<T>,
39) -> Option<Result<Response<Body>, Error>> {
40    let redirects = opts.advanced_opts.as_ref()?.redirects.as_deref()?;
41
42    let uri = req.uri();
43    let uri_path = uri.path();
44    // Refuse to run any regex against unreasonably long URIs.
45    // See module-level docs.
46    if uri_path.len() > MAX_URI_LEN_FOR_REGEX {
47        tracing::debug!(
48            "redirects: skipping match, uri path length {} exceeds cap {}",
49            uri_path.len(),
50            MAX_URI_LEN_FOR_REGEX
51        );
52        return None;
53    }
54    let host = req
55        .headers()
56        .get(http::header::HOST)
57        .and_then(|v| v.to_str().ok())
58        .unwrap_or("");
59    let mut uri_host = uri.host().unwrap_or(host).to_owned();
60    if let Some(uri_port) = uri.port_u16() {
61        uri_host.push_str(&format!(":{uri_port}"));
62    }
63    let matched = get_redirection(&uri_host, uri_path, Some(redirects))?;
64    let dest = match replace_placeholders(
65        uri_path,
66        &matched.source,
67        &matched.destination,
68        &matched.replacer,
69    ) {
70        Ok(dest) => dest,
71        Err(err) => return handle_error(err, opts, req),
72    };
73
74    match HeaderValue::from_str(&dest) {
75        Ok(loc) => {
76            let mut resp = Response::new(Body::empty());
77            resp.headers_mut().insert(hyper::header::LOCATION, loc);
78            *resp.status_mut() = matched.kind;
79            tracing::trace!(
80                "uri matches redirects glob pattern, redirecting with status '{}'",
81                matched.kind
82            );
83            Some(Ok(resp))
84        }
85        Err(err) => handle_error(
86            Error::new(err).context("invalid header value from current uri"),
87            opts,
88            req,
89        ),
90    }
91}
92
93/// Replaces placeholders in the destination URI by matching capture groups from the original URI.
94pub(crate) fn replace_placeholders(
95    orig_uri: &str,
96    regex: &Regex,
97    dest_uri: &str,
98    ac: &aho_corasick::AhoCorasick,
99) -> Result<String, Error> {
100    let regex_caps = if let Some(regex_caps) = regex.captures(orig_uri) {
101        regex_caps
102    } else {
103        return Err(Error::msg("regex didn't match, extracting captures failed"));
104    };
105
106    let caps: Vec<&str> = (0..regex_caps.len())
107        .map(|i| regex_caps.get(i).map(|s| s.as_str()).unwrap_or(""))
108        .collect();
109
110    tracing::debug!("url redirects/rewrites regex equivalent: {regex}");
111    tracing::debug!("url redirects/rewrites glob pattern captures: {caps:?}");
112    tracing::debug!("url redirects/rewrites glob pattern destination: {dest_uri:?}");
113
114    match ac.try_replace_all(dest_uri, &caps) {
115        Ok(dest) => {
116            tracing::debug!("url redirects/rewrites glob pattern destination replaced: {dest:?}");
117            Ok(dest)
118        }
119        Err(err) => Err(Error::new(err).context("failed replacing captures")),
120    }
121}
122
123/// Logs error and produces an Internal Server Error response.
124pub(crate) fn handle_error<T>(
125    err: Error,
126    opts: &RequestHandlerOpts,
127    req: &Request<T>,
128) -> Option<Result<Response<Body>, Error>> {
129    tracing::error!("{err:?}");
130    Some(error_page::error_response(
131        req.uri(),
132        req.method(),
133        &StatusCode::INTERNAL_SERVER_ERROR,
134        &opts.page404,
135        &opts.page50x,
136    ))
137}
138
139/// It returns a redirect's destination path and status code if the current request uri
140/// matches against the provided redirect's array.
141pub fn get_redirection<'a>(
142    uri_host: &'a str,
143    uri_path: &'a str,
144    redirects_opts: Option<&'a [Redirects]>,
145) -> Option<&'a Redirects> {
146    if let Some(redirects_vec) = redirects_opts {
147        for redirect_entry in redirects_vec {
148            // Match `host` redirect against `uri_host` if specified
149            if let Some(host) = &redirect_entry.host {
150                tracing::debug!(
151                    "checking host '{host}' redirect entry against uri host '{uri_host}'"
152                );
153                if !host.eq(uri_host) {
154                    continue;
155                }
156            }
157
158            // Match source glob pattern against the request uri path
159            if redirect_entry.source.is_match(uri_path) {
160                return Some(redirect_entry);
161            }
162        }
163    }
164
165    None
166}
167
168#[cfg(test)]
169mod tests {
170    use super::pre_process;
171    use crate::{
172        Error,
173        handler::RequestHandlerOpts,
174        settings::{Advanced, Redirects, build_placeholder_replacer},
175    };
176    use hyper::{Body, Request, Response, StatusCode};
177    use regex_lite::Regex;
178
179    fn make_request(host: &str, uri: &str) -> Request<Body> {
180        let mut builder = Request::builder();
181        if !host.is_empty() {
182            builder = builder.header("Host", host);
183        }
184        builder.method("GET").uri(uri).body(Body::empty()).unwrap()
185    }
186
187    fn get_redirects() -> Vec<Redirects> {
188        let s1 = Regex::new(r"/source1$").unwrap();
189        let r1 = build_placeholder_replacer(&s1);
190        let s2 = Regex::new(r"/source2$").unwrap();
191        let r2 = build_placeholder_replacer(&s2);
192        let s3 = Regex::new(r"/(prefix/)?(source3)/(.*)").unwrap();
193        let r3 = build_placeholder_replacer(&s3);
194        vec![
195            Redirects {
196                host: None,
197                source: s1,
198                destination: "/destination1".into(),
199                kind: StatusCode::FOUND,
200                replacer: r1,
201            },
202            Redirects {
203                host: Some("example.com".into()),
204                source: s2,
205                destination: "/destination2".into(),
206                kind: StatusCode::MOVED_PERMANENTLY,
207                replacer: r2,
208            },
209            Redirects {
210                host: Some("example.info".into()),
211                source: s3,
212                destination: "/destination3/$2/$3".into(),
213                kind: StatusCode::MOVED_PERMANENTLY,
214                replacer: r3,
215            },
216        ]
217    }
218
219    fn is_redirect(result: Option<Result<Response<Body>, Error>>) -> Option<(StatusCode, String)> {
220        if let Some(Ok(response)) = result {
221            let location = response.headers().get("Location")?.to_str().unwrap().into();
222            Some((response.status(), location))
223        } else {
224            None
225        }
226    }
227
228    #[test]
229    fn test_no_redirects() {
230        assert!(
231            pre_process(
232                &RequestHandlerOpts {
233                    advanced_opts: None,
234                    ..Default::default()
235                },
236                &make_request("", "/")
237            )
238            .is_none()
239        );
240
241        assert!(
242            pre_process(
243                &RequestHandlerOpts {
244                    advanced_opts: Some(Advanced {
245                        redirects: None,
246                        ..Default::default()
247                    }),
248                    ..Default::default()
249                },
250                &make_request("", "/")
251            )
252            .is_none()
253        );
254    }
255
256    #[test]
257    fn test_no_match() {
258        assert!(
259            pre_process(
260                &RequestHandlerOpts {
261                    advanced_opts: Some(Advanced {
262                        redirects: Some(get_redirects()),
263                        ..Default::default()
264                    }),
265                    ..Default::default()
266                },
267                &make_request("example.com", "/source2/whatever")
268            )
269            .is_none()
270        );
271
272        assert!(
273            pre_process(
274                &RequestHandlerOpts {
275                    advanced_opts: Some(Advanced {
276                        redirects: Some(get_redirects()),
277                        ..Default::default()
278                    }),
279                    ..Default::default()
280                },
281                &make_request("", "/source2")
282            )
283            .is_none()
284        );
285    }
286
287    #[test]
288    fn test_match() {
289        assert_eq!(
290            is_redirect(pre_process(
291                &RequestHandlerOpts {
292                    advanced_opts: Some(Advanced {
293                        redirects: Some(get_redirects()),
294                        ..Default::default()
295                    }),
296                    ..Default::default()
297                },
298                &make_request("", "/source1")
299            )),
300            Some((StatusCode::FOUND, "/destination1".into()))
301        );
302
303        assert_eq!(
304            is_redirect(pre_process(
305                &RequestHandlerOpts {
306                    advanced_opts: Some(Advanced {
307                        redirects: Some(get_redirects()),
308                        ..Default::default()
309                    }),
310                    ..Default::default()
311                },
312                &make_request("example.com", "/source2")
313            )),
314            Some((StatusCode::MOVED_PERMANENTLY, "/destination2".into()))
315        );
316
317        assert_eq!(
318            is_redirect(pre_process(
319                &RequestHandlerOpts {
320                    advanced_opts: Some(Advanced {
321                        redirects: Some(get_redirects()),
322                        ..Default::default()
323                    }),
324                    ..Default::default()
325                },
326                &make_request("example.info", "/source3/whatever")
327            )),
328            Some((
329                StatusCode::MOVED_PERMANENTLY,
330                "/destination3/source3/whatever".into()
331            ))
332        );
333    }
334}