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 mut 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    if let Some(query) = uri.query() {
74        if !dest.ends_with('?') && !dest.ends_with('&') {
75            dest.push(if dest.contains('?') { '&' } else { '?' });
76        }
77        dest.push_str(query);
78    }
79
80    match HeaderValue::from_str(&dest) {
81        Ok(loc) => {
82            let mut resp = Response::new(Body::empty());
83            resp.headers_mut().insert(hyper::header::LOCATION, loc);
84            *resp.status_mut() = matched.kind;
85            tracing::trace!(
86                "uri matches redirects glob pattern, redirecting with status '{}'",
87                matched.kind
88            );
89            Some(Ok(resp))
90        }
91        Err(err) => handle_error(
92            Error::new(err).context("invalid header value from current uri"),
93            opts,
94            req,
95        ),
96    }
97}
98
99/// Replaces placeholders in the destination URI by matching capture groups from the original URI.
100pub(crate) fn replace_placeholders(
101    orig_uri: &str,
102    regex: &Regex,
103    dest_uri: &str,
104    ac: &aho_corasick::AhoCorasick,
105) -> Result<String, Error> {
106    let regex_caps = if let Some(regex_caps) = regex.captures(orig_uri) {
107        regex_caps
108    } else {
109        return Err(Error::msg("regex didn't match, extracting captures failed"));
110    };
111
112    let caps: Vec<&str> = (0..regex_caps.len())
113        .map(|i| regex_caps.get(i).map(|s| s.as_str()).unwrap_or(""))
114        .collect();
115
116    tracing::debug!("url redirects/rewrites regex equivalent: {regex}");
117    tracing::debug!("url redirects/rewrites glob pattern captures: {caps:?}");
118    tracing::debug!("url redirects/rewrites glob pattern destination: {dest_uri:?}");
119
120    match ac.try_replace_all(dest_uri, &caps) {
121        Ok(dest) => {
122            tracing::debug!("url redirects/rewrites glob pattern destination replaced: {dest:?}");
123            Ok(dest)
124        }
125        Err(err) => Err(Error::new(err).context("failed replacing captures")),
126    }
127}
128
129/// Logs error and produces an Internal Server Error response.
130pub(crate) fn handle_error<T>(
131    err: Error,
132    opts: &RequestHandlerOpts,
133    req: &Request<T>,
134) -> Option<Result<Response<Body>, Error>> {
135    tracing::error!("{err:?}");
136    Some(error_page::error_response(
137        req.uri(),
138        req.method(),
139        &StatusCode::INTERNAL_SERVER_ERROR,
140        &opts.page404,
141        &opts.page50x,
142    ))
143}
144
145/// It returns a redirect's destination path and status code if the current request uri
146/// matches against the provided redirect's array.
147pub fn get_redirection<'a>(
148    uri_host: &'a str,
149    uri_path: &'a str,
150    redirects_opts: Option<&'a [Redirects]>,
151) -> Option<&'a Redirects> {
152    if let Some(redirects_vec) = redirects_opts {
153        for redirect_entry in redirects_vec {
154            // Match `host` redirect against `uri_host` if specified
155            if let Some(host) = &redirect_entry.host {
156                tracing::debug!(
157                    "checking host '{host}' redirect entry against uri host '{uri_host}'"
158                );
159                if !host.eq(uri_host) {
160                    continue;
161                }
162            }
163
164            // Match source glob pattern against the request uri path
165            if redirect_entry.source.is_match(uri_path) {
166                return Some(redirect_entry);
167            }
168        }
169    }
170
171    None
172}
173
174#[cfg(test)]
175mod tests {
176    use super::pre_process;
177    use crate::{
178        Error,
179        handler::RequestHandlerOpts,
180        settings::{Advanced, Redirects, build_placeholder_replacer},
181    };
182    use hyper::{Body, Request, Response, StatusCode};
183    use regex_lite::Regex;
184
185    fn make_request(host: &str, uri: &str) -> Request<Body> {
186        let mut builder = Request::builder();
187        if !host.is_empty() {
188            builder = builder.header("Host", host);
189        }
190        builder.method("GET").uri(uri).body(Body::empty()).unwrap()
191    }
192
193    fn get_redirects() -> Vec<Redirects> {
194        let s1 = Regex::new(r"/source1$").unwrap();
195        let r1 = build_placeholder_replacer(&s1);
196        let s2 = Regex::new(r"/source2$").unwrap();
197        let r2 = build_placeholder_replacer(&s2);
198        let s3 = Regex::new(r"/(prefix/)?(source3)/(.*)").unwrap();
199        let r3 = build_placeholder_replacer(&s3);
200        let s4 = Regex::new(r"/source4/(.*)").unwrap();
201        let r4 = build_placeholder_replacer(&s4);
202        vec![
203            Redirects {
204                host: None,
205                source: s1,
206                destination: "/destination1".into(),
207                kind: StatusCode::FOUND,
208                replacer: r1,
209            },
210            Redirects {
211                host: Some("example.com".into()),
212                source: s2,
213                destination: "/destination2".into(),
214                kind: StatusCode::MOVED_PERMANENTLY,
215                replacer: r2,
216            },
217            Redirects {
218                host: Some("example.info".into()),
219                source: s3,
220                destination: "/destination3/$2/$3".into(),
221                kind: StatusCode::MOVED_PERMANENTLY,
222                replacer: r3,
223            },
224            Redirects {
225                host: None,
226                source: s4,
227                destination: "/destination4?p=$1".into(),
228                kind: StatusCode::FOUND,
229                replacer: r4,
230            },
231        ]
232    }
233
234    fn is_redirect(result: Option<Result<Response<Body>, Error>>) -> Option<(StatusCode, String)> {
235        if let Some(Ok(response)) = result {
236            let location = response.headers().get("Location")?.to_str().unwrap().into();
237            Some((response.status(), location))
238        } else {
239            None
240        }
241    }
242
243    #[test]
244    fn test_no_redirects() {
245        assert!(
246            pre_process(
247                &RequestHandlerOpts {
248                    advanced_opts: None,
249                    ..Default::default()
250                },
251                &make_request("", "/")
252            )
253            .is_none()
254        );
255
256        assert!(
257            pre_process(
258                &RequestHandlerOpts {
259                    advanced_opts: Some(Advanced {
260                        redirects: None,
261                        ..Default::default()
262                    }),
263                    ..Default::default()
264                },
265                &make_request("", "/")
266            )
267            .is_none()
268        );
269    }
270
271    #[test]
272    fn test_no_match() {
273        assert!(
274            pre_process(
275                &RequestHandlerOpts {
276                    advanced_opts: Some(Advanced {
277                        redirects: Some(get_redirects()),
278                        ..Default::default()
279                    }),
280                    ..Default::default()
281                },
282                &make_request("example.com", "/source2/whatever")
283            )
284            .is_none()
285        );
286
287        assert!(
288            pre_process(
289                &RequestHandlerOpts {
290                    advanced_opts: Some(Advanced {
291                        redirects: Some(get_redirects()),
292                        ..Default::default()
293                    }),
294                    ..Default::default()
295                },
296                &make_request("", "/source2")
297            )
298            .is_none()
299        );
300    }
301
302    #[test]
303    fn test_match() {
304        assert_eq!(
305            is_redirect(pre_process(
306                &RequestHandlerOpts {
307                    advanced_opts: Some(Advanced {
308                        redirects: Some(get_redirects()),
309                        ..Default::default()
310                    }),
311                    ..Default::default()
312                },
313                &make_request("", "/source1")
314            )),
315            Some((StatusCode::FOUND, "/destination1".into()))
316        );
317
318        assert_eq!(
319            is_redirect(pre_process(
320                &RequestHandlerOpts {
321                    advanced_opts: Some(Advanced {
322                        redirects: Some(get_redirects()),
323                        ..Default::default()
324                    }),
325                    ..Default::default()
326                },
327                &make_request("example.com", "/source2")
328            )),
329            Some((StatusCode::MOVED_PERMANENTLY, "/destination2".into()))
330        );
331
332        assert_eq!(
333            is_redirect(pre_process(
334                &RequestHandlerOpts {
335                    advanced_opts: Some(Advanced {
336                        redirects: Some(get_redirects()),
337                        ..Default::default()
338                    }),
339                    ..Default::default()
340                },
341                &make_request("example.info", "/source3/whatever")
342            )),
343            Some((
344                StatusCode::MOVED_PERMANENTLY,
345                "/destination3/source3/whatever".into()
346            ))
347        );
348
349        assert_eq!(
350            is_redirect(pre_process(
351                &RequestHandlerOpts {
352                    advanced_opts: Some(Advanced {
353                        redirects: Some(get_redirects()),
354                        ..Default::default()
355                    }),
356                    ..Default::default()
357                },
358                &make_request("", "/source4/whatever")
359            )),
360            Some((StatusCode::FOUND, "/destination4?p=whatever".into()))
361        );
362    }
363
364    #[test]
365    fn test_query() {
366        assert_eq!(
367            is_redirect(pre_process(
368                &RequestHandlerOpts {
369                    advanced_opts: Some(Advanced {
370                        redirects: Some(get_redirects()),
371                        ..Default::default()
372                    }),
373                    ..Default::default()
374                },
375                &make_request("", "/source1?q=query-string")
376            )),
377            Some((StatusCode::FOUND, "/destination1?q=query-string".into()))
378        );
379
380        assert_eq!(
381            is_redirect(pre_process(
382                &RequestHandlerOpts {
383                    advanced_opts: Some(Advanced {
384                        redirects: Some(get_redirects()),
385                        ..Default::default()
386                    }),
387                    ..Default::default()
388                },
389                &make_request("example.com", "/source2?q=query-string")
390            )),
391            Some((
392                StatusCode::MOVED_PERMANENTLY,
393                "/destination2?q=query-string".into()
394            ))
395        );
396
397        assert_eq!(
398            is_redirect(pre_process(
399                &RequestHandlerOpts {
400                    advanced_opts: Some(Advanced {
401                        redirects: Some(get_redirects()),
402                        ..Default::default()
403                    }),
404                    ..Default::default()
405                },
406                &make_request("example.info", "/source3/whatever?q=query-string")
407            )),
408            Some((
409                StatusCode::MOVED_PERMANENTLY,
410                "/destination3/source3/whatever?q=query-string".into()
411            ))
412        );
413
414        assert_eq!(
415            is_redirect(pre_process(
416                &RequestHandlerOpts {
417                    advanced_opts: Some(Advanced {
418                        redirects: Some(get_redirects()),
419                        ..Default::default()
420                    }),
421                    ..Default::default()
422                },
423                &make_request("", "/source4/whatever?q=query-string")
424            )),
425            Some((
426                StatusCode::FOUND,
427                "/destination4?p=whatever&q=query-string".into()
428            ))
429        );
430    }
431}