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 URIs longer than [`MAX_URI_LEN_FOR_REGEX`]
17//!   bytes 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::{Request, Response, StatusCode};
23use regex_lite::Regex;
24
25use crate::body::Body;
26use crate::{Error, error_page, handler::RequestHandlerOpts, settings::Redirects};
27
28/// Maximum URI length (bytes) that will be fed to the redirect regex
29/// engine. Requests above this size skip redirect matching entirely.
30///
31/// 8 KiB matches the common HTTP-server URI cap and is more than the
32/// largest realistic redirect source while still bounding per-request
33/// regex work to a small constant.
34pub(crate) const MAX_URI_LEN_FOR_REGEX: usize = 8 * 1024;
35
36/// Applies redirect rules to a request if necessary.
37pub(crate) fn pre_process<T>(
38    opts: &RequestHandlerOpts,
39    req: &Request<T>,
40) -> Option<Result<Response<Body>, Error>> {
41    let redirects = opts.advanced_opts.as_ref()?.redirects.as_deref()?;
42
43    let uri = req.uri();
44    let uri_path = uri.path();
45    // SECURITY (ReDoS bound): refuse to run any regex against
46    // unreasonably long URIs. See module-level docs.
47    if uri_path.len() > MAX_URI_LEN_FOR_REGEX {
48        tracing::debug!(
49            "redirects: skipping match, uri path length {} exceeds cap {}",
50            uri_path.len(),
51            MAX_URI_LEN_FOR_REGEX
52        );
53        return None;
54    }
55    let host = req
56        .headers()
57        .get(http::header::HOST)
58        .and_then(|v| v.to_str().ok())
59        .unwrap_or("");
60    let mut uri_host = uri.host().unwrap_or(host).to_owned();
61    if let Some(uri_port) = uri.port_u16() {
62        uri_host.push_str(&format!(":{uri_port}"));
63    }
64    let matched = get_redirection(&uri_host, uri_path, Some(redirects))?;
65    let mut dest = match replace_placeholders(
66        uri_path,
67        &matched.source,
68        &matched.destination,
69        &matched.replacer,
70    ) {
71        Ok(dest) => dest,
72        Err(err) => return handle_error(err, opts, req),
73    };
74
75    // Preserve the client's query string across the redirect
76    // in an Apache's QSA rewrite option fashion.
77    if let Some(query) = uri.query() {
78        if !dest.ends_with('?') && !dest.ends_with('&') {
79            dest.push(if dest.contains('?') { '&' } else { '?' });
80        }
81        dest.push_str(query);
82    }
83
84    match HeaderValue::from_str(&dest) {
85        Ok(loc) => {
86            let mut resp = Response::new(crate::body::empty());
87            resp.headers_mut().insert(hyper::header::LOCATION, loc);
88            *resp.status_mut() = matched.kind;
89            tracing::trace!(
90                "uri matches redirects glob pattern, redirecting with status '{}'",
91                matched.kind
92            );
93            Some(Ok(resp))
94        }
95        Err(err) => handle_error(
96            Error::new(err).context("invalid header value from current uri"),
97            opts,
98            req,
99        ),
100    }
101}
102
103/// Replaces placeholders in the destination URI by matching capture groups from the original URI.
104#[doc(hidden)]
105pub fn replace_placeholders(
106    orig_uri: &str,
107    regex: &Regex,
108    dest_uri: &str,
109    ac: &aho_corasick::AhoCorasick,
110) -> Result<String, Error> {
111    let regex_caps = if let Some(regex_caps) = regex.captures(orig_uri) {
112        regex_caps
113    } else {
114        return Err(Error::msg("regex didn't match, extracting captures failed"));
115    };
116
117    let caps: Vec<&str> = (0..regex_caps.len())
118        .map(|i| regex_caps.get(i).map(|s| s.as_str()).unwrap_or(""))
119        .collect();
120
121    tracing::debug!("url redirects/rewrites regex equivalent: {regex}");
122    tracing::debug!("url redirects/rewrites glob pattern captures: {caps:?}");
123    tracing::debug!("url redirects/rewrites glob pattern destination: {dest_uri:?}");
124
125    match ac.try_replace_all(dest_uri, &caps) {
126        Ok(dest) => {
127            tracing::debug!("url redirects/rewrites glob pattern destination replaced: {dest:?}");
128            Ok(dest)
129        }
130        Err(err) => Err(Error::new(err).context("failed replacing captures")),
131    }
132}
133
134/// Logs error and produces an Internal Server Error response.
135pub(crate) fn handle_error<T>(
136    err: Error,
137    opts: &RequestHandlerOpts,
138    req: &Request<T>,
139) -> Option<Result<Response<Body>, Error>> {
140    tracing::error!("{err:?}");
141    Some(error_page::error_response(
142        req.uri(),
143        req.method(),
144        &StatusCode::INTERNAL_SERVER_ERROR,
145        &opts.page404,
146        &opts.page50x,
147    ))
148}
149
150/// It returns a redirect's destination path and status code if the current request uri
151/// matches against the provided redirect's array.
152pub fn get_redirection<'a>(
153    uri_host: &'a str,
154    uri_path: &'a str,
155    redirects_opts: Option<&'a [Redirects]>,
156) -> Option<&'a Redirects> {
157    if let Some(redirects_vec) = redirects_opts {
158        for redirect_entry in redirects_vec {
159            // Match `host` redirect against `uri_host` if specified
160            if let Some(host) = &redirect_entry.host {
161                tracing::debug!(
162                    "checking host '{host}' redirect entry against uri host '{uri_host}'"
163                );
164                if !host.eq(uri_host) {
165                    continue;
166                }
167            }
168
169            // Match source glob pattern against the request uri path
170            if redirect_entry.source.is_match(uri_path) {
171                return Some(redirect_entry);
172            }
173        }
174    }
175
176    None
177}
178
179#[cfg(test)]
180mod tests {
181    use super::pre_process;
182    use crate::body::Body;
183    use crate::{
184        Error,
185        handler::RequestHandlerOpts,
186        settings::{Advanced, Redirects, build_placeholder_replacer},
187    };
188    use hyper::{Request, Response, StatusCode};
189    use regex_lite::Regex;
190
191    fn make_request(host: &str, uri: &str) -> Request<Body> {
192        let mut builder = Request::builder();
193        if !host.is_empty() {
194            builder = builder.header("Host", host);
195        }
196        builder
197            .method("GET")
198            .uri(uri)
199            .body(crate::body::empty())
200            .unwrap()
201    }
202
203    fn get_redirects() -> Vec<Redirects> {
204        let s1 = Regex::new(r"/source1$").unwrap();
205        let r1 = build_placeholder_replacer(&s1);
206        let s2 = Regex::new(r"/source2$").unwrap();
207        let r2 = build_placeholder_replacer(&s2);
208        let s3 = Regex::new(r"/(prefix/)?(source3)/(.*)").unwrap();
209        let r3 = build_placeholder_replacer(&s3);
210        let s4 = Regex::new(r"/source4/(.*)").unwrap();
211        let r4 = build_placeholder_replacer(&s4);
212        vec![
213            Redirects {
214                host: None,
215                source: s1,
216                destination: "/destination1".into(),
217                kind: StatusCode::FOUND,
218                replacer: r1,
219            },
220            Redirects {
221                host: Some("example.com".into()),
222                source: s2,
223                destination: "/destination2".into(),
224                kind: StatusCode::MOVED_PERMANENTLY,
225                replacer: r2,
226            },
227            Redirects {
228                host: Some("example.info".into()),
229                source: s3,
230                destination: "/destination3/$2/$3".into(),
231                kind: StatusCode::MOVED_PERMANENTLY,
232                replacer: r3,
233            },
234            Redirects {
235                host: None,
236                source: s4,
237                destination: "/destination4?p=$1".into(),
238                kind: StatusCode::FOUND,
239                replacer: r4,
240            },
241        ]
242    }
243
244    fn is_redirect(result: Option<Result<Response<Body>, Error>>) -> Option<(StatusCode, String)> {
245        if let Some(Ok(response)) = result {
246            let location = response.headers().get("Location")?.to_str().unwrap().into();
247            Some((response.status(), location))
248        } else {
249            None
250        }
251    }
252
253    #[test]
254    fn test_no_redirects() {
255        assert!(
256            pre_process(
257                &RequestHandlerOpts {
258                    advanced_opts: None,
259                    ..Default::default()
260                },
261                &make_request("", "/")
262            )
263            .is_none()
264        );
265
266        assert!(
267            pre_process(
268                &RequestHandlerOpts {
269                    advanced_opts: Some(Advanced {
270                        redirects: None,
271                        ..Default::default()
272                    }),
273                    ..Default::default()
274                },
275                &make_request("", "/")
276            )
277            .is_none()
278        );
279    }
280
281    #[test]
282    fn test_no_match() {
283        assert!(
284            pre_process(
285                &RequestHandlerOpts {
286                    advanced_opts: Some(Advanced {
287                        redirects: Some(get_redirects()),
288                        ..Default::default()
289                    }),
290                    ..Default::default()
291                },
292                &make_request("example.com", "/source2/whatever")
293            )
294            .is_none()
295        );
296
297        assert!(
298            pre_process(
299                &RequestHandlerOpts {
300                    advanced_opts: Some(Advanced {
301                        redirects: Some(get_redirects()),
302                        ..Default::default()
303                    }),
304                    ..Default::default()
305                },
306                &make_request("", "/source2")
307            )
308            .is_none()
309        );
310    }
311
312    #[test]
313    fn test_match() {
314        assert_eq!(
315            is_redirect(pre_process(
316                &RequestHandlerOpts {
317                    advanced_opts: Some(Advanced {
318                        redirects: Some(get_redirects()),
319                        ..Default::default()
320                    }),
321                    ..Default::default()
322                },
323                &make_request("", "/source1")
324            )),
325            Some((StatusCode::FOUND, "/destination1".into()))
326        );
327
328        assert_eq!(
329            is_redirect(pre_process(
330                &RequestHandlerOpts {
331                    advanced_opts: Some(Advanced {
332                        redirects: Some(get_redirects()),
333                        ..Default::default()
334                    }),
335                    ..Default::default()
336                },
337                &make_request("example.com", "/source2")
338            )),
339            Some((StatusCode::MOVED_PERMANENTLY, "/destination2".into()))
340        );
341
342        assert_eq!(
343            is_redirect(pre_process(
344                &RequestHandlerOpts {
345                    advanced_opts: Some(Advanced {
346                        redirects: Some(get_redirects()),
347                        ..Default::default()
348                    }),
349                    ..Default::default()
350                },
351                &make_request("example.info", "/source3/whatever")
352            )),
353            Some((
354                StatusCode::MOVED_PERMANENTLY,
355                "/destination3/source3/whatever".into()
356            ))
357        );
358
359        assert_eq!(
360            is_redirect(pre_process(
361                &RequestHandlerOpts {
362                    advanced_opts: Some(Advanced {
363                        redirects: Some(get_redirects()),
364                        ..Default::default()
365                    }),
366                    ..Default::default()
367                },
368                &make_request("", "/source4/whatever")
369            )),
370            Some((StatusCode::FOUND, "/destination4?p=whatever".into()))
371        );
372    }
373
374    #[test]
375    fn test_query() {
376        assert_eq!(
377            is_redirect(pre_process(
378                &RequestHandlerOpts {
379                    advanced_opts: Some(Advanced {
380                        redirects: Some(get_redirects()),
381                        ..Default::default()
382                    }),
383                    ..Default::default()
384                },
385                &make_request("", "/source1?q=query-string")
386            )),
387            Some((StatusCode::FOUND, "/destination1?q=query-string".into()))
388        );
389
390        assert_eq!(
391            is_redirect(pre_process(
392                &RequestHandlerOpts {
393                    advanced_opts: Some(Advanced {
394                        redirects: Some(get_redirects()),
395                        ..Default::default()
396                    }),
397                    ..Default::default()
398                },
399                &make_request("example.com", "/source2?q=query-string")
400            )),
401            Some((
402                StatusCode::MOVED_PERMANENTLY,
403                "/destination2?q=query-string".into()
404            ))
405        );
406
407        assert_eq!(
408            is_redirect(pre_process(
409                &RequestHandlerOpts {
410                    advanced_opts: Some(Advanced {
411                        redirects: Some(get_redirects()),
412                        ..Default::default()
413                    }),
414                    ..Default::default()
415                },
416                &make_request("example.info", "/source3/whatever?q=query-string")
417            )),
418            Some((
419                StatusCode::MOVED_PERMANENTLY,
420                "/destination3/source3/whatever?q=query-string".into()
421            ))
422        );
423
424        assert_eq!(
425            is_redirect(pre_process(
426                &RequestHandlerOpts {
427                    advanced_opts: Some(Advanced {
428                        redirects: Some(get_redirects()),
429                        ..Default::default()
430                    }),
431                    ..Default::default()
432                },
433                &make_request("", "/source4/whatever?q=query-string")
434            )),
435            Some((
436                StatusCode::FOUND,
437                "/destination4?p=whatever&q=query-string".into()
438            ))
439        );
440    }
441
442    // Property-based regression tests for `replace_placeholders` and the
443    // upstream URI length guard.
444    //
445    // The guard exists so adversarial inputs cannot pin the CPU on
446    // regex matching; the property is "calls never panic and respect
447    // the cap". `replace_placeholders` itself must also be total over
448    // any byte-length input bounded by `MAX_URI_LEN_FOR_REGEX`.
449    use super::{MAX_URI_LEN_FOR_REGEX, replace_placeholders};
450    use proptest::prelude::*;
451
452    proptest! {
453        #![proptest_config(ProptestConfig {
454            cases: 128, ..ProptestConfig::default()
455        })]
456
457        /// `replace_placeholders` must never panic on arbitrary input
458        /// pairs (orig URI, destination template) within the URI cap,
459        /// regardless of whether the regex matches.
460        #[test]
461        fn prop_replace_placeholders_never_panics(
462            orig in "\\PC{0,512}",
463            dest in "\\PC{0,512}",
464        ) {
465            // A representative source pattern with 5 capture groups
466            // \u2014 mirrors realistic redirect rules.
467            let re = Regex::new(r"^/(.*)/(.*)/(.*)/(.*)/(.*)$").unwrap();
468            let ac = build_placeholder_replacer(&re);
469            let _ = replace_placeholders(&orig, &re, &dest, &ac);
470        }
471
472        /// When the regex does NOT match, `replace_placeholders` MUST
473        /// return an `Err` (no silent fallthrough).
474        #[test]
475        fn prop_replace_placeholders_no_match_returns_err(
476            // A leading char that prevents the regex from anchoring.
477            tail in "[a-zA-Z0-9_.-]{0,64}",
478            dest in "\\PC{0,64}",
479        ) {
480            let orig = format!("no-leading-slash-{tail}");
481            let re = Regex::new(r"^/(.*)/(.*)/(.*)/(.*)/(.*)$").unwrap();
482            let ac = build_placeholder_replacer(&re);
483            prop_assert!(replace_placeholders(&orig, &re, &dest, &ac).is_err());
484        }
485
486        /// On a successful match, the produced destination MUST contain
487        /// only the captured substrings (or original literals) — i.e.
488        /// no `$N` placeholders survive for `N < captures_len()`.
489        #[test]
490        fn prop_replace_placeholders_substitutes_all_indices(
491            a in "[a-zA-Z0-9]{1,16}",
492            b in "[a-zA-Z0-9]{1,16}",
493            c in "[a-zA-Z0-9]{1,16}",
494            d in "[a-zA-Z0-9]{1,16}",
495            e in "[a-zA-Z0-9]{1,16}",
496        ) {
497            let orig = format!("/{a}/{b}/{c}/{d}/{e}");
498            let re = Regex::new(r"^/(.*)/(.*)/(.*)/(.*)/(.*)$").unwrap();
499            let ac = build_placeholder_replacer(&re);
500            let dest = "/$0|$1|$2|$3|$4|$5".to_string();
501            let out = replace_placeholders(&orig, &re, &dest, &ac).unwrap();
502            // $0 = whole match (orig), then capture groups 1..=5.
503            let expected = format!("/{orig}|{a}|{b}|{c}|{d}|{e}");
504            prop_assert_eq!(out, expected);
505        }
506    }
507
508    /// `MAX_URI_LEN_FOR_REGEX` is a security/perf invariant; this test
509    /// keeps it as a tripwire if anyone ever lowers it accidentally.
510    #[test]
511    fn max_uri_len_for_regex_is_at_least_8kib() {
512        const { assert!(MAX_URI_LEN_FOR_REGEX >= 8 * 1024) };
513    }
514}