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<HeaderValue> {
83 let host = req.headers().get(HOST)?.to_str().ok()?;
84
85 let hostname = host
86 .rsplit_once(':')
87 .filter(|(_, port)| port.parse::<u16>().is_ok())
88 .map_or(host, |(h, _)| h);
89
90 let authority = self
91 .https_port
92 .map_or_else(|| hostname.to_owned(), |port| format!("{hostname}:{port}"));
93
94 let path_and_query = req
95 .uri()
96 .path_and_query()
97 .map_or("/", http::uri::PathAndQuery::as_str);
98
99 HeaderValue::from_str(&format!("https://{authority}{path_and_query}")).ok()
100 }
101}
102
103#[derive(Clone, Debug, Default)]
120pub struct RedirectHttpsLayer {
121 config: RedirectHttps,
122}
123
124impl RedirectHttpsLayer {
125 #[must_use]
127 pub const fn new(config: RedirectHttps) -> Self {
128 Self { config }
129 }
130}
131
132impl<S> Layer<S> for RedirectHttpsLayer {
133 type Service = RedirectHttpsService<S>;
134
135 fn layer(&self, inner: S) -> Self::Service {
136 RedirectHttpsService {
137 inner,
138 config: self.config.clone(),
139 }
140 }
141}
142
143#[derive(Clone, Debug)]
145pub struct RedirectHttpsService<S> {
146 inner: S,
147 config: RedirectHttps,
148}
149
150impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for RedirectHttpsService<S>
151where
152 S: Service<Request<ReqBody>, Response = Response<ResBody>>,
153 S::Future: Send + 'static,
154 S::Error: Send + 'static,
155 ResBody: Default + Send + 'static,
156{
157 type Response = Response<ResBody>;
158 type Error = S::Error;
159 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
160
161 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
162 self.inner.poll_ready(cx)
163 }
164
165 fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
166 if RedirectHttps::is_http(&req)
167 && let Some(location) = self.config.location(&req)
168 {
169 let status = self.config.status;
170 return Box::pin(async move {
171 let mut response = Response::new(ResBody::default());
172 *response.status_mut() = status;
173 response.headers_mut().insert(LOCATION, location);
174 Ok(response)
175 });
176 }
177 Box::pin(self.inner.call(req))
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use axum::{Router, body::Body, routing::get};
185 use http::StatusCode;
186 use tower::ServiceExt;
187
188 fn build_app(config: RedirectHttps) -> Router {
189 Router::new()
190 .route("/", get(|| async { "ok" }))
191 .layer(RedirectHttpsLayer::new(config))
192 }
193
194 async fn send(app: Router, req: http::Request<Body>) -> http::Response<Body> {
195 app.oneshot(req).await.unwrap()
196 }
197
198 fn forwarded_request(proto: &str, uri: &str) -> http::Request<Body> {
199 http::Request::builder()
200 .uri(uri)
201 .header(HOST, "example.com")
202 .header("x-forwarded-proto", proto)
203 .body(Body::empty())
204 .unwrap()
205 }
206
207 #[tokio::test]
208 async fn redirects_on_x_forwarded_proto_http() {
209 let response = send(
210 build_app(RedirectHttps::new()),
211 forwarded_request("http", "/path?q=1"),
212 )
213 .await;
214
215 assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
216 assert_eq!(
217 response.headers()["location"],
218 "https://example.com/path?q=1"
219 );
220 }
221
222 #[tokio::test]
223 async fn passes_through_on_x_forwarded_proto_https() {
224 let response = send(
225 build_app(RedirectHttps::new()),
226 forwarded_request("https", "/"),
227 )
228 .await;
229 assert_eq!(response.status(), StatusCode::OK);
230 }
231
232 #[tokio::test]
233 async fn redirects_on_http_uri_scheme() {
234 let req = http::Request::builder()
235 .uri("http://example.com/page")
236 .header(HOST, "example.com")
237 .body(Body::empty())
238 .unwrap();
239 let response = send(build_app(RedirectHttps::new()), req).await;
240
241 assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
242 assert_eq!(response.headers()["location"], "https://example.com/page");
243 }
244
245 #[tokio::test]
246 async fn passes_through_when_no_scheme_indicator() {
247 let req = http::Request::builder()
248 .uri("/")
249 .header(HOST, "example.com")
250 .body(Body::empty())
251 .unwrap();
252 let response = send(build_app(RedirectHttps::new()), req).await;
253 assert_eq!(response.status(), StatusCode::OK);
254 }
255
256 #[tokio::test]
257 async fn passes_through_when_no_host_header() {
258 let req = http::Request::builder()
259 .uri("/")
260 .header("x-forwarded-proto", "http")
261 .body(Body::empty())
262 .unwrap();
263 let response = send(build_app(RedirectHttps::new()), req).await;
264 assert_eq!(response.status(), StatusCode::OK);
265 }
266
267 #[tokio::test]
268 async fn custom_status_301() {
269 let config = RedirectHttps::new().status(StatusCode::MOVED_PERMANENTLY);
270 let response = send(build_app(config), forwarded_request("http", "/")).await;
271 assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
272 }
273
274 #[tokio::test]
275 async fn strips_http_port_from_host() {
276 let req = http::Request::builder()
277 .uri("/path")
278 .header(HOST, "example.com:80")
279 .header("x-forwarded-proto", "http")
280 .body(Body::empty())
281 .unwrap();
282 let response = send(build_app(RedirectHttps::new()), req).await;
283 assert_eq!(response.headers()["location"], "https://example.com/path");
284 }
285
286 #[tokio::test]
287 async fn custom_https_port() {
288 let config = RedirectHttps::new().https_port(8443);
289 let req = http::Request::builder()
290 .uri("/path")
291 .header(HOST, "example.com:8080")
292 .header("x-forwarded-proto", "http")
293 .body(Body::empty())
294 .unwrap();
295 let response = send(build_app(config), req).await;
296 assert_eq!(
297 response.headers()["location"],
298 "https://example.com:8443/path"
299 );
300 }
301
302 #[tokio::test]
303 async fn default_layer_uses_308() {
304 let app = Router::new()
305 .route("/", get(|| async { "ok" }))
306 .layer(RedirectHttpsLayer::default());
307 let response = send(app, forwarded_request("http", "/")).await;
308 assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
309 }
310}