Skip to main content

rune_axum_redirect_https/
layer.rs

1use http::{
2    Request, Response, StatusCode,
3    header::{HOST, HeaderValue, LOCATION},
4};
5use std::future::Future;
6use std::pin::Pin;
7use std::task::{Context, Poll};
8use tower::{Layer, Service};
9
10/// Configuration for the HTTP-to-HTTPS redirect middleware.
11///
12/// Build with [`RedirectHttps::new()`] and chain methods to adjust the redirect
13/// status code or target HTTPS port, then pass to [`RedirectHttpsLayer::new()`].
14///
15/// # Examples
16///
17/// ```rust
18/// use http::StatusCode;
19/// use rune_axum_redirect_https::RedirectHttps;
20///
21/// // HTTP on :8080 → HTTPS on :8443, 301 for legacy compatibility
22/// let config = RedirectHttps::new()
23///     .status(StatusCode::MOVED_PERMANENTLY)
24///     .https_port(8443);
25/// ```
26#[derive(Clone, Debug)]
27pub struct RedirectHttps {
28    status: StatusCode,
29    https_port: Option<u16>,
30}
31
32impl Default for RedirectHttps {
33    fn default() -> Self {
34        Self {
35            status: StatusCode::PERMANENT_REDIRECT,
36            https_port: None,
37        }
38    }
39}
40
41impl RedirectHttps {
42    /// Creates a `RedirectHttps` with defaults: `308 Permanent Redirect`, standard HTTPS port.
43    #[must_use]
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Sets the redirect status code.
49    ///
50    /// Defaults to `308 Permanent Redirect`, which preserves the HTTP method.
51    /// Use `301 Moved Permanently` when you need compatibility with older
52    /// clients that do not support 308.
53    ///
54    /// > [!WARNING]
55    /// > `301` converts POST to GET in many browsers and HTTP clients. Prefer
56    /// > `308` unless you have a specific reason to use `301`.
57    #[must_use]
58    pub const fn status(mut self, status: StatusCode) -> Self {
59        self.status = status;
60        self
61    }
62
63    /// Sets the HTTPS port in the redirect `Location` URL.
64    ///
65    /// When set, any port in the `Host` header is stripped and replaced with
66    /// this value. Useful when HTTP and HTTPS run on non-standard ports (e.g.
67    /// `8080` → `8443`). When unset the port is omitted from the URL, which
68    /// directs clients to the standard HTTPS port (443).
69    #[must_use]
70    pub const fn https_port(mut self, port: u16) -> Self {
71        self.https_port = Some(port);
72        self
73    }
74
75    fn is_http<B>(req: &Request<B>) -> bool {
76        if let Some(proto) = req.headers().get("x-forwarded-proto") {
77            return proto.as_bytes().eq_ignore_ascii_case(b"http");
78        }
79        req.uri().scheme() == Some(&http::uri::Scheme::HTTP)
80    }
81
82    /// The redirect target: `None` without a `Host` header, `Some(Err(()))`
83    /// when the `Host` header is not a plain host name or IP literal with an
84    /// optional port.
85    fn location<B>(&self, req: &Request<B>) -> Option<Result<HeaderValue, ()>> {
86        let host = req.headers().get(HOST)?;
87        Some(
88            host.to_str()
89                .ok()
90                .and_then(hostname)
91                .and_then(|hostname| self.location_for(hostname, req))
92                .ok_or(()),
93        )
94    }
95
96    fn location_for<B>(&self, hostname: &str, req: &Request<B>) -> Option<HeaderValue> {
97        let authority = self
98            .https_port
99            .map_or_else(|| hostname.to_owned(), |port| format!("{hostname}:{port}"));
100
101        let path_and_query = req
102            .uri()
103            .path_and_query()
104            .map_or("/", http::uri::PathAndQuery::as_str);
105
106        HeaderValue::from_str(&format!("https://{authority}{path_and_query}")).ok()
107    }
108}
109
110/// The host part of a `Host` header, or `None` unless it is a DNS name, an
111/// IPv4 address or a bracketed IPv6 literal, optionally followed by a port.
112///
113/// The value ends up in `Location`, so anything that could change where the
114/// URL points (`/`, `@`, `?`, `#`, `\`, ...) is refused rather than passed on.
115fn hostname(host: &str) -> Option<&str> {
116    let (name, port) = if host.starts_with('[') {
117        let end = host.find(']')?;
118        let (name, rest) = host.split_at(end + 1);
119        let inner = name.get(1..name.len() - 1)?;
120        if inner.is_empty()
121            || !inner
122                .bytes()
123                .all(|b| b.is_ascii_hexdigit() || b == b':' || b == b'.')
124        {
125            return None;
126        }
127        (
128            name,
129            if rest.is_empty() {
130                None
131            } else {
132                Some(rest.strip_prefix(':')?)
133            },
134        )
135    } else {
136        match host.rsplit_once(':') {
137            Some((name, port)) => (name, Some(port)),
138            None => (host, None),
139        }
140    };
141    if let Some(port) = port
142        && (port.is_empty()
143            || !port.bytes().all(|b| b.is_ascii_digit())
144            || port.parse::<u16>().is_err())
145    {
146        return None;
147    }
148    let plain = |b: u8| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_');
149    (name.starts_with('[') || (!name.is_empty() && name.bytes().all(plain))).then_some(name)
150}
151
152/// Tower [`Layer`] that redirects HTTP requests to HTTPS.
153///
154/// Apply with Axum's `.layer()` call. Use [`RedirectHttpsLayer::default()`] for
155/// a `308` redirect on standard ports, or [`RedirectHttpsLayer::new()`] to
156/// supply a custom [`RedirectHttps`] configuration.
157///
158/// # Examples
159///
160/// ```rust,no_run
161/// use axum::{routing::get, Router};
162/// use rune_axum_redirect_https::RedirectHttpsLayer;
163///
164/// let app: Router = Router::new()
165///     .route("/", get(|| async { "ok" }))
166///     .layer(RedirectHttpsLayer::default());
167/// ```
168#[derive(Clone, Debug, Default)]
169pub struct RedirectHttpsLayer {
170    config: RedirectHttps,
171}
172
173impl RedirectHttpsLayer {
174    /// Creates a `RedirectHttpsLayer` from a custom [`RedirectHttps`] configuration.
175    #[must_use]
176    pub const fn new(config: RedirectHttps) -> Self {
177        Self { config }
178    }
179}
180
181impl<S> Layer<S> for RedirectHttpsLayer {
182    type Service = RedirectHttpsService<S>;
183
184    fn layer(&self, inner: S) -> Self::Service {
185        RedirectHttpsService {
186            inner,
187            config: self.config.clone(),
188        }
189    }
190}
191
192/// Tower [`Service`] produced by [`RedirectHttpsLayer`].
193#[derive(Clone, Debug)]
194pub struct RedirectHttpsService<S> {
195    inner: S,
196    config: RedirectHttps,
197}
198
199impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for RedirectHttpsService<S>
200where
201    S: Service<Request<ReqBody>, Response = Response<ResBody>>,
202    S::Future: Send + 'static,
203    S::Error: Send + 'static,
204    ResBody: Default + Send + 'static,
205{
206    type Response = Response<ResBody>;
207    type Error = S::Error;
208    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
209
210    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
211        self.inner.poll_ready(cx)
212    }
213
214    fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
215        if RedirectHttps::is_http(&req)
216            && let Some(location) = self.config.location(&req)
217        {
218            let status = self.config.status;
219            return Box::pin(async move {
220                let mut response = Response::new(ResBody::default());
221                match location {
222                    Ok(location) => {
223                        *response.status_mut() = status;
224                        response.headers_mut().insert(LOCATION, location);
225                    }
226                    // Never redirect to whatever a forged `Host` says.
227                    Err(()) => *response.status_mut() = StatusCode::BAD_REQUEST,
228                }
229                Ok(response)
230            });
231        }
232        Box::pin(self.inner.call(req))
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use axum::{Router, body::Body, routing::get};
240    use http::StatusCode;
241    use tower::ServiceExt;
242
243    fn build_app(config: RedirectHttps) -> Router {
244        Router::new()
245            .route("/", get(|| async { "ok" }))
246            .layer(RedirectHttpsLayer::new(config))
247    }
248
249    async fn send(app: Router, req: http::Request<Body>) -> http::Response<Body> {
250        app.oneshot(req).await.unwrap()
251    }
252
253    fn forwarded_request(proto: &str, uri: &str) -> http::Request<Body> {
254        http::Request::builder()
255            .uri(uri)
256            .header(HOST, "example.com")
257            .header("x-forwarded-proto", proto)
258            .body(Body::empty())
259            .unwrap()
260    }
261
262    #[tokio::test]
263    async fn redirects_on_x_forwarded_proto_http() {
264        let response = send(
265            build_app(RedirectHttps::new()),
266            forwarded_request("http", "/path?q=1"),
267        )
268        .await;
269
270        assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
271        assert_eq!(
272            response.headers()["location"],
273            "https://example.com/path?q=1"
274        );
275    }
276
277    #[tokio::test]
278    async fn passes_through_on_x_forwarded_proto_https() {
279        let response = send(
280            build_app(RedirectHttps::new()),
281            forwarded_request("https", "/"),
282        )
283        .await;
284        assert_eq!(response.status(), StatusCode::OK);
285    }
286
287    #[tokio::test]
288    async fn redirects_on_http_uri_scheme() {
289        let req = http::Request::builder()
290            .uri("http://example.com/page")
291            .header(HOST, "example.com")
292            .body(Body::empty())
293            .unwrap();
294        let response = send(build_app(RedirectHttps::new()), req).await;
295
296        assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
297        assert_eq!(response.headers()["location"], "https://example.com/page");
298    }
299
300    #[tokio::test]
301    async fn passes_through_when_no_scheme_indicator() {
302        let req = http::Request::builder()
303            .uri("/")
304            .header(HOST, "example.com")
305            .body(Body::empty())
306            .unwrap();
307        let response = send(build_app(RedirectHttps::new()), req).await;
308        assert_eq!(response.status(), StatusCode::OK);
309    }
310
311    #[tokio::test]
312    async fn passes_through_when_no_host_header() {
313        let req = http::Request::builder()
314            .uri("/")
315            .header("x-forwarded-proto", "http")
316            .body(Body::empty())
317            .unwrap();
318        let response = send(build_app(RedirectHttps::new()), req).await;
319        assert_eq!(response.status(), StatusCode::OK);
320    }
321
322    #[tokio::test]
323    async fn custom_status_301() {
324        let config = RedirectHttps::new().status(StatusCode::MOVED_PERMANENTLY);
325        let response = send(build_app(config), forwarded_request("http", "/")).await;
326        assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
327    }
328
329    #[tokio::test]
330    async fn strips_http_port_from_host() {
331        let req = http::Request::builder()
332            .uri("/path")
333            .header(HOST, "example.com:80")
334            .header("x-forwarded-proto", "http")
335            .body(Body::empty())
336            .unwrap();
337        let response = send(build_app(RedirectHttps::new()), req).await;
338        assert_eq!(response.headers()["location"], "https://example.com/path");
339    }
340
341    #[tokio::test]
342    async fn custom_https_port() {
343        let config = RedirectHttps::new().https_port(8443);
344        let req = http::Request::builder()
345            .uri("/path")
346            .header(HOST, "example.com:8080")
347            .header("x-forwarded-proto", "http")
348            .body(Body::empty())
349            .unwrap();
350        let response = send(build_app(config), req).await;
351        assert_eq!(
352            response.headers()["location"],
353            "https://example.com:8443/path"
354        );
355    }
356
357    #[tokio::test]
358    async fn default_layer_uses_308() {
359        let app = Router::new()
360            .route("/", get(|| async { "ok" }))
361            .layer(RedirectHttpsLayer::default());
362        let response = send(app, forwarded_request("http", "/")).await;
363        assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
364    }
365
366    #[tokio::test]
367    async fn hostile_host_header_never_becomes_a_redirect() {
368        for host in [
369            "//evil.example",
370            "evil.example/",
371            "good.example@evil.example",
372            "evil.example#",
373            "evil.example?",
374            "good.example\\@evil.example",
375            "",
376            ":443",
377            "[::1",
378            "example.com:99999",
379        ] {
380            let req = http::Request::builder()
381                .uri("/path")
382                .header(HOST, host)
383                .header("x-forwarded-proto", "http")
384                .body(Body::empty())
385                .unwrap();
386            let response = send(build_app(RedirectHttps::new()), req).await;
387            assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{host}");
388            assert!(response.headers().get("location").is_none(), "{host}");
389        }
390    }
391
392    #[tokio::test]
393    async fn ip_literal_hosts_redirect() {
394        for (host, location) in [
395            ("[::1]:8080", "https://[::1]/"),
396            ("[2001:db8::1]", "https://[2001:db8::1]/"),
397            ("127.0.0.1:80", "https://127.0.0.1/"),
398            ("Example.COM", "https://Example.COM/"),
399        ] {
400            let req = http::Request::builder()
401                .uri("/")
402                .header(HOST, host)
403                .header("x-forwarded-proto", "http")
404                .body(Body::empty())
405                .unwrap();
406            let response = send(build_app(RedirectHttps::new()), req).await;
407            assert_eq!(response.headers()["location"], location, "{host}");
408        }
409    }
410}