rune_axum_redirect_https/
layer.rs1use 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#[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 #[must_use]
44 pub fn new() -> Self {
45 Self::default()
46 }
47
48 #[must_use]
58 pub const fn status(mut self, status: StatusCode) -> Self {
59 self.status = status;
60 self
61 }
62
63 #[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 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
110fn 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#[derive(Clone, Debug, Default)]
169pub struct RedirectHttpsLayer {
170 config: RedirectHttps,
171}
172
173impl RedirectHttpsLayer {
174 #[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#[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 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}